Live Activity был показан Apple на презентации iOS 16 и нового iPhone с Dynamic Island. Обновление доступно только для тех, кто обновил iOS до 16.1.
Меня зовут Азиз, я iOS-разработчик в inDrive. В статье расскажу, как мы добавляли Live Activity в наше iOS-приложение. Постараюсь ответить на все вопросы, которые возникли у нас в процессе разработки.

Зачем вообще добавлять Live Activity?
Основная идея в том, что вы как пользователь можете не открывать приложение каждый раз, когда вам надо проверить важную информацию, актуальную в конкретный момент времени.
Простой пример работы Live Activity. Допустим, вы заказали некую услугу. Помимо общего статуса «К вам едет заказ», вам на заблокированном экране показывается виджет со всей необходимой информацией: статус заказа, время доставки, информация о курьере/водителе и так далее.


Добавление в продакшн
Есть много статей, описывающих процесс добавления Live Activity в проект. За единственным исключением — ни одна статья не делится опытом добавления фичи в настоящий проект. Об этом и хочу рассказать в статье.
Наш процесс начался с демонстрации Live Activity бизнесу. Нужно было «продать» эту идею. Мы создали демо-приложение по мотивам статей и официальной документации. Получилось достаточно наглядно продемонстрировать основную идею.

После недолгих согласований мы начали внедрять Live Activity в проект inDrive. Организовали инициативную группу, в которой, помимо меня, было еще два iOS-разработчика.
При интеграции в боевой проект возник ряд проблем, с которыми надо было что-то делать:
-
XcodeGen и первый запуск — как добавить в project.yml новый target, да еще и с поддержкой Live Activity.
-
Полноценное понимание, как работают Push Notification с Live Activity.
-
У нас своя дизайн-система, как использовать ее в Live Activity.
-
Как подключить переводы.
-
Как связать UDF с Live Activity.
XcodeGen и первый запуск
В нашем проекте мы используем XcodeGen для генерации *.xcodeproj файла. В этом был небольшой челендж — ранее в проекте виджеты мы не использовали. Нам пришлось добавлять определенные темплейты в project.yml основного модуля нашего приложения. В основном таргете в info надо было добавить флаг:
NSSupportsLiveActivities: true
Затем необходимо создать темплейт для самого Live Activity виджет-тагрета:
LiveActivity: type: app-extension platform: iOS info: path: "${target_name}/SupportingFiles/Info.plist" properties: CFBundleDisplayName: ${target_name} CFBundleShortVersionString: *cfBundleShortVersionString NSExtension: NSExtensionPointIdentifier: "com.apple.widgetkit-extension" settings: base: TARGETED_DEVICE_FAMILY: "$(inherited)" PRODUCT_BUNDLE_IDENTIFIER: ${bundleId} configs: debug: PROVISIONING_PROFILE_SPECIFIER: "match Development ${bundleId}" CODE_SIGN_IDENTITY: "" DEBUG_INFORMATION_FORMAT: "" release: PROVISIONING_PROFILE_SPECIFIER: "match AppStore ${bundleId}" CODE_SIGN_IDENTITY: "" dependencies: - framework: SwiftUI.framework implicit: true - framework: WidgetKit.framework implicit: true
И не забыть в основном таргете:
dependencies: - target: LiveActivity
С указанием соответствующего bundleId, который предварительно надо не забыть привязать к Provision Profile (тоже отдельный для этого бандла).
После всех манипуляций и заветного: make finished successfully ?, надо показать Live Activity первый раз. Мы опустим момент с настройкой Content State и определения статичных свойств и тех, что должны обновляться. Важно не забыть добавить main — без него виджет не сможет запуститься.
@available(iOSApplicationExtension 16.1, *) @main struct Widgets: WidgetBundle { var body: some Widget { LiveActivityWidgetView() } }
Покажем сразу, как именно запустить Live Activity (iOS 16.1):
public func startWith(_ attributes: Attributes?, state: Attributes.ContentState, pushType: PushType?) { // 1 guard ActivityAuthorizationInfo().areActivitiesEnabled, let attributes = attributes, activity.isNil else { return } do { // 2 activity = try Activity<Attributes>.request( attributes: attributes, contentState: state, pushType: pushType ) if let token = activity?.pushToken { let unwrappedToken = token.map { String(format: "%02x", $0) }.joined() logger.debug("? Live Activity token: \(unwrappedToken)") // 3 props.action.execute(with: .didStartActivityWith(token: unwrappedToken)) } else { logger.error("⛔️ Failed Live Activity") } // 4 Task { guard let activity = activity else { return } for await data in activity.pushTokenUpdates { let token = data.map { String(format: "%02x", $0) }.joined() logger.debug("?? Live Activity token updates: \(token)") props.action.execute(with: .didPushTokenUpdates(token: token)) } } } catch { logger.error("⛔️ Failed Live Activity: \(error.localizedDescription)") props.action.execute(with: .didFailStartLiveActivity(error: error)) } }
-
Мы должны убедиться, что у пользователя включена в настройках возможность отображать Live Activty и нет запущенных активити.
-
При создании запроса на Activity нам надо передать pushType:
-
.token — хотите обновлять Live Activity посредством Push Notifications.
-
nil — только в рамках жизненного цикла приложения.
-
-
Если через токен, то асинхронно придет pushToken, который надо отправить на бэк. Именно так бэк поймет, что мы готовы получать обновления для Live Activity.
-
Токен для Live Activity имеет свойство обновляться в зависимости от того, осталось приложение запущенным или было выгружено. Надо отслеживать этот момент и сообщать об этом на бэкенд.
Чтобы обновлять Activity из самого приложения, если не хотите использовать Push Notification (iOS 16.1):
Task { await activity.update(using: state) }
И завершать:
Task { await activity.end(using: state, dismissalPolicy: .immediate) }
После вызова метода startWith() вы должны увидеть Live Activity. Если пройдете этот путь, делитесь кейсами в комментариях.
Полноценное понимание, как работают Push Notification с Live Activity
Изначально мы реализовали основной функционал без пушей. Live Activity устроен так, что его можно реализовать, не используя Push Notifications — просто на базе статусов внутри приложения. Затем задались вопросами:
-
Как Live Activity понимает, что push именно для нее? На самом деле это все подкапотная магия Apple. Система сама определяет по пришедшему Payload пуша и id, к какому Live Activity относится пришедшая информация.
-
Можно ли как-то ловить эти пуши в самом приложении? На момент написания статьи Apple не предоставляло никакой информации о том, как отловить пуш в запущенном приложении. Мы с ребятами проверяли, срабатывает ли метод:
application(_:didReceiveRemoteNotification:fetchCompletionHandler:)
В случае с Live Activity Push Notification этот метод не вызовется.
-
Как тестировать пуши, пока нет бэкенда? Мы сами придумали подходящий Payload и реализовали на тестовом приложении обычный флоу пуш-уведомлений. Дальше использовали это.
Также поясню, что большая часть работы предстояла на бэкенде. Мы передали примеры запросов в APNS c указанием, какой Payload будет ждать клиент. Более подробно про реализацию бэкенда расскажем во второй части статьи.
CURL в APNs
curl -v \ --header "apns-topic:{Your App Bundle ID}.push-type.liveactivity" \ --header "apns-push-type:liveactivity" \ --header "authorization: bearer $AUTHENTICATION_TOKEN" \ --data \ '{"aps": { "timestamp":1663300480, "event": "update", "content-state": { "playerOnFirst": "Tony Stark", "currentLap": 2 }, "alert": { "title": "Race Update", "body": "Tony Stark is now leading the race!" } }}' \ --http2 \ https://${APNS_HOST_NAME}/3/device/$DEVICE_TOKEN2
Payload
{ "aps": { "timestamp": 1669698850, "event": "update", "content-state": { "rideStatus": "on_ride", "time": 5 } } }
Собственная дизайн-система и Live Activity
Ранее в проекте мы не использовали SwiftUI, поэтому это для нас стало еще одним вызовом. После успешного запуска в демо-приложении, мы начали наводить красоту и пробовать использовать общие компоненты из дизайн-системы.
-
Базовые компоненты: цвета, шрифты, иконки не составили больших проблем, так как начиная с версии iOS 15, Apple добавили простую возможность использовать компоненты UIKit в SwiftUI.

Мы пошли немного дальше и реализовали это нативно. Кстати, базовые компоненты мы хотим выложить в open source — можно будет посмотреть.
-
Apple предоставляет простой механизм для обертки UIView в SwiftUI.View — UIViewRepresentable, который должен был облегчить нам жизнь, но получилось как-то так:

Или так:

Хотя планировалось так:)

