Как не наломать дров с Live Activity

Всем привет! Меня зовут Миша Шкутков, я разрабатываю пользовательское приложение Яндекс Go под iOS. В статье расскажу о Live Activity — фиче, которой Apple порадовала нас пару лет назад. Это тот самый виджет, с которым даже на залоченном айфоне видно, как машинка в прямом эфире едет к нам по линеечке. Если вы пользовались нашим супераппом, наверняка это видели. Расскажу, зачем нужен LA, как его внедрить и поддерживать после внедрения.

На русском Apple называет фичу «Эфир активности», но такое название мне не по душе, мой выбор — либо Live Activity, либо LA.

LA и другие фичи, которые делают процесс поездки наглядной и понятной для пользователя, внедряет команда Ridehailing. Узнайте больше о ней на отдельной странице.

Зачем нужен Live Activity

Знакомство с Live Activity начинается с того, что мы запрыгиваем в DeLorean, указываем на приборах 6 июня 2022 года, разгоняемся до 88 миль в час и оказываемся на конференции WWDC22.

Мы врываемся в калифорнийский Apple Park и видим на сцене Крейга Федериги, который вещает: «Смотрите, какую классную штуку мы сделали в iOS 16! Live Activity! Live Activity позволяет видеть часть вашего приложения на экране блокировки!» Ещё, наверное, он произносит слово «amazing» — ну, всё как обычно.

Крейг показывает на залоченном айфоне приложение Uber и говорит: «Гляньте, как теперь классно можно отслеживать такси!» И с этой секунды у нас появилась стопроцентная уверенность в том, что Live Activity должен быть в Яндекс Go.

Если серьёзно — у нас было три причины делать Live Activity:

  1. Показалось, что это реально удобно для пользователей.
  2. Нам самим интересно, как появление заметных и важных функций на экране блокировки повлияет на ключевые метрики.
  3. Apple обычно собирает списки приложений, которые поддерживают новые фичи iOS, чтобы продвигать их в App Store. Бесплатный фичеринг — тоже прикольно.

Live Activity нужен, чтобы показывать владельцам айфона информацию вашего приложения в самых заметных местах: на экране блокировки и в районе Dynamic Island — области экрана вокруг динамика и фронтальной камеры.

Фича уместна, когда у пользователя есть ограниченная по времени задача или событие — и их нужно мониторить. Самые очевидные примеры: спортивное мероприятие, всевозможные виды поездок… Но всё зависит только от нашей фантазии! Легко представить, что вы создаете Live Activity для процесса загрузки контента на сервер и показываете пользователю статус аплоада.

Знакомство с Live Activity

Снова прыгаем в DeLorean и перемещаемся в 7 сентября 2022 года на презентацию новых девайсов Apple, в частности iPhone 14 Pro и Pro Max, у которых есть новая область интерфейса под названием Dynamic Island. В этой области можно показывать Live Activity и, что особенно привлекательно, демонстрировать там информацию даже тогда, когда вы используете другие приложения.

Пожалуй, в этот момент нам хочется реализовать эту фичу еще сильнее, но в текущей версии 16.0 она недоступна. Виджет LA станет доступен только спустя некоторое время, с выходом беты iOS 16.1, и с этого момента начинается наше знакомство с ним. Предлагаю пройти этот путь вместе.

1. Что мы узнали из документации

  • Самое очевидное, что LA доступен с iOS 16.1.
  • У LA нет доступа в сеть, он не может обновлять себя сам.
  • Активный жизненный цикл LA — не более 8 часов. Через 8 часов он переходит в завершенное состояние и прекращает обновляться. И в этом состоянии может прожить еще 4 часа, после чего система его уберет.
  • Создать LA можно только тогда, когда приложение активно (в iOS 17.2 Apple убирает это ограничение и позволяет создавать LA по пушу). Если у нас есть предзаказ такси и мы хотим создать Live Activity перед поездкой — ничего не выйдет. Нужно как минимум прислать пользователю пуш, по которому он сможет перейти в приложение.
  • Единственный вид взаимодействия с LA в iOS 16 — переход в ваше приложение по диплинку. Всё, ноль интерактива. С iOS 17 появляется поддержка App Intents, которые позволяют обойти это ограничение.

