深入理解灵动岛

0 阅读17分钟

写在前面

集成灵动岛踩了一些坑,回看发现是对灵动岛的了解还不够透彻,就重新梳理了一下灵动岛从开始到结束的一整条链路,当然最好的方式是有所输出,于是就有了这篇文章。

1. 灵动岛是什么?

14 pro 之后,苹果从齐刘海过渡到了挖孔时代,挖孔的那个孔就是前置摄像头,没办法好像实在没地方放了,为了美观,用黑色的像素将这块区域给包了起来,就像个黑色的 “药丸”,然后让系统可以动态的去控制这块区域,比如你用计时器开个计时,它会显示倒计时,放首歌,专辑的封面就会挂在那里,连个 AirPods,它也会给你弹个小动画等等。它可以变形、伸缩、分裂,这块黑色的、由 系统掌控的,环绕前置传感器挖孔的屏幕显示区域, 就是灵动岛。

灵动岛_1.png

它有三种状态,与上图对应的分别是:

  • 紧凑态(Compact):以挖孔为界,分成左(leading)右(trailing)两小块——有东西在跑时的默认样子
  • 最小态(Minimal):一个小圆点 / 小药丸,当同时有第二个活动要抢位置的时候用
  • 展开态(Expanded):长按之后,膨胀成一张大卡片

那么谁能往上面放东西呢?有两类:

  • 系统自己的
    • 来电
    • FaceID
    • 充电
    • 静音
    • AirPods
    • 屏幕录制
    • 导航
    • 正在播放的音乐
    • ...
  • 第三方 APP

第三方 APP,说的就是我们这些开发者开发的 APP,第三方 APP 想上岛,只有一种途径,那就是使用 Live Activities(实时活动) 框架。

但是这里有一个关键的区分,就是所谓的集成灵动岛,其实是集成 Live Activities,但是 Live Activities 其实不仅仅会在灵动岛中展示,它有两个地方可以展示,一个是灵动岛,另一个则是你的锁屏页面。当你收到一个 Live Activities 的内容时,会同时展示在你的灵动岛和你的锁屏页面。

哪些设备能用?

首次推出是在 14 pro 上,然后 14 pro 和 max 都能用,15 及之后的基本是全系都支持。

另外一个是系统版本的要求,iOS 16.0 是跟随 14 pro 一起发布,但是 16.0 仅支持系统自己使用,等到 16.1 的时候才允许第三方上岛,所以其实 最低的系统要求是 iOS 16.1

还有就是编程语言的要求,要实现灵动岛的页面部分,必须使用 SwiftUI,因为 Live Activity 的 UI 是跑在 Widget Extension 里的,而 WidgetKit 只认 SwiftUI,不支持 UIKit。

你的主 APP 不必一定是 SwiftUI,启动 / 更新 / 结束 活动的那套 ActivityKit 代码是普通的 Swift API,所以老的 UIKit 项目也能正常使用,只是那一小块的 Widget 界面得用 SwiftUI 来写。

所以要求是:

  • 14 pro 或以后机型
  • iOS 16.1
  • App 可以是 UIKit,但灵动岛那块 UI 必须是 SwiftUI

正如我们前面说过,我们集成的是 Live Activities,Live Activities 不仅仅作用于灵动岛,即使是在并没有灵动岛的机型上,当系统接收到一条灵动岛消息时,(如果你在使用手机)也会在手机界面上弹出一个 alert,类似通知一样的横幅。然后它还会有一个常驻的入口,也就是你的锁屏页面,会有一张卡片常驻在你的锁屏页面。

2. 如何集成

先来看看数据是怎么流的:

① 配置证书 / 能力
② 定义数据模型 (Attributes 静态 + ContentState 动态)
③ 搭 UI (Widget Extension, SwiftUI)
④ App:启动活动 → 监听拿 token → 上报服务端
        │
        ▼
⑤ 服务端 → APNs 推送 (apns-push-type: liveactivity + 新 content-state)
        │
        ▼
⑥ 系统用新 content-state 重渲染你 Widget 里的视图   ← App 进程全程不参与!
        │
        ▼
⑦ 用户在 锁屏 / 灵动岛 看到更新   →   ⑧ App 或服务端发 end 结束活动

下面开始一步步解析这些步骤。

2.1 证书

从证书说起,Live Activity 走的也是 APNs,所以需要打开 Push Notifications 能力。

灵动岛_3.png

虽然走的同一条路,但是 Live Activity 有自己的专用通道,用的 token 也是属于 Live Activity 的 token。

iOS 的 token 有三种:

token绑定的 topic怎么拿
普通推送 device token<bundleID>registerForRemoteNotifications(UserNotifications)
Live Activity token<bundleID>.push-type.liveactivityActivityKit
VoIP token<bundleID>.voipPushKit

token 本质上是「发往某个 Topic 的投递地址」,token 属于哪一种,取决于它绑定哪个 topic。

对于 Live Activity,区分了 topic 之后,再根据作用域区分:

  • App 级,push-to-start token(Activity.pushToStartTokenUpdates):一个 app 一个,不依赖任何活动,用来起新的活动
  • 活动级,per-activity token(Activity.pushTokenUpdates):每个运行中的活动各一个,用来更新/结束那个活动

最后再根据 event 来区分你要做什么动作:

  • start
  • update
  • end

下面的部分我会给出一个完整的消息体的示例。

2.2 工程结构 + 定义模型

Live Activity 是属于 WidgetKit 的内容,所以需要在工程中新建一个 Widget Extension:

灵动岛_4.png

然后需要将 Include Live Activity 勾上 ———— 会顺带生成 ActivityAttributes 骨架和 Live Activity 的 widget 模板:

灵动岛_5.png

所以一个 Live Activity 工程固定两个 target:

  • 主 App(ActivityKit):启动 / 更新 / 结束活动,拿 token 上报
  • Widget Extension(WidgetKit):画锁屏视图 + 灵动岛三态。是独立进程,独立 Bundle ID

然后就是定义模型的部分,上一步创建的 Attributes 长这样:

@available(iOS 16.1, *)
struct LiveActivityDemoAttributes: ActivityAttributes {

    /// 动态数据:每次本地 / 推送更新时整体替换。
    /// 字段名必须和服务端 content-state 的 key 一一对应。
    public struct ContentState: Codable, Hashable {
        var emoji: String        // 紧凑态左侧 emoji
        var title: String        // 标题(锁屏 / 展开态)
        var body: String         // 正文(锁屏 / 展开态)
        var progress: Double?    // 可选:进度 0...1
    }

    /// 静态数据:创建时确定,之后不可变。
    var name: String
}

有个要注意的点是,Attributes 文件需要加到两个 target 中去,不然会编译不过:

灵动岛_6.png

模型不止两个 target 共用,服务端也需要对齐:

{
  "aps": {
    "timestamp": 1782000060,                          // Unix 秒(必填):系统据此判新旧、丢弃过期包
    "event": "start",                                 // 动作:start=创建 / update=更新 / end=结束
    "attributes-type": "LiveActivityDemoAttributes",  // 必须 = 客户端 ActivityAttributes 结构体名,错配则 iOS 静默拒绝(APNs 仍 200)
    "attributes": {                                   // 静态数据(创建后不可变),对应结构体里非 ContentState 的字段
      "name": "LiveActivityDemo"                      // = LiveActivityDemoAttributes.name
    },
    "content-state": {                                // 动态数据;字段名 + 类型必须和 ContentState 一一对应
      "emoji": "⬇️",                                  // ContentState.emoji    (String,必填)
      "title": "下载中",                               // ContentState.title    (String,必填)
      "body": "模型包.zip",                            // ContentState.body     (String,必填)
      "progress": 0.62                                // ContentState.progress (Double?,可选;必须是数字,不能是 "0.62")
    },
    "alert": {                                        // start 必须带,否则系统不弹灵动岛;update 时这块可选
      "title": "已下载 62%",                           // 锁屏 / 横幅提示标题
      "body": "点击查看进度"                            // 锁屏 / 横幅提示正文
    }
  }
}

