iOS 使用推送通知更新 Dynamic Island 和 Live Activity

随着 iOS 16 推出的 Live Activity 以及 iOS 16.1 正式支持的 Dynamic Island 已经可以提交 App Store 发布了,最近在测试 Live Activity 的后台更新碰到一些问题,在这里记录一下。

什么是 Live Activity 和 Dynamic Island

Live Activity 和 Dynamic Island 其实是作为同一个 Widget 的两种形态存在的,它们使用 ActivityKit 中的 ActivityConfiguration 同时进行配置,在拥有 Dynamic Island 支持的 iPhone 14 Pro 和 iPhone 14 Pro Max 上可以正常显示 Dynamic Island 内容,而在其他机型上均以 Live Activity 的形式来展示。

struct GroceryDeliveryApp: Widget {
    var body: some WidgetConfiguration {
        ActivityConfiguration(for: GroceryDeliveryAppAttributes.self) { context in
            return VStack(alignment: .leading) {
                HStack {
                    VStack(alignment: .center) {
                        Text(context.state.courierName + " is on the way!").font(.headline)
                        Text(Date(timeIntervalSince1970: context.state.deliveryTime).formatted(Date.ISO8601FormatStyle()))
                    }
                }
            }.padding(15)
        } dynamicIsland: { context in

            DynamicIsland {
              // 展开状态时的界面
            } compactLeading: {
              // 收起状态时的左边部分界面
            } compactTrailing: {
              // 收起状态时的右边部分界面
            } minimal: {
              // 最小状态时的界面
            }
            .keylineTint(.cyan)
        }
    }
}

如何更新 Live Activity 和 Dynamic Island

和主界面 Widgets 不同的是,Live Activity 并没有一个 Timeline Provider 来提供定期更新机制,它只能依赖于 App 主动更新,或者依赖 Push 通知来更新。

如果要使用 App 来主动更新 Live Activity,这要求 App 具备任一一种后台运行模式,保持 App 持续在后台运行,从而根据运行状态来更新 Live Activity 上显示的内容,但是这对于大多数 App 来说可能并不适用,除了导航类、音频播放类 App,一般 App 很难可以申请到持续后台运行的权限。

那么就只剩下使用 Push Notification 来更新这一条路。

APNs 认证方式选择

传统的 APNs 连接方式是使用基于证书认证的,但是在使用证书认证的连接中,即使使用了更新 Live Activity 所需要的额外信息,仍然会碰到 TopicDisallowed 错误,在苹果开发者论坛里找到一个帖子(https://developer.apple.com/forums/thread/712499)提到,此类通知需要使用 Token-Based 认证方式来连接 APNs。

Token-Based Connection to APNs

在苹果的开发者文档里可以找到如何建立一个 Token-Based 连接到 APNs:Establishing a Token-Based Connection to APNs

需要注意的是,在 Token-Based 认证过程中所需要的 Key 与原先使用证书认证所生成的 Key 并不是同一个,Token-Based 所需要的 Key 需要重新去苹果开发者后台生成,并且生产和 Sandbox 环境可以使用同一个 Key,需要保障此 Key 的安全。

如何来获取认证需要所要使用的 Key,在苹果的文档中也有提及,只需要简单填写一个名字即可创建。

获取 Push Token

需要注意的事,Live Activity 推送所使用的 Push Token,并不是在 App 启动时通过 UIApplication 注册时所获得的 Device Token,需要单独通过创建 Live Activity 时所获得的对象来获取针对 Live Activity 进行推送通知的 Push Token。

var activities = Activity<GroceryDeliveryAppAttributes>.activities

activities.forEach { act in
    if let data = act.pushToken {
        let mytoken = data.map { String(format: "%02x", $0) }.joined()
        print("act push token", mytoken)
    }
}

通过 Activity 对象的 pushToken 属性可以获得针对 Live Activity 推送所需要使用的 Push Token。

注意:Activity 对象的 pushToken 并不会在对象创建完成时即时生成,需要等待一段时间才会有值,因此需要等待或者监测 pushTokenUpdates 来获得有效的值。

生成 Token

为了测试发送推送通知,一开始我找到一个 Mac App 来进行测试,但是它并没有对 Token-Based 认证方式进行适配,并且 Token-Based 认证使用了 JWT 来生成 Token,为了方便测试,在 Medium 上找到一篇文章提供了一个脚本来快速生成请求的 Payload 并且发送到 APNs。

以下脚本来自 iOS 16 Live Activities: Updating Remotely Using Push Notification

export TEAM_ID=YOUR_TEAM_ID
export TOKEN_KEY_FILE_NAME=YOUR_AUTHKEY_FILE.p8
export AUTH_KEY_ID=YOUR_AUTHKEY_ID
export DEVICE_TOKEN=YOUR_PUSH_TOKEN
export APNS_HOST_NAME=api.sandbox.push.apple.com

export JWT_ISSUE_TIME=$(date +%s)
export JWT_HEADER=$(printf '{ "alg": "ES256", "kid": "%s" }' "${AUTH_KEY_ID}" | openssl base64 -e -A | tr -- '+/' '-_' | tr -d =)
export JWT_CLAIMS=$(printf '{ "iss": "%s", "iat": %d }' "${TEAM_ID}" "${JWT_ISSUE_TIME}" | openssl base64 -e -A | tr -- '+/' '-_' | tr -d =)
export JWT_HEADER_CLAIMS="${JWT_HEADER}.${JWT_CLAIMS}"
export JWT_SIGNED_HEADER_CLAIMS=$(printf "${JWT_HEADER_CLAIMS}" | openssl dgst -binary -sha256 -sign "${TOKEN_KEY_FILE_NAME}" | openssl base64 -e -A | tr -- '+/' '-_' | tr -d =)
export AUTHENTICATION_TOKEN="${JWT_HEADER}.${JWT_CLAIMS}.${JWT_SIGNED_HEADER_CLAIMS}"

生成 Push Payload 并发送

在获得 AuthKey,生成完 APNs Token 之后,就可以开始真正发送推送通知到 Live Activity 了:

curl -v \
--header "apns-topic:YOUR_BUNDLE_ID.push-type.liveactivity" \
--header "apns-push-type:liveactivity" \
--header "authorization: bearer $AUTHENTICATION_TOKEN" \
--data \
'{"aps": {
   "timestamp":1666667682,
   "event": "end",
   "content-state": {
      "courierName": "Iron Man",
      "deliveryTime": 1666661582
   },
   "alert": {
      "title": "Track Update",
      "body": "Tony Stark is now handling the delivery!"
   }
}}' \
--http2 \
https://${APNS_HOST_NAME}/3/device/$DEVICE_TOKEN