2. Внешний вид

У LA четыре варианта отображения: один на экране блокировки и три в районе Dynamic Island. На экране это SwiftUI-вью, ограниченная по высоте 160 поинтами.

Варианты отображения в районе Dynamic Island:

  • Compact — выделяется место и слева, и справа от островка.
  • Minimal — выделяется часть или только справа, или только слева: места там немного, но можно втиснуть иконку или суперкороткий текст, и пользователи всё равно будут благодарны.
  • Expanded — раскрытое состояние, которое появляется либо когда мы взаимодействуем с LA пальцами, либо по какому-то внешнему событию.

Важно, что, если вы хотите работать с Live Activity, — нужно поддерживать все четыре состояния без исключений.

Возможно такое, что у пользователя запущены несколько LA одновременно — скажем, он вызвал Uber и заказал пиццу. В этом случае решение о том, какой из LA более приоритетный и как будет отображаться, принимается операционной системой.

Но несколько LA могут быть созданы даже в рамках одного приложения: например, в Яндекс Go можно вызвать несколько такси, а еще сделать заказ в Лавке. Тут у нас больше контроля за счет параметра relevanceScore, который можно задавать самостоятельно. Он будет определять приоритеты отображаемых Live Activities на экране блокировки и в районе Dynamic Island.

3. Обновление Live Activity

Как обновлять нашу «прелесть», если сама она ходить в сеть и обновляться не может? Очевидно, это должно делать либо наше приложение, либо наш сервер (если он есть). Рассмотрим оба варианта

Обновление из приложения

Алгоритм обновления из приложения такой:

  1. Базовая проверка. Проверяем версию ОС, смотрим, не запретил ли пользователь создавать вашему приложению Live Activity.
  2. Подготовка данных для начального состояния LA.
  3. Создание LA. Мы можем сделать это только в foreground, когда приложение активно. Или попробовать создать LA из бэкграунда — только ничего не создастся.
  4. Обновление состояния LA. Вызываем у созданного объекта метод Update.
  5. Завершение LA.

Нюанс: Live Activity и приложение — два независимых процесса. Поэтому возможна ситуация, когда приложение будет выгружено из памяти или пользователь смахнет его сам, а Live Activity останется жить и ждать обновлений, как Хатико своего хозяина. Нужно не забывать про этот случай и придумывать, как его обрабатывать корректно. Возможно, использовать бэкграунд-таск типа BGProcessingTask, прибегать к Location Services, будить приложение пушами…

Обновление с сервера

В обновлении Live Activity с сервера первые шаги те же:

  1. Базовые проверки.
  2. Формирование начального состояния. При этом клиент сам может не знать о начальном состоянии, нужно сходить на сервер и получить его.
  3. Создание Live Activity в foreground.

Дальше — отличия:

  1. Дополнительным параметром мы указываем, что обновляем LA с сервера, и система асинхронно выдает нам пуш-токен, по которому можно его обновлять.
  2. Этот токен надо зарегистрировать на сервере. Здесь два варианта:
    • Сервер говорит, что всё хорошо. Тогда мы следим за статусом LA.
    • Сервер говорит, что всё плохо. Тогда мы сразу закрываем LA, не оставляя его в начальном состоянии, потому что, скорее всего, не сможем его обновлять.
       
      Если пользователь смахнет LA — пожалуй, стоит сходить на сервер и дерегистировать токен, чтобы не совершались лишние вычисления на сервере.
  3. На протяжении всего жизненного цикла LA сервер обновляет его состояние по своей логике и может даже его завершить.

Нюансы серверного обновления:

  • Пуш-токены для приложения и для LA — разные, и для пушей Live Activity используются специфические поля, поэтому на бэкенде понадобится доработка.
  • На бэкенд ложится дополнительная нагрузка, нужна реализация логики обновлений: понадобится добавлять новые методы подписки и дерегистрации Live Activity.

Обновления с сервера лучше всего использовать, когда выполняются сразу все три условия: у вас есть сервер, он знает о событии, у вас «тонкий» клиент. Если хоть один из пунктов не соблюдается — лучше обновлять LA из приложения. Но строгих правил нет, при необходимости можно даже сочетать оба метода. В Такси все три условия, описанные выше, выполняются, поэтому наш выбор — обновления с сервера.