这是服务端提交给 apns 的一个完整的消息体,要和 Attributes.swift 中的定义对应。

2.3 注册 / 启动活动(客户端)

下面就是在客户端拿到 Live Activity 的 token,上报给服务端。

不同 iOS 系统获取的方式不一样:

  • 16.1 只能先启动活动,再拿这个活动的 token(update)
  • 16.2 能力和 16.1 一样,但是获取 token 的 API 换成了新的(update)
  • 17.2 可以在 App 关闭的情况下,凭空拉起灵动岛(start)

request 之前,需要做一下权限检查,ActivityAuthorizationInfo().areActivitiesEnabled,为 true 时才进行 request

在此之前,需要在 info.plist 中设置 NSSupportsLiveActivities = YES,不然的话 ActivityAuthorizationInfo().areActivitiesEnabled 会一直返回 false。

注意下文中的 type 2 代表 update/end,type 3 代表 start,这是我们和服务端的约定,而不是苹果的 type。

iOS 16.1,先起活动 → 监听这个活动的 token:

// iOS 16.1
let activity = try Activity.request(
    attributes: attributes,
    contentState: initialState,   // 旧 API:直接传 ContentState
    pushType: .token              // 要远程推送 / 拿 token,必须 .token
)

// token 异步下发、会轮换 —— 必须持续监听,不能读一次
Task {
    for await tokenData in activity.pushTokenUpdates {
        let token = tokenData.map { String(format: "%02x", $0) }.joined()
        reportToken(token, pushType: 2)   // per-activity token
    }
}

16.1 需要先 request,然后再通过回调来获取 token,16.1 还没有 start,所以都是 update。

iOS 16.2,能力一样,API 换成 ActivityContent:

// iOS 16.2+
let activity = try Activity.request(
    attributes: attributes,
    content: ActivityContent(state: initialState, staleDate: nil),  // 新 API
    pushType: .token
)

Task {
    for await tokenData in activity.pushTokenUpdates {
        let token = tokenData.map { String(format: "%02x", $0) }.joined()
        reportToken(token, pushType: 2)
    }
}

能力和 16.1 是一样的,也是需要先 request,只是换了个 api,用 ActivityContentstate 包一层,顺带能设 staleDate(过期时间)。

iOS 17.2,不用先起活动,直接拿 App 级 token:

17.2 是一个质变,有了 push-to-start token(pushToStartTokenUpdates)。它是 App 级的,不需要先有活动,服务端拿到它,就能在 App 没在跑、一个活动都没有时把活动拉起来。

// iOS 17.2+:App 级 push-to-start token(type 3)
if #available(iOS 17.2, *) {
    Task {
        for await tokenData in Activity<MyAttributes>.pushToStartTokenUpdates {
            let token = tokenData.map { String(format: "%02x", $0) }.joined()
            reportToken(token, pushType: 3)   // push-to-start token
        }
    }

    // 服务端用 type-3 token 把活动起来后,App 这边用 activityUpdates 捕获它,
    // 再监听这个新活动自己的 token(type 2),用于后续更新
    Task {
        for await activity in Activity<MyAttributes>.activityUpdates {
            observeActivityToken(activity)   // 内部仍是 activity.pushTokenUpdates → type 2
        }
    }
}

合起来就是:

if #available(iOS 17.2, *) {
    observePushToStartToken()     // type 3:监听 App 级 token
    observeNewActivities()        // 捕获服务端拉起的活动 → 监听其 type 2
} else if #available(iOS 16.2, *) {
    startActivityWithNewAPI()     // 没有 push-to-start:自己起一个活动拿 type 2(新 API)
} else {
    startActivityWithLegacyAPI()  // iOS 16.1:旧 API 起活动拿 type 2
}

2.4 服务端的处理

拿到 token 之后交给服务端,然后让服务端去处理和苹果的交互,好巧不巧,服务端也是我写的,那就顺便也说说服务端。

服务端就不贴代码了,主要做三件事:

  1. 按种类存好 token
  2. 发的时候 优先 update,失效再发 start
  3. 及时清理挂掉的 token