И так:

В интернете мы не нашли этому объяснения и решили отказаться от части фич, пока не переведем компоненты на нативные SwiftUI. Если у вас есть идеи, почему так происходит, будем рады почитать.
Цвета машин
В основном приложении мы не меняем цвет автомобиля, который едет к пользователю. Это иконка зашита в приложении — мы просто ее подставляем. Для LiveActivity я взял иконку из Figma, поставил и забыл.
Потом пришел мой коллега и сказал: «А почему нам не красить машинки в тот цвет, который приходит с бэкенда?». Мы выяснили, что цвет приходит просто в виде текста: Gold, Blue и так далее. Попросили бэк отправлять еще hex, чтобы можно еще и красить.
Тут пришлось немного схитрить. Мы разбили изображение на несколько слоев: кузов, который будем красить, и базу автомобиля, которая будет неизменной. Получилось как-то так:

Таким простым способом добавили информативности для виджета.
Как подключить переводы?
Мы работаем в 47 странах мира и поддерживаем множество языков. Запуская фичу, мы должны поддерживать все языки, включая арабский (где правописание справа налево).
Для удобной работы с переводами мы используем платформу Crowdin, а для виджета и острова необходимо настроить положение в зависимости от локализации. Мы использовали для этого радости, которые дает нам SwiftUI в виде Environment.
@Environment(\.layoutDirection) var direction
UDF + Live Activity
Благодаря UDF мы можем реализовать service component, который будет «слушать» события изменения определенного State, формировать наш Live Activity и обновлять его при необходимости.
Как я показал выше, мы можем завернуть старт, обновление и завершение Live Activity через service component. Это будет полезно в том случае, если вы захотите обновлять состояние через запущенное приложение (без remote push notification).
Важно помнить
Чтобы обновлять Live Activity через пуши, вам явным образом надо запустить его в активном приложении. Дальше всю магию Apple берет на себя.
import UDF import ActivityKit @available(iOS 16.1, *) public typealias LiveActivityAttributes = ActivityAttributes & Equatable @available(iOS 16.1, *) open class LiveActivityServiceComponent<Attributes: LiveActivityAttributes> public var activity: Activity<Attributes>? public let disposer = Disposer() public var props: Props = .init() { didSet { render(props) } } public init() { } open func render(_: Props) { // тут мы реализуем логику в зависимости от того, какой пропс пришел // либо можно заставить явно переопределить это метод в наследниках // вызыввать методы startWith(),updateWith(), endWith() в моменты когда это необходимо } } // MARK: - LiveActivityServiceComponent @available(iOS 16.1, *) extension LiveActivityServiceComponent: ViewComponent { public func startWith(_ attributes: Attributes?, state: Attributes.ContentState, pushType: PushType?) { ... } public func updateWith(_ state: Attributes.ContentState) { ... } public func endWith(_ state: Attributes.ContentState) { ... } } // MARK: - Props @available(iOS 16.1, *) public protocol LiveActivityProps: Equatable { associatedtype Attributes: ActivityAttributes & Equatable var attributes: Attributes? { get } var contentState: Attributes.ContentState? { get } var pushType: PushType? { get } var action: CommandOf<LiveActivityAction> { get } init( attributes: Attributes?, contentState: Attributes.ContentState?, pushType: PushType?, action: CommandOf<LiveActivityAction> ) } @available(iOS 16.1, *) extension LiveActivityProps { init() { self.init(attributes: nil, contentState: nil, pushType: nil, action: .nop) } } @available(iOS 16.1, *) public extension LiveActivityServiceComponent { struct Props: LiveActivityProps { public let attributes: Attributes? public let contentState: Attributes.ContentState? public let pushType: PushType? public let action: CommandOf<LiveActivityAction> public init( attributes: Attributes? = nil, contentState: Attributes.ContentState? = nil, pushType: PushType? = nil, action: CommandOf<LiveActivityAction> = .nop ) { self.attributes = attributes self.contentState = contentState self.pushType = pushType self.action = action } } } // MARK: - Action public enum LiveActivityAction: Action { case didStartActivityWith(token: String) case didPushTokenUpdates(token: String) case didFailStartLiveActivity(error: Error) }
Далее можно ловить LiveActivityAction в нужном редьюсере и отправлять запросы на бэкенд с токеном для получения push-уведомлений. Через них можно как обновить, так и завершить Live Activity. Это достаточно подробно описано в документации Apple.
Для первой части получилось достаточно объемно, поэтому в следующем году поделюсь дополнительными наблюдениями относительно Live Activity. Отдельную благодарность хочу сказать Леше Какоулину и Пете Казакову — без этих ребят ничего бы не получилось.
Полезные ссылки:
ссылка на оригинал статьи https://habr.com/ru/articles/708366/
Добавить комментарий