如何在现有项目中添加实时活动 - 第一部分
关于如何为项目添加实时活动功能的终极指南的第一部分。
Apple 在推出 iOS 16 和配备灵动岛的新款 iPhone 时,推出了实时活动功能。此升级仅适用于已升级到 iOS 16.1 的用户。
我叫 Aziz,是一名在 inDrive 工作的 iOS 开发者。在这篇文章中,我将告诉您我们是如何为我们的 iOS 应用添加实时活动的。在这里,我将尝试回答我们在开发过程中遇到的所有问题。
首先,为什么要添加实时活动?
基本理念是,作为用户,您不必在需要查看特定时间点的关键相关信息时打开应用程序。
这是一个简单的示例,说明您的实时活动如何工作。假设您已订购某项服务。除了“您的订单正在路上”的通用状态外,锁屏上还会显示一个包含所有必要信息的 widget,例如订单状态、送达时间、有关快递员/司机的详细信息等。
如果您拥有 iPhone 14 Pro 或 14 Pro Max,则可以使用灵动岛功能。
当您点击该功能时,将显示相关信息块。在我们的例子中,这是行程状态,您离司机的距离,以及车辆的车牌号和颜色。
投入生产
市面上有很多文章描述了将实时活动添加到项目中的过程。唯一的区别是,它们都没有分享将此功能添加到现有项目的实践经验。这就是我想在这篇文章中告诉您的。
我们的过程始于向企业演示实时活动功能。目的是“推销”这个想法。我们创建了一个基于相关文章和官方文档的演示应用程序。这帮助我们清晰地展示了基本理念。
在短暂的审批期后,我们开始将实时活动功能集成到 inDrive 项目中。我们组建了一个行动小组,除了我之外,还有另外两名 iOS 开发者。
在集成到现有项目中时,我们遇到了一些需要解决的问题
- XcodeGen 和首次运行 — 如何向 project.yml 添加新目标,特别是当它必须支持实时活动时?
- 深入了解推送通知如何与实时活动功能配合使用。
- 我们有自己的设计系统,如何在实时活动中使用它?
- 如何连接翻译资源。
- 如何将 UDF 链接到实时活动。
XcodeGen 和首次运行
在我们的项目中,我们使用 XcodeGen
生成 *.xcodeproj 文件。这有点棘手,因为我们以前从未在项目中这样做过 widget。我们必须在应用程序主模块的 project.yml 文件中添加特定模板。必须在主目标的 info 部分添加一个标志
NSSupportsLiveActivities: true
然后我们需要为实时活动 widget 本身创建一个模板
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,该 ID 应首先链接到 Provision Profile(也为该 bundle 单独配置)。
在完成所有步骤并成功完成构建 make finished successfully 🎉 后,您的实时活动功能必须首次显示。在这里,我们将省略配置 Content State 的部分,以及定义静态属性和需要更新的属性。重要的是要记住添加“main
”,因为没有它您将无法运行 widget。
@available(iOSApplicationExtension 16.1, *)
@main
struct Widgets: WidgetBundle {
var body: some Widget {
LiveActivityWidgetView()
}
}
See below for how to launch 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))
}
}
通过应用程序升级 Activity,如果您不想使用推送通知(iOS 16.1)
Task {
await activity.update(using: state)
}
然后结束此过程
Task {
await activity.end(using: state, dismissalPolicy: .immediate)
}
- 我们必须确保用户可以在设置中显示启用的实时活动,并且没有正在运行的活动。
- 创建 Activity 请求时,我们需要通信
pushType
.token
— 通过推送通知升级实时活动nil
— 仅在应用程序生命周期内
- 如果通过
.token
升级,pushToken
将异步到达,必须发送到后端。这将确保后端团队知道我们已准备好接收实时活动的更新。 - 实时活动令牌的更新基于应用程序是正在运行还是已下载。我们需要跟踪这一点并将其报告给后端。
调用 startWith()
方法后,您应该会在屏幕上看到实时活动。如果您采用此方法,请务必在评论中分享您的情况。
深入了解推送通知如何与实时活动功能配合使用
最初,我们实现了没有推送通知的基本功能。实时活动功能的设计可以不使用任何推送通知来实现 — 仅基于应用内状态。然后我们提出了这些问题
- 实时活动如何知道哪个推送是为它准备的?这完全是 Apple 的底层魔力。根据传入的推送通知 payload 和 ID,系统会自行确定哪个实时活动与传入的信息相关。
- 可以在应用程序本身捕获这些推送通知吗?在撰写本文时,Apple 没有提供有关如何在正在运行的应用程序中捕获推送通知的信息。我和我的同事们检查了这个技术是否有效
application(_:didReceiveRemoteNotification:fetchCompletionHandler:)
在这种情况下,此方法不会为实时活动推送通知调用。
- 如何在后端尚未就绪的情况下测试推送通知?我们自己设计了一个合适的 Payload,并在测试应用程序中实现了常规的推送通知流程。接下来,我们 使用了这个。
大部分工作都必须在后端完成。我们向 APNS 发送了示例请求,指明了客户端将等待的 Payload。请继续关注有关我们后端实现工作的更多详细信息,请参阅文章的第二部分。
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
{
"aps": {
"timestamp": 1669698850,
"event": "update",
"content-state": {
"rideStatus": "on_ride",
"time": 5
}
}
}
专有设计系统和实时活动
我们以前没有在项目中使用过 SwiftUI
,所以这对我们来说是另一个挑战。在演示应用程序成功启动后,我们开始解决一些小问题,并尝试使用设计系统中的通用组件。
- 颜色、字体和图标等基本组件不是大问题,因为从 iOS 15 开始,Apple 增加了一种在 SwiftUI 中使用 UIKit 组件的便捷方法。
我们更进一步,并原生实现了它。顺便说一下,我们打算将基本组件开源,以便您可以查看。
- Apple 提供了一个简单的机制来将 UIView 包装在
SwiftUI.View
(UIViewRepresentable
)中,这本应使我们的生活更轻松,但结果却像这样或者像这样:
虽然计划是这样的。:)
还有像这样
由于我们未能在线找到解释,我们决定暂时搁置一些功能,直到我们将组件切换为原生 SwiftUI。如果您有任何关于为什么会这样想法,我们很乐意听取您的意见。
车辆颜色
在我们的主应用程序中,我们不会改变车辆前往用户位置的颜色。此图标已嵌入应用程序中,我们只需替换它。对于 LiveActivity,我从 Figma 中获取了一个图标,将其放在我想要的位置,然后就忘了它。
然后我的一个同事过来对我说,“为什么我们不把车辆变成后端发送的颜色呢?”我们发现颜色只是文本:金色、蓝色等。我们要求后端团队也发送十六进制值,以便我们也能进行着色。
尽管如此,我们还是必须在这里使用一些巧妙的技巧。我们将图像分解成几个图层:我们将要着色的车身,以及不会改变的车辆底部。结果是这样的
这种简单的技术帮助我们使 widget 更具信息量。
如何连接翻译资源?
我们在 47 个国家开展业务,并支持多种语言。在启动一项功能时,我们必须确保所有语言都得到支持,包括阿拉伯语(从右到左书写)。
我们使用 Crowdin 平台来方便地处理翻译,对于 widget 和 island,必须根据涉及的区域设置调整设置。为此,我们使用了 SwiftUI 通过 Environment 带来的“乐趣”。
@Environment(\.layoutDirection) var direction
UDF + 实时活动
我们可以使用 UDF 来实现一个服务组件,该组件将“监控”特定状态的更改事件,并生成我们的实时活动并在必要时进行更新。
如上所示,我们可以通过服务组件封装实时活动的启动、更新和结束。如果您希望通过正在运行的应用程序(不使用远程推送通知)更新状态,这将非常有用。
这一点至关重要。要通过推送通知更新实时活动,它必须通过活动应用程序显式运行。从那时起,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) {
// the logic we implement here is based on the props that come in
// or you can explicitly override this method in the descendants
// call the startWith(),updateWith(), endWith() methods when needed
}
}
// 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)
}
然后,您可以在所需的 reducer 中捕获 LiveActivityAction
,并向后端发送带有令牌的请求以接收推送通知。您可以使用它们来刷新和结束实时活动。Apple 的 文档对此过程进行了详细描述。
文章的第一部分内容相当多,足以供您消化,但我很快就会分享更多关于实时活动的观察。我想特别感谢 Lyosha Kakoulin 和 Petya Kazakov,没有他们,这一切都不会发生。
有用链接
历史
- 2023 年 1 月 5 日:初始版本