可以看看下面的流程图:

灵动岛_7.png

2.5 UI 搭建 & App Group

UI 这部分没有太多说的,但是需要注意的是,让 UI 同学注意一下灵动岛的 设计规范,不然可能有些样式无法实现。

另外如果需要使用主 target 的数据的话,要在证书中加上 App Group 的能力,这样主 target 和 Live Activity 的 数据才能互通。

如果要建 App Group,那么其实一共有三个 Identifier,两个 Profile。

三个 Identifier 分别是:

  • 主 App 的 Identifier
  • Live Activity 的 Identifier
  • App Group 的 Identifier

这是 App Group 的创建:

灵动岛_8.png

填的时候会自动加上 group. 前缀。

灵动岛_9.png

在主 App 和 Live Activity 的 Identifier 中,勾上 App Group,然后选择你新建的 App Group 的 Identifier,就可以将主 App 和 Live Activity 关联起来,这样就能共享 App Group 的数据了。

注意,主 App 和 Live Activity 都要打开 App Group 能力。

灵动岛_10.png

两个 Profile 就是主 App 的 profile 和 Live Activity 的 profile。

2.6 渲染

经过前面的步骤,App、服务端、系统,其实这条通道已经搭好了,App 发起注册,服务端拿 token 去和 APNs 发消息,然后系统收到之后,渲染出来,那么就剩最后一步,系统是怎么渲染的。

首先,渲染这个过程,App 是不参与的,也就是需要将样式和逻辑提前定义到项目中,比如:

@available(iOS 16.1, *)
struct LiveActivityDemoAttributes: ActivityAttributes {

    /// 动态数据:每次本地 / 推送更新时整体替换。
    /// 字段名必须和服务端 content-state 的 key 一一对应。
    public struct ContentState: Codable, Hashable {
        var emoji: String        // 紧凑态左侧 emoji
        var title: String        // 标题(锁屏 / 展开态)
        var body: String         // 正文(锁屏 / 展开态)
        var progress: Double?    // 可选:进度 0...1
    }

    /// 静态数据:创建时确定,之后不可变。
    var name: String
}

然后是渲染的部分,把 progress 画出来:

struct LiveActivityDemoLALiveActivity: Widget {
    var body: some WidgetConfiguration {
        ActivityConfiguration(for: LiveActivityDemoAttributes.self) { context in
            // ===== 锁屏卡片 =====
            LockScreenCard(state: context.state)
                .activityBackgroundTint(Color.black.opacity(0.85))
                .activitySystemActionForegroundColor(Color.white)
        } dynamicIsland: { context in
            let state = context.state
            return DynamicIsland {
                // ===== 展开态 =====
                // leading/trailing 留空,内容全放 .bottom 自己排:
                // 贴边内容会被展开态的大圆角遮罩裁剪(进度条两端被切),这样布局可控
                DynamicIslandExpandedRegion(.leading) { EmptyView() }
                DynamicIslandExpandedRegion(.trailing) { EmptyView() }
                DynamicIslandExpandedRegion(.bottom) {
                    ExpandedContent(state: state)
                }
            } compactLeading: {
                // ===== 紧凑态左侧 =====
                Text(state.emoji)
                    .padding(.leading, 4)
            } compactTrailing: {
                // ===== 紧凑态右侧 =====
                if let p = state.progress {
                    Text("\(Int(p * 100))%")
                        .font(.system(size: 14, weight: .medium)).monospacedDigit()
                        .foregroundStyle(p >= 1.0 ? Color.green : Color.blue)
                        .contentTransition(.numericText())   // 数字滚动过渡
                        .padding(.trailing, 2)
                }
            } minimal: {
                // ===== 最小态 =====
                Text(state.emoji)
            }
            .keylineTint(.blue)
        }
    }
}

写了个 demo,有需要的话可以看看,代码都在里面。

效果:

灵动岛_11.gif

接下来就是系统的工作了,App 全程不参与,就算 App 没打开也一样:

APNs ──▶ 设备(系统接收)
  ① 校验:token 和 topic 对得上吗?timestamp 比上一条新吗?(旧的直接丢弃)
  ② 看 event:start = 创建 / update = 更新 / end = 结束
  ③ 解码:把 content-state 这段 JSONCodable 解成你的 ContentState
  ④ 重渲染:拿新的 ContentStateWidget 里那份视图重画一遍
        锁屏卡片灵动岛紧凑态/展开态/最小态,一起刷新

关于渲染的部分,有几个点是需要补充的:

1. 三条硬约束

在 Widget Extension 里的 UI 有三条硬约束,全都源自「渲染的是系统,不是你的 App」:

  • 不能联网:视图里没有发请求的机会,所有数据只能来自 ContentState
  • 不能滚动:ScrollView 放上去不报错,但是不能滚动,这是一张静态卡片,不是页面
  • 图片只能用本地的:Image 只能加载扩展自己的 Assets 里的资源(或 App Group 容器里的文件),不能给个 URL 让它异步加载。而 ContentState 又有 4KB 的上限,塞不下图。所以如果想通过服务端来换图,只能预先内置在客户端,服务端下发编号,客户端来映射

2. 点击跳转

点击灵动岛或者锁屏的卡片,默认就是打开 App,如果想跳到具体页面,那就需要使用 widgetURL(_:) 或者 Link。

最小态和紧凑态时,只有一个点击目标,只能使用 widgetURL,所谓的一个点击目标就是灵动岛本身,不能再具体到灵动岛上的某一个 UI 元素。另外如果需要跳转到某个具体的页面,是需要在 widgetURL 中带上参数的,比如 widgetURL(URL(string: "liveactivitydemo://open?from=lockscreen")),但是经过实测,iOS 26 上,紧凑态和最小态拉起 App 时,系统不派发任何深链信号widgetURLLinkButton(intent:)NSUserActivity 全都试过了,一个都不响应。所以你可以认为 iOS 26 之后,紧凑态和缩小态点击后,widgetURL 是无参的。

所以,如果想要精确的跳转某个路由,只能在展开态和锁屏上去处理

展开态和锁屏可以通过 Link 去绑定不同的元素,也就是可以同时存在多个 Link,点击不同的元素就可以响应不同的操作。

2.7 小结

流程图大致如下:

灵动岛_2.png

  1. 客户端拿到灵动岛的 token
  2. 把 token 给服务端
  3. 服务端走 APNs
  4. 系统收到推送
  5. 拉起或更新 Live Activity

3. 值得一说的坑

3.1 Start 一时爽

刚集成的时候,觉得要什么 Update,来一个 Satrt 一个不香吗,因为 Start 的效果用户感知是最强烈的。结果发现没弹几次就再也弹不出来了(走 APNs)。因为 start 是新建,不是更新:每 start 一次就多一个活动,旧的并不会消失,而苹果对于活动的数量是有上限要求的,达到了上限之后,再 request / start 会直接失败。

所以正确的方式是,有活动的 token 就 update,update 失效(服务端调 APNs 失败,410/400 错误码)时再降级 start,这样去让灵动岛「原地更新」,而不是去不断的新建。

但是这种策略还有一个坑,就是如果你去 update 一个结束活动的 token,APNs 的返回是 200,但是手机界面上什么都不会发生,也不会触发降级。这样就导致服务端一直以为自己在正常更新,用户却再也看不到新的灵动岛。

苹果也没有具体说明一个活跃的活动多久会失效,只有个大概时间,8~12 小时,解决的方法就是,当一个活动的 token 注册的时间 > 8 小时,就当它已经失效了,直接清理掉,走 start 新建。

客户端同样需要处理,在冷启动的时候检查 Activity<MyAttributes>.activities,把多余的活动 end 掉(比如只保留最新一个)。不然开发阶段反复 start,真机上很快就堆满了。

3.2 静默失败:两边都说成功,用户啥也没看到

