iOS16 Live Activity 初体验

  • WWDC
  • 发布于2022年07月31日

WWDC 2022 Keynote 中苹果给我们介绍了 iOS 16 中一个比较亮眼的更新:Live Activity(实时活动),开发者可以在锁屏页面上放置一个可以“实时”更新的 Widget,比如外卖或者打车应用,在开启实时活动之后我们可以在锁屏页上实时看到外卖小哥/司机与我们的距离及预计到达时间。但是这一 API 及对应功能并没有第一时间放出,而是随着 iOS 16 Beta4 一起放出:【实时活动现已推出 Beta 版本】

这篇文章主要是对官方 API 做一个简单提炼,并梳理下一些需要注意的点。

写在前面

  • Live Activity 功能及正式 API 不会随 iOS 16 的首个正式版本释出,而是在今年晚些时候释出,具体时间没有给;
  • 只有在 Live Activity 正式释出后,才可提交带对应功能的 App 版本到 App Store;
  • Live Activity 仅 iPhone 可用;
  • 下面提及到的代码及示例都是 Beta 版的,可能随时会发生变化,建议在正式版释出后着重关注

Live Activity 后续均使用实时活动来翻译。

实际使用/开发体验

设备及开发环境:iPhone 12 iOS 16 Beta 4、Xcode 14 Beta 4、macOS 12.4

  1. 当前版本(iOS16 Beta 4)锁屏页面展示实时活动,不需要用户授权也不需要用户手动添加,开启实时活动后会自动展示在锁屏页上,但是用户可以在设置中手动关闭。猜测后续大概率需要用户授权,不然可能会被某些开发者利用;
  2. 同一个应用可以展示多个实时活动,但是会被折叠(类似通知),锁屏页面可以有多个 app 同时展示,会按 app 分组,可以看下截图;
  1. 没有付费账号,还没尝试使用远程推送来更新/停止实时活动;
  2. 实时活动锁屏 UI 必须使用 SwiftUI,相对来说比较简单,实时活动组件的高度不能超过 220px(原文是 220 pixels,但实际测试发现是 220pt),否则系统会自动裁切;
  3. 基于 Widget,但刷新机制不一样,widget 是根据时间线来更新,而实时活动则不受这个控制,可以使用远程推送或者宿主应用代码来更新,暂时没看到更新频率相关限制;
  4. 因为是基于 Widget,所以还是可以给控件绑定不同 deep link,使其可以跳转到对应页面;
  5. 整体上来说实时活动的适配比较容易,重要的还是结合 App 的实际场景合理运用应该能取得不错效果,后续应该会有很多有创意的 idea 出现,可以期待一波。

写了个 Demo,模拟地铁到站预估时间的场景,代码放在 GitHub/iOS16LiveActivityDemo 上了,有啥疑问可以留言或者提 issue。


以下内容主要对文档做个翻译.

实时活动的要求和约束

  • 一个实时活动在应用或用户结束前能够存活8 个小时,如果 8 小时内没有结束,系统会自动结束该实时活动;
  • 已结束的实时活动会在在锁屏页上保留4 个小时,之后系统会自动将其移除,当然期间用户也可以手动移除;
  • 综上,一个实时活动在锁屏页上最长可以停留12 个小时
  • 实时活动有自己的沙盒,跟 Widget 不一样的一点是:它无法使用网络也不能接受地理位置更新;
  • 我们可以在应用内使用 ActivityKit 来更新实时活动,也可以在实时活动的 Widget 中接收远程推送来更新,下面会具体说到;

让应用适配实时活动

如果应用之前已经有 Widget,那么可以在已有的 Widget Extension 中添加实时活动的相关实现;如果之前没有的话可以新建一个。值得注意的是:实时活动并不是 widget,他们的更新机制有较大区别。上面也有提到,实时活动是通过应用内的 ActivityKit 或者远程推送来更新的,而 widget 则依赖系统的 timeline 机制。

下面是适配的相关步骤:

  1. 创建一个 Widget Extension,如果已有,则可跳过这一步;
  1. Info.plist 文件中添加一个键值对,key 为 NSSupportsLiveActivities,value 为 YES;
  1. 在代码里定义一组 ActivityAttributes 以及 Activity.ContentState,后续会用它们来开始、更新及结束实时活动;
  2. 创建 Widget 并返回一个 ActivityConfiguration
  3. 添加开始、更新、结束实时活动的相关代码,并设计对应的 UI 样式;
  4. 运行查看效果。
import ActivityKit
import SwiftUI
import WidgetKit

// 示例代码,展示披萨配送的实时活动

// 继承 ActivityAttributes ,定义自定义属性用于widget UI展示
// Attributes 用来定义不可变的静态数据,比如这里的披萨数量和花费
struct PizzaDeliveryAttributes: ActivityAttributes {
    public typealias PizzaDeliveryStatus = ContentState

    // ContentState用来封装动态(会发生变化的)数据,比如这里的配送员名字、预计送达时间
    public struct ContentState: Codable, Hashable {
        var driverName: String
        var estimatedDeliveryTime: Date
    }
    var numberOfPizzas: Int
    var totalAmount: String
}

struct PizzaDeliveryActivityWidget: Widget {
    var body: some WidgetConfiguration {
        ActivityConfiguration(attributesType: PizzaDeliveryAttributes.self) { context in
            // 根据数据创建锁屏widget UI,系统默认情况下文字颜色使用白色,然后使用最适合锁屏页的背景色;也可以像下面这样使用 activityBackgroundTint(_:) 来设置自定义颜色
            VStack {
                Text("\(context.attributes.numberOfPizzas) ordered for \(context.attributes.totalAmount).")
                HStack {
                    Text("\(context.state.driverName) is on their way with your pizza!")
                    Text(context.state.estimatedDeliveryTime, style: .timer)
                }
            }.activityBackgroundTint(Color.cyan)

            // 或者像这样使用ZStack的方式在最底下放置背景视图
            ZStack {
                Color.cyan
                VStack {
                    Text("\(context.attributes.numberOfPizzas) ordered for \(context.attributes.totalAmount).")
                    HStack {
                        Text("\(context.state.driverName) is on their way with your pizza!")
                        Text(context.state.estimatedDeliveryTime, style: .timer)
                    }
                }
            }.activitySystemActionForegroundColor(Color.cyan)
        }
    }
}

值得注意的是实时活动 Widget 的最大高度不能超过 220px(原文是 220 pixels,但实际测试发现是 220pt),否则系统会自动裁剪

检查实时活动是否可用

由于实时活动仅在 iPhone 上生效,同时用户也可以在设置中手动关闭某个应用的实时活动,所以在使用前最好要做一个检测。

  • 使用 areActivitiesEnabled 来同步判断开始实时活动前是否显示锁屏 UI;
  • 使用异步接口 activityEnablementUpdates 来检测用户授权状态的变更。

需要注意的是:每个应用可以开启若干个实时活动,同时系统也能展示多个 app 的实时活动;所以我们在启动、更新、结束实时活动时,也要考虑出错的情况以提供更好的用户体验。

启动实时活动

应用在前台的时候,我们可以使用 request(attributes:contentState:pushType:) 方法来启动实时活动,对应参数 attributes 作为实时活动的初始值,contentState 作为动态变化的数据。如果应用实现了远程推送,也可以提供 pushType 参数,后面远程推送部分会讲到。

// 启动实时活动示例代码

// 提供初始化值
let pizzaDeliveryAttributes = PizzaDeliveryAttributes(numberOfPizzas: 42, totalAmount:"$420,-")

// 提供动态变化数据,预估配送到达时间为1小时后
let initialContentState = PizzaDeliveryAttributes.PizzaDeliveryStatus(driverName: "Bill James", estimatedDeliveryTime: Date().addingTimeInterval(60 * 60))

// 启动实时活动,这里 pushType 暂时置 nil
do {
    let deliveryActivity = try Activity<PizzaDeliveryAttributes>.request( attributes: pizzaDeliveryAttributes, contentState: initialContentState, pushType: nil)
    print("Requested a pizza delivery Live Activity \(deliveryActivity.id)")
} catch (let error) {
    print("Error requesting pizza delivery Live Activity \(error.localizedDescription)")
}

更新实时活动

启动实时活动后我们可以得到一个 Activity 实例,接着我们可以调用该实例 update(using:) 方法来更新实时活动。我们也可以通过 Activity。activities 方法来获取当前所有的实时活动实例。

更新一个已结束的实时活动会被忽略。

let updatedDeliveryStatus = PizzaDeliveryStatus(driverName: "Anne Johnson", estimatedDeliveryTime: Date().addingTimeInterval(60 * 60))
do {
    // deliveryActivity是上面启动时拿到的Activity实例
    // 更新的数据大小不能超过4KB
    try await deliveryActivity.update(using: updatedDeliveryStatus)
} catch(let error) {
    print("Error updating activity \(error.localizedDescription)")
}

内容更新动画

系统会忽略实时活动 widget 的所有动画修饰符,比如withAnimation(_:_:)animation(_:value:)。系统会自动给动态变化的内容添加动画,比如会给 Text 添加模糊的过渡效果,给 Image 以及 SF Symbol 添加过渡动画。如果更新过程中有视图的添加或移除,系统也会给他们加上淡入淡出的过渡动画。

我们也可以使用系统内置的过渡动画:opacitymove(edge:)slidepush(from:) 或者将它们组合使用,对于那种计时的 Text,我们也可以使用 numericText(countsDown:) 修饰符来做文本变化动画。

结束实时活动

在关联的事件/任务结束时,我们也应该结束对应的实时活动。上面也有提到结束后的实时活动在用户手动移除前还会在锁屏页上停留 4 小时。当然我们也可以使用 end(using:dismissalPolicy:) 方法指定实时活动结束后的移除策略。

let updatedDeliveryStatus = PizzaDeliveryStatus(driverName: "Anne Johnson", estimatedDeliveryTime: Date())
do {
    // 指定移除策略为默认,即用户手动移除前停留4小时
    // 还有 im/mediate 即立即移除,以及可以指定一个移除的时间 after(Date)
    try await deliveryActivity.end(using: updatedDeliveryStatus, dismissalPolicy: .default)
} catch(let error) {
    print("Error ending activity \(error.localizedDescription)")
}

需要注意的是,用户可以在任意时间将实时活动从锁屏页面移除,该操作相应的也会结束对应的实时活动,但是他不会取消用户在启动实时活动时的一些行为。比如上面披萨配送示例里,尽管用户可以移除披萨配送信息的实时活动,但不代表取消了对应的披萨订单。

使用远程推送更新/结束实时活动

除了上述的更新和结束的方式,我们还可以通过推送通知来实现,具体实现流程和逻辑其实普通的推送通知区别不大,这里不做赘述。有一点不同的是:实时活动不需要使用 registerForRemoteNotifications() 来注册推送通知,我们使用 ActivityKit 来获取推送 token。具体流程如下:

  1. 启动实时活动时需要指定 pushType 参数为 .token,或者不传该参数(参数默认值就为 .token);
  2. 成功启动后,将拿到的 pushToken 发送给服务端,后续使用该 token 来给对应实时活动发送推送通知;
  3. 服务端使用对应 token 发送推送时需要必须要指定 content-state 字段的值和代码里的 Activity.ContentState 匹配上,这样系统才能解码对应 JSON 内容来更新实时活动;
  4. 使用推送内容来更新或结束对应的实时活动;
  5. 使用 pushTokenUpdates 监听实时活动实例的 pushToken 变化,并将新值发送给服务端同时废弃旧值,
  6. 当实时活动结束后,通知服务端废弃对应 token。

模拟器上测试实时活动的远程推送需要使用 T2 或 M 系列芯片的 Mac,并且要求系统 >=macOS 13。

如果你不清楚自己电脑是否是 T2,可以通过如图方式确认,按住 Option 键,并点击左上角  查看 系统信息->控制器即可

也可以使用直接在下方列表查找,基本上 18 年后的都支持。

下面是一个对应上方披萨配送示例的 push payload 数据

{
    "aps": {
        "timestamp": 1650998941,
        "event": "update",
        // 这里和上述 PizzaDeliveryAttributes.ContentState 里的字段一一对应
        "content-state": {
            "driverName": "Anne Johnson",
            "estimatedDeliveryTime": 1659416400
        }
    }
}

跟新追踪

Activity 这个类除了拥有一个 id 的唯一标识外,还提供了一系列的状态变化的监听,比如内容状态、活动状态以及 push token 的变更。我们可以使用对应的监听来更新应用,让实时活动与应用保持同步。

  • 使用 activityStateUpdates 来监听实时活动的状态,判断是否已结束;
  • 使用 contentState 来监听实时活动的动态内容变化;
  • 使用 pushTokenUpdates 来监听实时活动的 push token 变化。

查看实时活动列表

一个应用可以同时开启多个实时活动,比如用户可以同时关注多个球赛直播,我们可以使用 activityUpdates 来获取当前正在进行中的实时活动。 在某些场景下我们可能也会用到这个方法来获取当前进行中的实时活动,比如应用闪退后再次打开应用,如果想要结束或更新某些实时活动,就可以通过这个方法来获取到所有的实时活动。

// Fetch all ongoing pizza delivery activities.
let activityStream = Activity<PizzaDeliveryAttributes>.activityUpdates() for await activity in activityStream { print("Pizza delivery details: \(activity.description)") }
本博客所有文章除特别声明外,均采用CC 4.0许可协议。转载请注明出处和作者。

关注微信公共号Vong或在微博上关注@Vong_HUST,永远不会错过新内容! 您的支持和鼓励将为我的博客写作增添更多的动力!

动态更新