4. Жизненный цикл

Live Activity существует в четырех состояниях:

  • .active — обновления идут, Live Activity реально «лайв».
  • .dismissed — состояние, если пользователь смахнул LA или его убрала система.
  • .ended — завершенное состояние с остановкой обновлений.
  • .stale — особое состояние, куда Live Activity может уходить из активного и откуда возвращаться в .active обратно, — о его функции поговорим ниже.

Проблемы и нюансы интеграции

Дисклеймер: в этой статье КОДА НЕ БУДЕТ. Примеры кода есть в документации, а SwiftUI-вью и так достаточно простая. Поэтому рассказ будет не о классах и функциях, а о более общих наблюдениях.

Нюанс 1: частота обновлений

Пока мы ждали бэкенд, вышел релиз 16.2, где Apple исправляла баги версии 16.1. Уместно вспомнить классическую гифку. Впрочем, в разработке нам эту гифку и так никогда не удается забыть.

Обновление фиксило проблему, при которой многие Live Activities зависали и переставали обновляться. Для решения Apple добавила в iOS 16.2 настройку NSSupportsLiveActivitiesFrequentUpdates, которую надо было прописать в Info.plist.

Если эта переменная равна YES — троттлинг пушей выключается и пуши отбрасываются существенно реже, а Live Activity обновляется заметно лучше.

Нюанс 2: проблема с режимом сна

После первой же раскатки Live Activity мы начали получать от пользователей скриншоты, текст виджета на которых невозможно прочесть.

Этот эффект проявлялся при переходе в режим сна. Если у человека включена светлая тема, а девайс переходит в sleeping mode, то тема телефона меняется на темную, но оформление LA остается в основной, светлой, теме, и черный текст растворяется на темном фоне.

Идей по исправлению было много, но мы выбрали единственный гарантированно работающий способ — поставить свой нестандартный цвет фона и в светлую тему (посередине), и в темную тему (справа). Так и решили проблему.

Пусть итоговый цвет подложки чуть отличается от «родных» пушей, зато нам не приходится извиняться за нечитаемый текст.

Напомню, Яндекс Go — это суперапп. В какой-то момент мы поддержали в Яндекс Go LA заказов, сделанных в Лавке. Сразу после встраивания модуля Лавки подложка виджета при заказах из Лавки отличалась по цвету от наших (слева) — там цвет оставался системным. Мы это исправили, и теперь оба виджета смотрятся консистентно — как уведомления одного приложения (справа).

Нюанс 3: новые диплинки

Поскольку всё взаимодействие c Live Activity в первых версиях iOS было ограничено диплинками, нам пришлось добавить недостающие. Так, например, для использования кнопки «Уже выхожу» и для формы оценки поездки их действительно не хватило. Всё решилось созданием для этих функций новых диплинков.

Отмечу: реализовывать диплинки несложно, но при создании каждой новой ссылки надо оценивать, не могут ли ее использовать злоумышленники, не создает ли она брешь в безопасности ваших приложений.

Нюанс 4: закрывать или подождать

Обманчиво простой вопрос: как и в какой момент скрывать Live Activity? Очевидное и не очень верное решение, которое мы испробовали вначале, — давать команду о скрытии Live Activity из самого приложения, а не с сервера. Поездка завершилась — убираем LA. Но пользователи часто сворачивают экран завершения поездки и уже не видят Live Activity. А виджет мог бы еще повисеть на виду и предложить пользователю оценить поездку или оставить водителю чаевые.

Мы решили не закрывать LA из приложения, а довериться бэкенду, у которого есть своя логика. Если бэкенд видит, что оценка не выставлена, а поездка завершена, он может еще некоторое время подержать LA, а потом прислать пуш и скрыть его.

Нюанс 5: неверный размер вью Live Activity

Эстетическая проблема, которую пока не решили, — сплющивание виджета Live Activity на экране блокировки. Слева — сплющенный образец, а справа — нормальный.

Проблема редкая и невоспроизводимая. Единственное объяснение, которое у нас есть: совмещать UI kit и SwiftUI — нетривиальная задача. Кажется, Apple и сама страдает от этого недостатка. Мы продолжаем искать решение.

