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. Отдельную благодарность хочу сказать Леше Какоулину и Пете Казакову — без этих ребят ничего бы не получилось.
Полезные ссылки: