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