Нюанс 6: VoiceOver

Каждую новую фичу мы тестируем на доступность для людей с ограниченными возможностями. Например, если включить на экране блокировки VoiceOver и перемещаться между элементами, то, когда фокус попадает на Live Activity, система не говорит, какое именно приложение мы наблюдаем. Это не работает даже для таймера: вы услышите, что на нём есть кнопки «Запустить» и «Остановить», а что именно надо запускать и останавливать — непонятно.

Мы добавили вводный мини-текст, который говорит, что виджет относится к приложению Яндекс Go. Реализация выполнена под экспериментальным конфигом бэкенда. И если Apple вдруг решит эту проблему у себя, то мы с какой-то версии iOS выключим эту фичу и дублирования информации не будет.

Нюанс 7: дублирование информации в пушах

Кстати, про дублирование. Информация из Live Activity может повторяться в обычных пушах. Решение элементарное — если LA активен, отключать пуши. К сожалению, именно для Такси этот способ не подходит. Дело в том, что Live Activity можно смахнуть, и мы узнаем об этом слишком поздно. А наше приложение связано с физическим миром, и пропущенное уведомление может привести к тому, что водитель будет намного дольше ожидать вас в машине. Так что подъезжающая машина, ожидающий водитель — важные события, и мы не хотим, чтобы пользователь ее пропустил.

Более того: сейчас если мы видим, что пуш не был показан пользователю, мы дублируем его в СМС, для этого есть специальная логика. Мы думали о том, чтобы мониторить ситуацию через NotificationServiceExtension и скрывать лишние пуши. Но в этом случае нельзя получить информацию об актуальных статусах Live Activity.

Пока хорошего решения не видим, поэтому закрываем глаза на дублирование.

Нюанс 8: цвет подложки в iOS 17

От новой операционной системы iOS 17 мы ждали фиксов и новых фич, а получили сюрприз: Live Activities стали темными во всех ситуациях, подложка буквально почернела. На картинке внизу светлая тема, вверху — темная, а выглядят виджеты одинаково.

Мы приняли это за баг, но по мере продвижения к релизу поняли, что, вероятно, именно так Apple исправила проблему sleeping mode, принудительно изменив нестандартный цвет на черный, чтобы избежать невидимого текста.

Мы поняли, что если убрать наш кастомный цвет и вернуться к activityBackgroundTint, станет лучше. Помня о проблеме iOS 16, мы оставили прежнее решение для версий ниже семнадцатой.

Присматриваем после запуска

Проблема отсутствия обновления

Даже после запуска Live Activity может заболеть, и его главная болезнь — остановка обновлений в определенный момент. Причина этого может скрываться и на сервере, и в клиенте.

Причины зависания LA на сервере

  • Ошибка логики. На сервере сложная логика отправки пушей, отслеживания состояний, и там может что-то пойти не так.
  • Проблема с сервером отправки пушей в Apple. Отправкой пушей может заниматься отдельный сервер, ваш или сторонний, и если он барахлит — надо делать повторные попытки.
  • Повышенная активность на сервере. Live Activity существенно нагружает бэкенд — активности видны даже тогда, когда ваше приложение не запущено, их надо обновлять и на экране блокировки, и во время работы других приложений. У нас эта проблема проявилась настолько, что ребята из других сервисов на сервере пожаловались на повышение RPS, — пришлось снизить частотность пушей до 1 пуша в 45 секунд.

Причины зависания LA на клиенте

  • Ошибки в логике. Например, если при регистрации токена с ним происходит проблема, LA лучше закрыть сразу. В ином случае есть риск оставить его в неактуальном состоянии.
  • Троттлинг пушей. iOS может отсекать пуши по своим внутренним системным причинам. Как с этим жить — обсудим дальше.
  • Режим энергосбережения. Когда вы включаете режим экономии аккумулятора — батарейка становится желтой и система начинает резать косты: реже доставлять пуши, например. Если бы Apple в этом режиме просто выключала LA — это было бы идеально. Но LA остается, а пуши доходят намного реже.
    После некоторых репортов об ошибках мы даже хотели понять, не включен ли был в момент ошибки Low Power Mode. До недавнего времени Apple про это не писала и нам приходилось собирать информацию по крупинкам. О прояснении этой ситуации тоже расскажу ниже.

Как решать такие проблемы? Вот топ методов — от самоочевидных до хитрых.

  1. Поправить баги. Всего-навсего :)
  2. Использовать настройку NSSupportsLiveActivitiesFrequentUpdates, о которой написано выше («Нюанс 1: частота обновлений»). Эта настройка доступна с iOS 16.2 и помогает существенно уменьшить число зависаний. Да, пользователи могут ее отключить, но это делают единицы. И эти единицы, наверное, готовы к проблемам с обновлениями.
  3. Использовать приоритеты пушей. Высокоприоритетные пуши лучше доставляются, но на них есть лимит. На низкоприоритетные нет ограничений по количеству, но они без разговоров троттлятся системой. Поэтому для важных событий (скажем, для изменения статуса такси) мы используем высокоприоритетные пуши и разбавляем их низкоприоритетными для событий проходных.
  4. Использовать stale-состояние! (Думали, не вспомним про него? Загляните в главу про жизненный цикл!) Работает stale-состояние так:
    • При формировании начального или последующего состояния LA можно указать в будущем критическую дату (stale date).
    • Если при наступлении этой даты обновлений нет — LA переходит в состояние stale и система разово обновляет Live Activity.
    • Увидев этот флаг, вы можете как-то это отобразить: намеком, значком, подписью. Можно сказать пользователю откровенно: мы не знаем, что происходит, но обновлений нет, перейди в приложение и погляди сам. Это лучше, чем дезинформация. Например, Live Activity может считать, что машина приедет через 20 минут, а она уже на пороге.

Проблема метрик

Еще одна проблема Live Activity — отсутствие метрик. Единичные жалобы из разных источников не показывают масштаба проблемы. Вчера было три жалобы, но мелкие. Сегодня одна, но большая, а завтра жалоб вообще не завезли. О чём это говорит? Проклятая неизвестность.

Наш идеальный вариант метрики — то, как долго LA за весь жизненный цикл находится в stale-состоянии. Как посчитать такую метрику, мы пока ищем. Но что уже можно сделать — трекать состояние LA в момент перехода из него в приложение и запоминать, если оно stale. Это дает неполную картину, но уже лучше, чем ничего.

Отдельная новость про метрику: в октябре 2023 года Apple поделилась с нами информацией о работе пушей, в том числе данными о пушах в режиме энергосбережения, а также о том, как работает стор пушей.

Также появился отличный дашборд Push Notifications Console, из которого мы увидели, что:

  • Доставляемость пушей среднего приоритета (обычных) — 70%.
  • Лишь 15% обычных пушей доставляются сразу, остальные либо отсекаются, либо кладутся в ящичек (стор).
  • Доставляемость высокоприоритетных пушей — 97%, и почти все они доходят сразу (не сразу — менее 0,5%).

Итоги списком

  1. Фича — огонь. По мере раскатки пользователи приходили и делились восторгами: все пользуются, всем нравится. Да что говорить, ей пользуемся мы сами!
  2. Фича всегда на виду у пользователя, и к ее функциональности должно быть пристальное внимание. Любой недочет сразу бросится в глаза. Поэтому важно хорошо ее протестировать и ничего не забыть.
  3. Число поездок с оценкой после раската LA выросло на 4 процентных пункта. Тоже позитив. Водителей надо оценивать и благодарить.
  4. Нагрузка на сервер после раската тоже выросла. В ответ на это мы провели рефакторинг. Пока тестируем, что получилось, и рассчитываем справиться с усиленной нагрузкой. Надеемся, что сможем обновляться почаще — скажем, раз в 5 секунд.
  5. Нет, мы не считаем, что всё готово. Мы продолжаем вкладываться в фичу: например, недавно поддержали особые состояния для тарифа «Вместе», а также планируем интеграции с другими сервисами. Поддерживайте Live Activities. Они делают пользователей счастливее!

Другие публикации

  • 📹
  • mobile
  • Такси

Yet another Flutter DI

  • 📹
  • mobile
  • Маркет

BDUI MythBusters