1. Что такое Live Activities?
Для начала стоит разобраться, что же такое Live Activities и как эту концепцию видит Apple.
Активность в режиме реального времени отображает актуальную информацию из вашего приложения, позволяя людям сразу увидеть ход выполнения деятельности, события или задачи.
Другими словами, это уведомление, которое динамически меняет свое содержимое в зависимости от происходящих событий. Например, можно показывать обновления статуса доставки, отслеживать прогресс задачи или даже текущий счет в спортивном матче. Такие уведомления всегда актуальны и информативны, при этом пользователю не нужно открывать приложение.
Несомненно, это невероятно удобно. Однако, к сожалению, не каждая платформа предлагает такую функциональность по умолчанию. Изначально Live Activities были эксклюзивной фишкой Apple и реализовывались через фреймворк ActivityKit. Но эта часть про Android, а он не предоставляет аналогичный механизм из коробки.
Здесь на помощь приходит RemoteViews — класс в Android, который описывает иерархию представлений, способную отображаться в другом процессе. Эта иерархия создается на основе XML-разметки, а RemoteViews предоставляет базовые операции для изменения содержимого этих представлений. Проще говоря, это удобный инструмент для динамического обновления контента уведомлений, позволяющий изменять элементы UI, такие как текст, индикатор прогресса и изображения, в реальном времени.
Но кто дает теорию без примера?) Для наглядности разберем процесс создания такой функциональности на моковых данных. Цель, которую я себе поставил будет выглядеть как-то так:

2. Создаем свой аналог Live Activity
Этап 1. Создаем данные
Для начала необходимо определить, какие данные мы будем использовать в этих уведомлениях. Поэтому логично начать с создания отдельного класса, который будет ответственен за управление этими данными.
class LiveActivityModel {
int stage;
int minutesToDelivery;
int stagesCount;
LiveActivityModel({
required this.stage,
required this.minutesToDelivery,
required this.stagesCount,
});
factory LiveActivityModel.fromJson(Map<String, dynamic> json) {
return LiveActivityModel(
stage: json['stage'] as int,
minutesToDelivery: json['minutesToDelivery'] as int,
stagesCount: json['stagesCount'] as int,
);
}
Map<String, dynamic> toJson() {
return {
'stage': stage,
'minutesToDelivery': minutesToDelivery,
'stagesCount': stagesCount,
};
}
}
Этап 2. Создаем связи
Для настройки механизма связи между Flutter-приложением и нативной Android-частью необходимо создать MethodChannel. Это позволит отправлять команды из Flutter в нативное приложение и получать ответы от Android.
Для управления уведомлениями создадим класс LiveActivityAndroidService
, который будет содержать все методы для взаимодействия с нативной частью приложения. В этом классе определим методы для запуска, обновления, завершения и окончания уведомлений.
Кроме того, данные сразу будем преобразовывать в JSON, чтобы в Android-части можно было обращаться к ним по ключам.
class LiveActivityAndroidService {
// Важно использовать везде одно название!
final MethodChannel _method =
const MethodChannel("flutterAndroidLiveActivity");
Future<void> startNotifications({required LiveActivityModel data}) async {
try {
await _method.invokeMethod("startDelivery", data.toJson());
} on PlatformException catch (e) {
throw PlatformException(code: e.code);
}
}
Future<void> updateNotifications({required LiveActivityModel data}) async {
try {
await _method.invokeMethod("updateDeliveryStatus", data.toJson());
} on PlatformException catch (e) {
throw PlatformException(code: e.code);
}
}
Future<void> finishNotifications({required LiveActivityModel data}) async {
try {
await _method.invokeMethod("finishDelivery", data.toJson());
} on PlatformException catch (e) {
throw PlatformException(code: e.code);
}
}
Future<void> endNotifications({required LiveActivityModel data}) async {
try {
await _method.invokeMethod("endNotifications");
} on PlatformException catch (e) {
throw PlatformException(code: e.code);
}
}
}
Чтобы не только Flutter знал, как обрабатывать эти запросы, нужно уведомить и Android. Для этого мы переопределим метод configureFlutterEngine в Android-части приложения(android\app\src\...\MainActivity.kt), где свяжем Flutter с соответствующими функциями в нативной части.
class MainActivity : FlutterActivity() {
// Тот же канал, который мы указывали в нашем классе для методов
private val CHANNEL = "flutterAndroidLiveActivity"
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL).setMethodCallHandler { call, result ->
if (call.method == "startDelivery") {
// ...Обработка вызова метода "startDelivery"
} else if (call.method == "updateDeliveryStatus") {
// ...Обработка вызова метода "updateDeliveryStatus"
} else if (call.method == "finishDelivery") {
// ...Обработка вызова метода "finishDelivery"
} else if (call.method == "endNotifications") {
// ...Обработка вызова метода "endNotifications"
}
}
}
}
Сами того не замечая, мы уже сделали треть для разработки нашего "Live Activity". Теперь самая интересная часть :>
Этап 3. Плавное погружение в натив
Чтобы заложить каркас нашего самого дефолтного уведомления нам нужно его создать. Файл должен находиться в android\app\src\main\res\layout с расширением xml.
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<!-- Текст со статусом заказа -->
<TextView
android:id="@+id/order_status"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Your order is being processed"
android:textSize="16sp"
android:textColor="#000000"
android:layout_marginBottom="8dp" />
<!-- Текст со этапом заказа -->
<TextView
android:id="@+id/stage_status"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Stage status 1/4"
android:textSize="16sp"
android:textStyle="bold"
android:shadowColor="#AA000000"
android:textColor="#000000"
android:layout_marginBottom="8dp"
android:gravity="start" />
<!-- Картинка (иконка этапа доставки) -->
<ImageView
android:id="@+id/image_stage"
android:layout_width="wrap_content"
android:layout_height="100dp"
android:layout_marginBottom="8dp"
android:src="@drawable/stage1"
/>
</LinearLayout>
Как было упомянуто ранее всё наше уведомление, по сути своей - это динамически меняющийся контент XML-разметки. Поэтому очень хорошо, если вы его знаете. Если нет, то гугл чатгпт в помощь) Тут я разберу ключевые моменты:android:id
(для текста) и android:src
(для изображения) — это два ключевых атрибута, с которыми мы будем работать почти всегда. Они создают связь между RemoteViews и нашей разметкой, позволяя динамически обновлять содержимое уведомлений и взаимодействовать с элементами интерфейса. Если с текстом все просто — передаем строку, и она сразу отображается, — то с изображениями ситуация немного сложнее. Все используемые изображения нужно заранее разместить в android/app/src/main/res/drawable, чтобы с ними можно было работать в уведомлениях. При этом важно учитывать рекомендации Android:
Android поддерживает растровые файлы следующих форматов: PNG (предпочтительно), WEBP (предпочтительно, требуется уровень API 17 или выше), JPG (допустимо), GIF (не рекомендуется).
Этап 4. Самый натив :)
Как и Flutter мы создадим класс который будет отвечать за весь процесс с уведомлениями.
/// Для версии андроида >= 8.0
@RequiresApi(Build.VERSION_CODES.O)
class LiveActivityManager(private val context: Context) {
// Кастомная разметка уведомлений, связанная с именем пакета и XML-разметкой
private val remoteViews = RemoteViews("com.example.live_acticity_article", R.layout.live_notification)
// Уникальный идентификатор уведомления для его отображения и обновления
private val notificationId = 100
// Канал уведомлений с высоким приоритетом для важных уведомлений
private val channelWithHighPriority = "channelWithHighPriority"
// Канал уведомлений с обычным приоритетом для менее важных уведомлений
private val channelWithDefaultPriority = "channelWithDefaultPriority"
// Переменная для открытия MainActivity при взаимодействии с уведомлением
private val pendingIntent = PendingIntent.getActivity(
context,
200,
Intent(context, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_REORDER_TO_FRONT
},
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
// Сервис для управления уведомлениями на устройстве
private val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
init {
createNotificationChannel(channelWithDefaultPriority)
createNotificationChannel(channelWithHighPriority, true)
}
// Функция для создания каналов уведомлений
private fun createNotificationChannel(channelName: String, importanceHigh: Boolean = false) {
val importance = if (importanceHigh) NotificationManager.IMPORTANCE_HIGH else NotificationManager.IMPORTANCE_DEFAULT
val existingChannel = notificationManager.getNotificationChannel(channelName)
if (existingChannel == null) {
val channel = NotificationChannel(channelName, "Delivery Notification", importance).apply {
setSound(null, null)
vibrationPattern = longArrayOf(0L)
}
notificationManager.createNotificationChannel(channel)
}
}
// 1 стадия - Заказ оформлен
private fun onFirstNotification(): Notification {
return Notification.Builder(context, channelWithHighPriority)
.setSmallIcon(R.drawable.notification_icon)
.setContentTitle("Live Notification - Your order has been processed")
.setContentIntent(pendingIntent)
.setWhen(3000)
.setOngoing(true)
.setCustomBigContentView(remoteViews)
.build()
}
// 2 стадия - Заказ начал собираться
private fun onGoingNotification(): Notification {
return Notification.Builder(context, channelWithDefaultPriority)
.setSmallIcon(R.drawable.notification_icon)
.setContentTitle("Live Notification - Your order is being collected")
.setContentIntent(pendingIntent)
.setOngoing(true)
.setCustomBigContentView(remoteViews)
.build()
}
// 3 стадия - Заказ в пути
private fun onOrderOnTheWayNotification(minutesToDelivery: Int): Notification {
val minuteString = if (minutesToDelivery > 1) "minutes" else "minute"
return Notification.Builder(context, channelWithHighPriority)
.setSmallIcon(R.drawable.notification_icon)
.setContentIntent(pendingIntent)
.setOngoing(true)
.setContentTitle("Live Notification - Your order is on its way and will be delivered in $minutesToDelivery $minuteString")
.setCustomBigContentView(remoteViews)
.build()
}
// 4 стадия - Заказ доставлен
private fun onFinishNotification(): Notification {
return Notification.Builder(context, channelWithHighPriority)
.setSmallIcon(R.drawable.notification_icon)
.setContentTitle("Live Notification - Your order is delivered")
.setContentIntent(pendingIntent)
.setAutoCancel(true)
.setCustomBigContentView(remoteViews)
.build()
}
// Функция для отображения уведомления первой стадии
fun showNotification(stage: Int, stagesCount: Int) {
val notification = onFirstNotification()
remoteViews.setTextViewText(R.id.order_status, "Your order has been processed and will be collected soon")
remoteViews.setTextViewText(R.id.stage_status, "Stage status $stage/$stagesCount")
notificationManager.notify(notificationId, notification)
}
// Функция для обновления и отображения уведомления второй и третьей стадий
fun updateNotification(minutesToDelivery: Int, stage: Int, stagesCount: Int) {
val minuteString = if (minutesToDelivery > 1) "minutes" else "minute"
when (stage) {
2 -> {
remoteViews.setTextViewText(R.id.order_status, "Your order is being assembled and will be shipped to you soon")
remoteViews.setImageViewResource(R.id.image_stage, R.drawable.stage2)
}
3 -> {
remoteViews.setTextViewText(R.id.order_status, "Your order is on its way and will be delivered in $minutesToDelivery $minuteString")
remoteViews.setImageViewResource(R.id.image_stage, R.drawable.stage3)
}
}
remoteViews.setTextViewText(R.id.stage_status, "Stage status $stage/$stagesCount")
val notification: Notification? = when (stage) {
2 -> {
onGoingNotification()
}
3 -> {
onOrderOnTheWayNotification(minutesToDelivery)
}
else -> null
}
if (notification != null) {
notificationManager.notify(notificationId, notification)
} else {
println("Error: Notification is null.")
}
notificationManager.notify(notificationId, notification)
}
// Функция для отображения уведомления четвертой стадии
fun finishDeliveryNotification(stage: Int, stagesCount: Int) {
val notification = onFinishNotification()
remoteViews.setTextViewText(R.id.order_status, "Your order is delivered. Enjoy your purchase!")
remoteViews.setImageViewResource(R.id.image_stage, R.drawable.stage4)
remoteViews.setTextViewText(R.id.stage_status, "Stage status $stage/$stagesCount")
notificationManager.notify(notificationId, notification)
}
// Функция для удаления каналов при окончании жизненного цикла уведомления (просто скрыли)
fun endNotification() {
val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
notificationManager.deleteNotificationChannel(channelWithHighPriority)
notificationManager.deleteNotificationChannel(channelWithDefaultPriority)
remoteViews.setViewVisibility(R.id.order_status, View.GONE)
}
}
Если у вас глаза стали по 5 копеек, не переживайте — сейчас разберем основные моменты в этом, пусть и относительно повторяющемся, но важном коде.
Ключевой момент — это переме нная remoteViews
, о которой говорится все это время. Этот объект связывает разметку, которая будет отображаться в уведомлениях, с текущими данными, такими как статус заказа, изображение и текст. В данном случае RemoteViews инициализируется через имя пакета и разметку, которую мы заранее создали в XML, и позволяет динамически обновлять содержимое уведомления.
// 1 стадия - Заказ оформлен
private fun onFirstNotification(): Notification {
// Создаем новый билд уведомления с указанием канала с высоким приоритетом
return Notification.Builder(context, channelWithHighPriority)
// Устанавливаем маленькую иконку для уведомления
.setSmallIcon(R.drawable.notification_icon)
// Устанавливаем заголовок уведомления
.setContentTitle("Live Notification - Your order has been processed")
// Устанавливаем действие при нажатии на уведомление - открывается MainActivity
.setContentIntent(pendingIntent)
// Устанавливаем флаг, чтобы уведомление оставалось на экране
.setOngoing(true)
// Используем кастомную разметку для уведомления, связанную с remoteViews
.setCustomBigContentView(remoteViews)
// Строим и возвращаем уведомление
.build()
}
onFirstNotification()
и подобные ей приватные функции отвечают за создание «скелета» уведомления для «Live Activity», которое показывает статус заказа. Мы настраиваем его с помощью Notification.Builder
, добавляя иконку, заголовок и действие при нажатии. Чтобы уведомление не пропадало, используем setOngoing(true)
, а привязка через setCustomBigContentView
позволит нам менять содержимое этого уведомления в будущем.
// Функция для отображения уведомления первой стадии
fun showNotification(stage: Int, stagesCount: Int) {
val notification = onFirstNotification()
remoteViews.setTextViewText(R.id.order_status, "Your order has been processed and will be collected soon")
/* remoteViews.setImageViewResource(R.id.image_stage, R.drawable.stage2)
Для установки изображений мы бы использовали такую конструкцию, но поскольку
у нас картинка по умолчанию - это картинка 1 этапа(стадии), то меня все устраивает.
*/
remoteViews.setTextViewText(R.id.stage_status, "Stage status $stage/$stagesCount")
notificationManager.notify(notificationId, notification)
}
А как раз такие публичные функции берутэтот «скелет» и наполняют его актуальными данными. Они обновляют текстовые поля в remoteViews, напирмер, отображая текущий статус заказа и этап выполнения. Далее передают готовое уведомление в notificationManager, чтобы оно появилось на экране или обновилось, если уже активно.
Единственный, кто отличается логикой в своем теле, это:
fun endNotification() {
// Получаем NotificationManager для работы с уведомлениями
val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
// Удаляем каналы уведомлений с высоким и обычным приоритетом
notificationManager.deleteNotificationChannel(channelWithHighPriority)
notificationManager.deleteNotificationChannel(channelWithDefaultPriority)
// Скрываем элементы UI, которые отображают статус заказа и статус этапа
remoteViews.setViewVisibility(R.id.order_status, View.GONE)
remoteViews.setViewVisibility(R.id.stage_status, View.GONE)
}
Как несложно догадаться, функция endNotification()
предназначена для завершения работы с уведомлениями. Она удаляет каналы уведомлений и скрывает элементы UI, отображающий статус заказа и статус этапа, что-то вроде ручного завершения или очистки. Мы будем вызывать её, когда пользователь скрывает уведомления, чтобы избежать лишней нагрузки на систему и освободить ресурсы.
Самое сложное позади! Осталось только завершить нативную часть, дописав методы, которые мы только что создали, и связать их с логикой обработки уведомлений.
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL).setMethodCallHandler {
call, result ->
if (call.method == "startDelivery") {
val args = call.arguments<Map<String, Any>>()
val stage = args?.get("stage") as? Int
val stagesCount = args?.get("stagesCount") as? Int
if (stage != null && stagesCount != null) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
LiveActivityManager(this@MainActivity).showNotification(stage, stagesCount)
}
}
result.success("Notification displayed")
} else if (call.method == "updateDeliveryStatus") {
val args = call.arguments<Map<String, Any>>()
val minutes = args?.get("minutesToDelivery") as? Int
val stage = args?.get("stage") as? Int
val stagesCount = args?.get("stagesCount") as? Int
if (minutes != null && stage != null && stagesCount != null){
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
LiveActivityManager(this@MainActivity).updateNotification(minutes, stage, stagesCount)
}
}
result.success("Notification updated")
} else if (call.method == "finishDelivery") {
val args = call.arguments<Map<String, Any>>()
val stage = args?.get("stage") as? Int
val stagesCount = args?.get("stagesCount") as? Int
if (stage != null && stagesCount != null) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
LiveActivityManager(this@MainActivity)
.finishDeliveryNotification(stage, stagesCount)
}
}
result.success("Notification delivered")
} else if (call.method == "endNotifications") {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
LiveActivityManager(this@MainActivity)
.endNotification()
}
result.success("Notification cancelled")
}
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
ActivityCompat.requestPermissions(this, permissions, 200)
}
}
override fun onStop() {
super.onStop()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
LiveActivityManager(context).endNotification()
}
}
Основное, что стоит отметить, — это то, как мы связали Flutter и нативный код Android через MethodChannel. С помощью этого канала можно передавать данные из Flutter в Android и обратно. Например, при вызове startDelivery
, updateDeliveryStatus
или finishDelivery
мы получаем данные, такие как минуты или общее количество стадий доставки, и на их основе запускаем или обновляем уведомления через LiveActivityManager
.
Проверки версии SDK не менее важны, чем всё остальное. Например, перед использованием функций уведомлений, доступных только в определённых версиях Android, необходимо убедиться, что устройство поддерживает их. Так, если версия SDK соответствует Android 8.0 (Oreo) или выше (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O), можно безопасно создавать каналы уведомлений. Кроме того, начиная с Android 13 (Tiramisu), в системе появились дополнительные ограничения и изменения в поведении уведомлений, которые также стоит учитывать при разработке.
Стоит также обратить внимание на val args = call.arguments<Map<String, Any>>(). Это ключевой элемент, который позволяет принимать данные, передаваемые из Flutter в JSON формате. В нашем случае это информация о стадии доставки, количестве минут и других параметрах, необходимых для отправки уведомлений.
Этап 5. Радуемся и смотрим на результат
Теперь осталось завершить работу во Flutter и посмотреть, что у нас получилось.
class HomeScreen extends StatefulWidget {
const HomeScreen({super.key});
@override
State<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen> {
LiveActivityAndroidService liveActivityService = LiveActivityAndroidService();
LiveActivityModel liveActivityModel =
LiveActivityModel(stage: 1, minutesToDelivery: 10, stagesCount: 4);
Timer? timer;
@override
void dispose() {
endNotifications();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
centerTitle: true,
title: const Text("Live activity in Android"),
),
body: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Center(
child: ElevatedButton(
onPressed: () async {
setState(() {
liveActivityModel = LiveActivityModel(
stage: 1, minutesToDelivery: 10, stagesCount: 4);
});
await liveActivityService
.startNotifications(data: liveActivityModel)
.then((value) {
startNotifications();
});
},
child: const Text("Start Notifications"),
),
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () {
endNotifications();
},
child: const Text("End Notifications"),
)
],
),
);
}
void startNotifications() {
timer?.cancel();
timer = Timer.periodic(const Duration(seconds: 10), (value) async {
liveActivityModel.stage += 1;
liveActivityModel.minutesToDelivery -= 3;
if (liveActivityModel.stage == 2 || liveActivityModel.stage == 3) {
await liveActivityService.updateNotifications(data: liveActivityModel);
}
if (liveActivityModel.stage == 4) {
await liveActivityService
.finishNotifications(data: liveActivityModel)
.then((value) {
timer?.cancel();
});
}
});
}
void endNotifications() {
timer?.cancel();
setState(() {
liveActivityModel =
LiveActivityModel(stage: 1, minutesToDelivery: 10, stagesCount: 4);
});
liveActivityService.endNotifications(data: liveActivityModel);
}
}
Напишем UI, который содержит две кнопки: одна отвечает за запуск процесса отправки уведомлений, другая — за их остановку и сброс данных. При запуске создаётся экземпляр LiveActivityModel
, который хранит информацию о текущем этапе доставки, оставшемся времени и общем количестве этапов. После нажатия кнопки старта создаётся таймер, который каждые 10 секунд обновляет уведомление, изменяя данные о стадии доставки и времени до завершения. Когда процесс достигает финального этапа, уведомления автоматически завершаются. При остановке процесса все данные сбрасываются к начальному состоянию. В целом этого для эмуляции данных и отображения нашего «Live Activity» вполне достаточно)
3. Итог
Хотя в Android нет точного аналога Live Activity, то, что она предоставляет, уже достаточно хорошо и может покрывать задачи, для которых она была создана. Если у вас есть вопросы или предложения по улучшению — пишите в комментариях! А так же хочется сказать, что английский вариант статьи и сорсы есть у меня на сайте. А мои подписчики в тг всегда в курсе всех моих проделок и узнают все самыми первыми! Подписывайтесь, скоро будет еще одна не менее интересная статья! ;D