这里的 event 可以为 updateend,以及具体 Payload 的格式,可以参见苹果开发者文档 Updating and ending your Live Activity with remote push notifications

另外,Payload 中的 content-state 是与 Live Activity 中 ActivityAttributes 的 ContentState 保持一致,因此在这里的属性建议使用简单的原子类型,例如 String 或 Int 等,这样在生成 Payload 时可以更简单一些。

做作完这些之后,就可以正式发送推送通知到 Live Activity 了,运行完上述 curl 指令就已经完成了一切。

更新 Live Activity

与传统 Home Widget 不同的是,Live Activity 中并没有一个方法可以让我们感受到已经接收到了 Push 通知,并且可以更新当前 Live Activity 的内容。

其实这一切都是自动的,在收到通知时,系统就会自动将 Payload 中的 content-state 映射到 ActivityAttributes 的 ContentState 中,并且通过 ActivityConfiguration 重新生成 Widget 的 body,从而更新 Live Activity 的界面。

如何维护 Live Activity 更新历史

在学习过程中,我碰到一个需求是可能需要保持推送通知过来的历史数据,从而可以维护一个列表来展示多个订单的状态,但是服务端并不能直接维护这样一个列表,只能针对单个订单进行状态推送,这个时候可以在 Live Activity 这一侧来做一些事。

我们可以在 Activity 生成的过程,将 ContentState 进行保存,并且通过唯一的 Key 来进行去重,从而获得一个历史列表:

var orders = [String]()
if let oldOrders = UserDefaults.standard.array(forKey: "current_orders") as? [String] {
    orders = oldOrders.filter { $0 != context.state.courierName }
}
orders.append(context.state.courierName)
UserDefaults.standard.set(orders, forKey: "current_orders")

这里通过 UserDefaults 来保持历史收到过的 ContentState,并且通过唯一属性进行去重,这样就可以在后续生成界面的过程中,去展示一下列表。

结论

总的来说,开发和推送更新 Live Activity 和 Dynamic Island 并不是很复杂,但是由于一般开发过程中会使用第三方的推送服务来进行发送推送通知,而在目前这个时间点上,第三方服务都还没有适配 Live Activity 这个类型的推送通知,因此如果需要后台更新 Lvie Activity,但是 App 自身又没有权限保持在后台运行的情况,就需要自行架设相关推送服务并进行对接了,这需要一些额外的开发工作和成本。

希望此文对大家有所帮助。

参考资料

发表评论?

2 条评论。

  1. 请教一下,一直没明白pushToken如何获取,以及如何更新。
    在最初使用Activity.request(attributes: orderAttributes, contentState: initContentState,pushType:***)里的pushType从哪里获取

发表评论


注意 - 你可以用以下 HTML tags and attributes:
<a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <s> <strike> <strong>