APNs 返回 200,客户端不报错,但是没有看到任何灵动岛的内容,大致的原因如下:

  1. attributes-type 错配:必须和客户端 ActivityAttributes 的结构体名一字不差,错了 iOS 直接拒绝创建,但 APNs 照样返回的是 200
  2. content-state 和 ContentState 对不上:字段名差一个字母、少一个必填字段,Codable 解码失败,都是静默不更新
  3. 类型不匹配:比如把 0.62 发成了 "0.62",同样也是解码失败,不更新

所以发现 APNs 推送成功了但是没效果,先排查是不是哪里的字段写错了,当然也要注意可能是累计太多 start 没清理。

3.3 关于 update

我在 demo 中的例子,是在本地更新的一个 demo 例子,activity.update() 是跑在 App 进程里的代码,进程被杀就没了。所以如果要 App 不在的时候也要更新数据,必须走推送。

还有一些按时间就能算出来的东西(倒计时、匀速进度),是不需要一直更新的,只需要在首次的时候给个时间,然后交给系统本地走秒就行,App 关闭也可以正常一秒一秒的跳。

3.4 环境错配

APNs 有两个独立环境,host 不一样,token 也不通用,debug 属于 Sandbox,release/testflight/appstore 属于 Production。

沙盒应该发到 api.sandbox.push.apple.com,生产包发到 api.push.apple.com,如果是 debug 包可以 release 包不行,优先看这个。

3.5 排查和定位问题

其实知道灵动岛的一条链路是怎么流转的,基本也能知道怎么去排查问题,确定链路是在哪一部分断掉的就可以了。

  1. App 内(权限 / request / 拿 token)
  2. token 上报与存储
  3. 服务端 -> APNs
  4. 系统渲染

本地出现的异常还好,问题在于怎么去排查线上的问题,线上唯一能依赖的就是我们在每一个关键的步骤上去埋点或者记录日志,一条灵动岛从注册到显示,线上可观测到的内容是:

授权状态 → 拿到 token → 上报成功 → 服务端下发(status/reason) → ??? → 用户点击回流
                                                    ↑
                                   这一段(系统渲染)没有任何回执,是观测盲区

一步一步来,从客户端开始:

  1. pushType: nil 起一个纯本地活动、本地 update —— 能正常启动,能正常更新,说明模型和 UI 的定义是没问题的,问题在于推送链路
  2. 检查授权状态,冷启 / 回前台各报一次,变化才上报(这个可以知道用户有多少是正常关闭授权的状态而不是异常收不到灵动岛)
  3. token 注册:看注册是断在哪一环
  4. 点击时,Link 或者 widgetURL 处加埋点,上报来源和 id
  5. 冷启:上报 Activity.activities 数量(看是否堆积的活动过多)

服务端:

  1. 用 uid 反查 token,看 token 是哪种,注册了多久
  2. 查调用 APNs 的记录,接口是否报错
  3. 看 token 注册了多久,字段是否正确,attributes-type 和 bundleId 的映射等等是否正确等
  4. 绕过服务端,用 curl 直接发客户端拿到的 token 到 APNs
  5. 把服务端的 content-state 放到客户端的用 JSONDecoder 解一遍,如果有错误可以明确的看到是哪个字段的问题

就是确保每个关键链路都有日志可看,这样基本就能排查出链路是在哪一步断掉的。

写在最后

整篇写下来,核心其实就是一句话:灵动岛上跑的不是你的 App,而是系统。你能做的,只是提前把样式和模型交给系统(Widget Extension),再通过 token 给系统传消息(APNs)。

再回顾一下整条链路:

  1. 配好 Push 能力和 Info 开关,新建 Widget Extension
  2. 定义 Attributes / ContentState,主 App、Extension、服务端三方对齐
  3. 按 iOS 版本拿 token,分种类上报
  4. 服务端优先 update,失败了降级走 start,及时清理
  5. 剩下的交给系统渲染,如果有问题,按链路来分段排查

灵动岛的位置非常显眼,即使没有挖孔的手机,也就是还没有灵动岛的手机,也能收到展开态的灵动岛通知和锁屏的卡片,但是推的多用户也烦,所以推什么内容,什么频次也需要认真的考虑。

有什么问题欢迎交流。

另附上 demo