Live Activity предназначена для конкретного момента: что-то происходит прямо сейчас, параллельно тому, чем занят пользователь, и он хочет проверить это, не открывая ваше приложение. Доставка еды и спортивные результаты — обычные примеры.
Мы создали такую функцию для более спокойного случая: пользователь пересылает квитанцию, и приложение тратит от нескольких секунд до минуты на ее обработку на стороне сервера (загрузка вложения, извлечение текста, сохранение результата), прежде чем появится что-то для отображения. Индикатор загрузки заставляет пользователя ждать; обычное push-уведомление сообщает только результат. Live Activity показывает весь процесс в реальном времени: получение, затем обработку, а после — итоговую сводку или ошибку на экране блокировки и в Dynamic Island.
В этой статье рассматривается создание такой функции от начала до конца: настройка клиента и инфраструктура push-уведомлений на бэкенде, поскольку активность продолжает работать в фоновом режиме только в том случае, если обе стороны реализованы правильно.
Схема сквозного потока данных
Пошаговое руководство:
- Приложение запускает активность локально или через push-to-start. Push-to-start позволяет бэкенду запустить активность от имени приложения в тот момент, когда он понимает, что есть что отслеживать, даже если приложение не находится на переднем плане.
- iOS создает push-токен для этой активности. Важно не упустить: это не обычный push-токен устройства. Он привязан к конкретной активности и может обновляться.
- Приложение регистрирует этот токен на бэкенде, привязывая его к соответствующей активности. Это первое взаимодействие между клиентом и бэкендом: все, что было до этого — чистый API iOS, все, что после — зависит от вашей собственной инфраструктуры.
- Бэкенд сопоставляет свои доменные события со схемой content-state, которую ожидает виджет. «Обработка квитанции» становится stage: processing, progress: 0.4 — это слой трансляции, специфичный для вашего приложения, а не то, что ActivityKit предоставляет «из коробки».
- Бэкенд отправляет push-уведомление через APNs, используя токен из шага 3, всякий раз, когда меняется сопоставленное состояние.
- iOS доставляет push-уведомление и перерисовывает активность. На этом этапе код приложения не выполняется; ActivityKit обрабатывает это напрямую из полезной нагрузки push-уведомления.
Реализация на мобильных устройствах
На стороне клиента есть три части: схема content-state, на основе которой отрисовывается активность, сам UI виджета и надежная регистрация push-токена, чтобы бэкенд мог связаться с активностью позже. Все три части живут полностью в приложении Expo, без необходимости синхронизировать отдельный проект Xcode.
Схема content-state
ActivityKit предоставляет активности одну фиксированную структуру ContentState на весь срок ее жизни: вы не можете подменить структуру по мере прохождения этапов, можно только изменять значения полей в той форме, с которой вы начали. Поэтому схема представляет собой небольшой, всегда присутствующий заголовок (какой этап, значение прогресса, от кого) плюс три опциональных вложенных объекта, из которых в каждый момент времени заполнен только один в зависимости от этапа.
Завершенное обновление на практике выглядит так:
Состояния processing и failure следуют той же структуре, пока они активны; остальные два остаются null. Клиенту достаточно проверки if (props.summary) { ... }, и никогда не требуется предварительная проверка этапа, чтобы понять, существует ли поле, которое он собирается прочитать.
UI виджета
Сам виджет написан как React-компонент с использованием @expo/ui/swift-ui. expo-widgets компилирует его в реальное нативное представление SwiftUI. Нет необходимости поддерживать параллельный Swift-файл рядом с TSX; компонент и есть виджет:
Каждая область, которую запрашивает ActivityKit (баннер на экране блокировки, компактные и минимальные состояния Dynamic Island, развернутые области), — это просто разный return, управляемый одним и тем же переключателем props.stage. STAGE_ACCENT_COLOR, STAGE_SYMBOL и getPrimaryContent() выполняют фактическое ветвление по этапам один раз, в верхней части, поэтому сам JSX остается декларативным.
Запуск активности
В продакшене клиент никогда не вызывает start() самостоятельно. Бэкенд знает, что квитанция только что прибыла, поэтому он запускает активность удаленно через push-to-start. На клиенте «запуск» — это на самом деле «согласие»: регистрация токена, который нужен ActivityKit, чтобы позволить чему-то другому запустить активность от вашего имени.
Для push-to-start требуется iOS 17.2+, что на одну версию выше самого ActivityKit (16.1). Функция isPushToStartEligibleDevice() явно проверяет это; на старом устройстве этот хук ничего не делает, и функция должна деградировать до «нет отслеживания в реальном времени для этого пользователя», а не приводить к сбою.
На третьем аргументе стоит остановиться — это activityId, и его нет в стандартном expo-widgets. ActivityKit присваивает активности собственный непрозрачный UUID, не связанный с идентификатором, который использует ваш бэкенд. Чтобы позже соотнести push-токен с нужной записью в домене, активность должна нести и ваш ID, поэтому мы добавили поле attributes.activityId, которое устанавливается во время start() на клиенте или в полезной нагрузке push-to-start, когда бэкенд запускает ее удаленно, и считывается нативно везде, где сообщается push-токен.
Регистрация push-токена
Мобильное приложение прослушивает push-токен из двух разных источников: getInstances() для активностей, дескриптор которых у него уже есть, и прослушиватель на уровне модуля для одного конкретного случая: активность, которую ActivityKit запускает удаленно через push-to-start, пока приложение находится в фоновом режиме, но не завершено. Этот случай не вызывает перезапуск, поэтому ничего не синхронизируется автоматически; без прослушивателя, не привязанного к существующему дескриптору, токен для этой активности вообще не дошел бы до бэкенда.
Реализация бэкенда
Все, что было до этого, работает полностью на устройстве. Задача бэкенда меньше по объему, но именно здесь активность становится «живой»: хранение токенов, которые только что зарегистрировал клиент, трансляция ваших собственных доменных событий в схему content-state и вызов APNs в два нужных момента: один раз для запуска активности и повторно для ее обновления.
Модель данных
Одна таблица, индексируемая по пользователю и (для токенов обновления) по активности:
Нет ограничения уникальности на (user_id, type): у пользователя может быть несколько устройств, каждое из которых вносит свою строку push_to_start, и активность каждого устройства вносит свою строку activity_update после регистрации. Мертвый токен (APNs сообщает, что он недействителен) просто удаляется; ничто в системе не должно его искать или восстанавливать.
Эндпоинты
Два, соответствующие тем, которые уже вызывает клиент:
Оба являются простыми upsert-операциями: повторная регистрация того же токена ничего не делает, обновленный токен вставляет новую строку. Это не просто удобство, это то, что рекомендует документация Apple: отслеживайте push-токен для каждой Live Activity и аннулируйте предыдущий, устаревший токен на своем сервере, когда приходит новый, согласно Starting and updating Live Activities with ActivityKit push notifications.
Маппер content-state
Одна функция отвечает за превращение вашего доменного состояния в точную схему, которую ожидает виджет. Она используется повторно как для полезной нагрузки push-to-start, так и для каждого последующего обновления, поэтому существует единственный источник истины о том, «как выглядит этап X»:
Для обновления это вызывается путем повторного считывания задания и его элементов непосредственно из базы данных в момент отправки, вместо того чтобы доверять любым данным, которые инициировали вызов. Дочерние элементы могут достигать терминального состояния в разном порядке, и их задания могут конкурировать друг с другом в очереди, поэтому повторное вычисление из базы данных во время отправки означает, что тот вызов, который фактически выполняется последним, всегда отправляет актуальную информацию, независимо от порядка поступления.
Поток Push-to-start
Срабатывает один раз, в тот момент, когда бэкенд узнает, к какому пользователю относится новое задание. Отправляется на каждый активный токен push_to_start для этого пользователя (один push на устройство; каждый порождает свою независимую активность):
Механизм обеспечения идемпотентности (nullable-штамп времени в стиле startedAt в задании, который проверяется и устанавливается перед отправкой) предотвращает создание второго экземпляра активности при повторной попытке обработки задания в очереди.
Запрос APNs
Один метод отправляет как push-to-start, так и push-update полезные нагрузки, приведенные ниже, поскольку они различаются только несколькими полями aps. Объект aps — это в точности результат работы маппера content-state плюс любые дополнительные поля, необходимые для события; заголовки — это то, что фактически обеспечивает принятие запроса:
providerToken — это JWT, который вы подписываете самостоятельно с помощью ключа ES256 .p8, сгенерированного один раз на портале Apple Developer (developer.apple.com, в разделе Certificates, Identifiers & Profiles > Keys).
apns-topic требует суффикс .push-type.liveactivity к вашему bundle id, а apns-push-type: liveactivity является отдельным обязательным заголовком, который не подразумевается автоматически из topic. Оба момента легко упустить, так как большинство руководств по APNs написаны для обычных push-уведомлений на устройство, а не для Live Activities.
Что касается ответа, единственная важная проверка — это shouldDeleteToken: код 410 или причина BadDeviceToken — это то, что подразумевается в разделе модели данных выше фразой «недействительный токен просто удаляется».
Поток Push-update
Срабатывает при каждом последующем событии в домене. Отправляется на каждый активный токен activity_update для данного activityId:
Терминальные состояния (completed/failed) по-прежнему отправляются как "event": "update", а не "end". Это позволяет конечному состоянию оставаться видимым на экране блокировки, а не немедленно исчезать, полагаясь на собственное окно устаревания ActivityKit, вместо того чтобы принудительно закрывать активность в момент завершения вашего конвейера.
Нюансы APNs
Несколько моментов требуют реального времени на отладку, чтобы получить полезную нагрузку, которую APNs действительно примет и обработает:
- content-state — это обертка, а не ваша схема. Реальная структура ContentState, которую определяет expo-widgets, — это просто { name: string, props: string } — name это буквальное имя виджета, которое вы передали в createLiveActivity, а props — это весь ваш объект content-state, преобразованный через JSON.stringify() в одну строку. Отправка полей вашей схемы напрямую на верхнем уровне content-state даст вам код 200 от APNs, но активность молча никогда не обновится — ничто ее не декодирует, и ничто не сообщит вам об этом.
- События start требуют три поля, которые не нужны для update: attributes-type (буквальная строка "LiveActivityAttributes"), attributes (где фактически устанавливается activityId) и — это легко упустить, так как документация Apple описывает его как опциональное — alert. Эта опциональность применима к update; для start пропуск alert означает, что APNs принимает push, но активность молча никогда не запускается на устройстве.
- Push-токены привязаны к тому entitlement aps-environment, с которым была подписана сборка, а не к Debug или Release. Если ваша сборка принудительно использует одну среду для всех конфигураций (обычно для Live Activities, так как поддержка sandbox push-to-start нестабильна), каждый токен, который выдает ваше приложение, принадлежит этой среде — отправка на другой хост APNs приведет к получению ошибки 400 BadDeviceToken для каждого запроса, которую на первый взгляд невозможно отличить от действительно недействительного токена.
Заключение
Это полный цикл: виджет, написанный на TSX и скомпилированный в нативный SwiftUI через expo-widgets, и push-токены, зарегистрированные по двум путям. Бэкенд превращает ваши события в push-уведомления APNs ровно в два нужных момента.
Самым сложным было то, что ActivityKit запускает активность удаленно, пока приложение находится в фоновом режиме, а не завершено. Ничто в этом пути не инициирует перезапуск, поэтому хук синхронизации токенов содержит второй слушатель на уровне модуля, созданный специально для его перехвата. Мы поделились исправлением с сопровождающими expo-widgets (PR #48589 включает соответствующую часть по примирению перезапуска), так что у него есть четкий путь к апстриму.
Expo дает вам клиентскую часть без написания кода на Swift. Бэкенд-часть остается за вами, и теперь вы увидели обе.
Ссылки
- — документация Apple по ActivityKit; источник рекомендаций по аннулированию токенов в разделе бэкенд-эндпоинтов.
- — исправление в expo-widgets для примирения push-токенов getInstances() после перезапуска, упомянутое в заключении.
- expo-widgets — пакет, на котором построена клиентская реализация этой статьи.
- @expo/ui — связки SwiftUI, используемые для написания UI виджета на TSX.










