iOS 实时活动怎么做?在 Windows 上用 ActivityKit 做灵动岛实时活动
最后更新
iOS 实时活动(Live Activity)用 ActivityKit 把一件正在进行的事放到锁屏和灵动岛上,比如外卖配送、打车、比赛比分。做法是给 App 加一个小组件扩展写界面,在 App 里用 ActivityKit 启动、更新和结束,必要时由服务器通过 APNs 推送更新。
这篇给 SwiftUI 工程 MyApp 加一个「外卖配送」实时活动:用 kxbuild project add-target --type widget-live-activity 添加扩展,写锁屏和灵动岛三种样式,在 App 里启动、更新、结束,最后从电脑用 APNs 推送更新它,全程在 Windows 上完成。
实时活动的界面写在小组件扩展里,建议先看给 iOS App 加一个桌面小组件(WidgetKit)。文中的 Apple 规则以官方文档为准,截至 2026 年 10 月。
iOS 实时活动和小组件有什么不同?
两者都用 SwiftUI 写界面、放在同一个小组件扩展里,但更新方式完全不同。
| 小组件 | 实时活动 | |
|---|---|---|
| 显示位置 | 主屏幕、锁屏、今天视图 | 锁屏、灵动岛、StandBy,配对的 Apple Watch 和 Mac |
| 谁来更新 | 系统按时间线定时取数据 | App 用 ActivityKit 更新,或服务器推送 |
| 持续时间 | 一直在 | 一件事结束就结束,最长 8 小时 |
| 适合 | 一眼看完的汇总信息 | 正在进行、几分钟就变一次的状态 |
| 能否联网 | 时间线里可以 | 不能,数据只能从 App 或推送来 |
几条需要提前知道的限制。
| 规则 | 内容 |
|---|---|
| 系统版本 | ActivityKit 从 iOS 16.1 开始提供,下文用到的 ActivityContent 写法要 iOS 16.2。本文添加的扩展要求 iOS 17 及以上 |
| 灵动岛 | 只有带灵动岛的 iPhone(iPhone 14 Pro 起)显示,其他机型只显示锁屏样式,解锁时以横幅出现 |
| 持续时间 | 活动最多进行 8 小时,到时系统自动结束并从灵动岛移除。结束后最多再在锁屏停留 4 小时,所以锁屏上最长 12 小时 |
| 数据大小 | 不变数据和会变数据加起来不能超过 4 KB,每次更新和推送也是 |
| 启动时机 | 一般要在 App 处于前台时启动,更新和结束可以在后台 |
| 用户开关 | 用户可以在「设置」里关闭某个 App 的实时活动 |
第 1 步:怎么添加实时活动扩展?
以下两种做法任选其一。
- VS Code 插件。在 KXBuild 视图中右键 MyApp 这个 target,选
KXBuild: 添加 Target…,类型选带实时活动的小组件,名称用默认的 MyAppWidget。实时活动不需要和 App 共享数据,App Group 留空。 - 命令行。在工程所在目录执行下面的命令。
1kxbuild project add-target -P .\MyApp.xcodeproj --type widget-live-activity --name MyAppWidget
widget-live-activity 会添加一个小组件扩展,里面同时有一个普通小组件和一个实时活动。扩展的 Bundle ID 是 App 的 Bundle ID 加点和小写的名称,这里是 com.example.myapp.myappwidget,命令输出里会提示为它注册 App ID。全部类型和参数见 kxbuild 命令参考和添加 Target。
已经按 171 篇加过 widget 类型小组件的工程,不用再加一个扩展。把下文的实时活动代码加进原来的扩展,在 MyAppWidgetBundle.swift 的 body 里加上它,再按下一节给 App 开启 Supports Live Activities。
添加扩展后生成了哪些文件?
工程目录下多出一个 MyAppWidget 文件夹。
| 文件 | 作用 |
|---|---|
| MyAppWidgetBundle.swift | 扩展入口,同时列出小组件和实时活动 |
| MyAppWidget.swift | 普通小组件,不需要可以从 Bundle 里去掉 |
| MyAppWidgetLiveActivity.swift | 实时活动的数据定义和界面 |
| Info.plist | 声明这是一个 WidgetKit 扩展 |
| Assets.xcassets | 强调色和背景色 |
MyAppWidgetBundle.swift 里已经同时列出了两者,不用改。
1@main
2struct MyAppWidgetBundle: WidgetBundle {
3 var body: some Widget {
4 MyAppWidget()
5 MyAppWidgetLiveActivity()
6 }
7}
第 2 步:开启 Supports Live Activities
App 要在 Info.plist 里声明 NSSupportsLiveActivities 为 YES,否则启动实时活动会失败。
用 widget-live-activity 添加时,App target 的构建设置里已经加上了 INFOPLIST_KEY_NSSupportsLiveActivities = YES,在工程设置编辑器的 Build Settings 页搜索 NSSupportsLiveActivities 能看到。
工程自己维护 Info.plist 文件的(比如从老 Xcode 项目搬过来的),在 App 的 Info.plist 里加上下面这个键,用属性列表编辑器改也可以。
1<key>NSSupportsLiveActivities</key>
2<true/>
这个键加在 App 的 Info.plist 里,不是扩展的。
第 3 步:实时活动的数据怎么定义?
实时活动的数据分两部分,用一个遵守 ActivityAttributes 的结构体描述。
- 不变的数据,写成结构体的属性,比如订单号、店名。启动时定好,之后不能改。
- 会变的数据,写在嵌套的
ContentState里,比如状态、进度、预计送达时间。每次更新都整份换掉。
App 启动活动时要用到这个类型,扩展显示时也要用到,所以两边各放一份一模一样的定义。字段名、类型、嵌套结构必须完全一致,推送里的 JSON 也按这些字段名解析。
第 4 步:锁屏和灵动岛界面怎么写?
把 MyAppWidget/MyAppWidgetLiveActivity.swift 换成下面的内容。
1import ActivityKit
2import WidgetKit
3import SwiftUI
4
5/// 实时活动的数据。App 里要有一份一模一样的定义
6struct DeliveryAttributes: ActivityAttributes {
7 /// 会变的数据:每次 update 或推送都换一份新的
8 public struct ContentState: Codable, Hashable {
9 var status: String
10 var progress: Double
11 var eta: Date
12 }
13
14 /// 不变的数据:启动时定好,之后不能改
15 var orderNumber: String
16 var shopName: String
17}
18
19struct MyAppWidgetLiveActivity: Widget {
20 var body: some WidgetConfiguration {
21 ActivityConfiguration(for: DeliveryAttributes.self) { context in
22 // 锁屏,以及没有灵动岛的机型上的横幅
23 DeliveryLockScreenView(context: context)
24 .activityBackgroundTint(Color.orange.opacity(0.15))
25 .activitySystemActionForegroundColor(.orange)
26 } dynamicIsland: { context in
27 DynamicIsland {
28 // 展开样式:长按灵动岛,或收到带提醒的更新时出现
29 DynamicIslandExpandedRegion(.leading) {
30 Label(context.attributes.shopName, systemImage: "bag.fill")
31 .font(.caption)
32 .foregroundStyle(.orange)
33 }
34 DynamicIslandExpandedRegion(.trailing) {
35 CountdownText(eta: context.state.eta)
36 .font(.caption.bold())
37 }
38 DynamicIslandExpandedRegion(.center) {
39 Text(context.state.status)
40 .font(.headline)
41 .lineLimit(1)
42 }
43 DynamicIslandExpandedRegion(.bottom) {
44 ProgressView(value: context.state.progress)
45 .tint(.orange)
46 }
47 } compactLeading: {
48 Image(systemName: "bag.fill")
49 .foregroundStyle(.orange)
50 } compactTrailing: {
51 CountdownText(eta: context.state.eta)
52 .frame(width: 44)
53 .font(.caption2.bold())
54 } minimal: {
55 Image(systemName: "bag.fill")
56 .foregroundStyle(.orange)
57 }
58 .keylineTint(.orange)
59 }
60 }
61}
62
63struct DeliveryLockScreenView: View {
64 let context: ActivityViewContext<DeliveryAttributes>
65
66 var body: some View {
67 VStack(alignment: .leading, spacing: 8) {
68 HStack {
69 Label(context.attributes.shopName, systemImage: "bag.fill")
70 .font(.subheadline.bold())
71 .foregroundStyle(.orange)
72 Spacer()
73 Text("订单 \(context.attributes.orderNumber)")
74 .font(.caption)
75 .foregroundStyle(.secondary)
76 }
77 HStack(alignment: .firstTextBaseline) {
78 Text(context.isStale ? "状态可能已过时" : context.state.status)
79 .font(.headline)
80 Spacer()
81 CountdownText(eta: context.state.eta)
82 .font(.headline)
83 }
84 ProgressView(value: context.state.progress)
85 .tint(.orange)
86 }
87 .padding()
88 }
89}
90
91/// 倒计时由系统负责走字,不需要每秒更新实时活动
92struct CountdownText: View {
93 let eta: Date
94
95 var body: some View {
96 if eta > Date() {
97 Text(timerInterval: Date()...eta, countsDown: true)
98 .monospacedDigit()
99 .multilineTextAlignment(.trailing)
100 } else {
101 Text("即将送达")
102 }
103 }
104}
ActivityConfiguration 的第一个闭包是锁屏样式,dynamicIsland 闭包里是灵动岛的三种样式。三种都要提供,系统按场景自动选。
| 样式 | 什么时候出现 | 代码位置 |
|---|---|---|
| 紧凑(compact) | 只有一个实时活动时,摄像头左右各一块 | compactLeading、compactTrailing |
| 最小(minimal) | 多个 App 同时有实时活动时,缩成一个小圆 | minimal |
| 展开(expanded) | 长按灵动岛,或收到带提醒的更新时短暂出现 | DynamicIsland { } 里的各个区域 |
展开样式用 DynamicIslandExpandedRegion 分区摆放内容。
| 区域 | 位置 |
|---|---|
.leading |
摄像头左侧,内容多时往下排 |
.trailing |
摄像头右侧,内容多时往下排 |
.center |
摄像头下方 |
.bottom |
以上三块的下方,适合放进度条 |
写界面时注意几点。
- 倒计时交给系统。
Text(timerInterval:countsDown:)会自己走字,不要为了让秒数变化去频繁更新活动。 - 紧凑样式要窄。宽度有限时内容会被截断,用图标或很短的文字。
- 锁屏高度。超过 160 点系统可能截断。
- 过时状态。启动和更新时可以给一个
staleDate,到时context.isStale变成true,界面上提示信息可能不是最新的。 - 动画。实时活动里
withAnimation这类动画修饰会被忽略,系统用自己的过渡效果。 - 图片。图片分辨率不能大于显示区域,太大的图可能导致活动启动失败。
点击实时活动默认打开 App。
第 5 步:在 App 里怎么启动、更新和结束?
把 App 的 MyApp/ContentView.swift 换成下面的内容。文件开头是和扩展里一模一样的 DeliveryAttributes,后面是管理实时活动的类和三个按钮。
1import ActivityKit
2import SwiftUI
3
4/// 和小组件里的定义保持一字不差
5struct DeliveryAttributes: ActivityAttributes {
6 public struct ContentState: Codable, Hashable {
7 var status: String
8 var progress: Double
9 var eta: Date
10 }
11
12 var orderNumber: String
13 var shopName: String
14}
15
16@available(iOS 16.2, *)
17@MainActor
18final class DeliveryActivityManager: ObservableObject {
19 @Published var message = ""
20 private var activity: Activity<DeliveryAttributes>?
21
22 init() {
23 // App 被系统关掉后重新打开,接回还在进行的实时活动
24 activity = Activity<DeliveryAttributes>.activities.first
25 }
26
27 func start() {
28 guard ActivityAuthorizationInfo().areActivitiesEnabled else {
29 message = "实时活动已在设置中关闭"
30 return
31 }
32 let attributes = DeliveryAttributes(orderNumber: "A1024", shopName: "街角咖啡")
33 let state = DeliveryAttributes.ContentState(
34 status: "商家已接单",
35 progress: 0.2,
36 eta: Date().addingTimeInterval(20 * 60)
37 )
38 do {
39 let activity = try Activity.request(
40 attributes: attributes,
41 content: ActivityContent(state: state, staleDate: Date().addingTimeInterval(30 * 60)),
42 pushType: .token // 不用推送更新时传 nil
43 )
44 self.activity = activity
45 message = "已启动 \(activity.id)"
46 watchPushToken(of: activity)
47 } catch {
48 message = "启动失败:\(error.localizedDescription)"
49 }
50 }
51
52 func update() async {
53 guard let activity else { return }
54 let state = DeliveryAttributes.ContentState(
55 status: "骑手已取餐",
56 progress: 0.7,
57 eta: Date().addingTimeInterval(8 * 60)
58 )
59 await activity.update(
60 ActivityContent(state: state, staleDate: Date().addingTimeInterval(15 * 60)),
61 alertConfiguration: AlertConfiguration(title: "骑手已取餐", body: "预计 8 分钟送达", sound: .default)
62 )
63 message = "已更新"
64 }
65
66 func end() async {
67 guard let activity else { return }
68 let final = DeliveryAttributes.ContentState(status: "已送达", progress: 1, eta: Date())
69 await activity.end(ActivityContent(state: final, staleDate: nil), dismissalPolicy: .default)
70 self.activity = nil
71 message = "已结束"
72 }
73
74 private func watchPushToken(of activity: Activity<DeliveryAttributes>) {
75 Task {
76 // 令牌是异步生成的,而且中途可能变,每次变化都要重新上报
77 for await data in activity.pushTokenUpdates {
78 let token = data.map { String(format: "%02x", $0) }.joined()
79 print("Live Activity push token: \(token)")
80 // 在这里把 token 和订单号一起发给你的服务器
81 }
82 }
83 }
84}
85
86@available(iOS 16.2, *)
87struct DeliveryPanel: View {
88 @StateObject private var manager = DeliveryActivityManager()
89
90 var body: some View {
91 VStack(spacing: 16) {
92 Button("开始配送") { manager.start() }
93 .buttonStyle(.borderedProminent)
94 Button("骑手已取餐") { Task { await manager.update() } }
95 Button("已送达,结束") { Task { await manager.end() } }
96 Text(manager.message)
97 .font(.footnote)
98 .foregroundStyle(.secondary)
99 }
100 }
101}
102
103struct ContentView: View {
104 var body: some View {
105 if #available(iOS 16.2, *) {
106 DeliveryPanel()
107 .padding()
108 } else {
109 Text("实时活动需要 iOS 16.2 及以上")
110 }
111 }
112}
三个操作分别对应 ActivityKit 的三个方法。
| 操作 | 方法 | 要点 |
|---|---|---|
| 启动 | Activity.request(attributes:content:pushType:) |
App 在前台时调用。pushType: .token 表示要用推送更新,会拿到一个 push token |
| 更新 | activity.update(_:alertConfiguration:) |
传一份新的 ContentState。带 AlertConfiguration 时,灵动岛机型会短暂展开,其他机型以横幅提示 |
| 结束 | activity.end(_:dismissalPolicy:) |
带上最终状态。.default 让它在锁屏多留一段时间,.immediate 立即移除,.after(date) 到指定时间移除 |
启动可能失败,比如用户关了实时活动,或者设备上同时进行的活动已经到上限,所以 request 要放在 do/catch 里,把错误提示给用户。Activity<DeliveryAttributes>.activities 能拿到这个 App 当前所有进行中的活动,App 重启后用它接回之前的活动。
暂时不做推送的,把 pushType: .token 改成 pushType: nil,下面的推送能力也可以先不开。
第 6 步:App ID 和描述文件要怎么准备?
实时活动所在的扩展是一个单独的程序,签名用自己的描述文件。登录 developer.apple.com,进入 Certificates, Identifiers & Profiles,按下面的顺序操作。
- 给扩展注册 App ID。在 Identifiers 页点加号,选 App IDs → App,Bundle ID 选 Explicit,填
com.example.myapp.myappwidget。 - App 的 App ID 开启 Push Notifications。要用推送更新时才需要,在 App 的 App ID(
com.example.myapp)的 Capabilities 里勾选 Push Notifications,保存。 - 生成描述文件。给扩展的 App ID 新建 Development 描述文件。App 开启推送后,把 App 原来的描述文件 Edit 后直接 Save,重新下载。
- 放进 keychain 目录。两份描述文件都放进 KXBuild 的
keychain目录,删掉 App 的旧描述文件。构建时按 Bundle ID 自动给 App 和扩展各配一份。
用推送还要给 App 加 aps-environment entitlement,做法和普通推送完全一样,按给 iOS App 加推送通知的「给工程加 entitlements」一节操作。实时活动的推送令牌由 ActivityKit 提供,App 不用调用 registerForRemoteNotifications 来拿它。
扩展签名的完整规则见 iOS App 扩展的签名配置。
第 7 步:在 iPhone 上验证
- 在状态栏选好 iPhone,按 F5。KXBuild 会把 App 和扩展一起构建、签名并安装,然后启动 App。
- 点「开始配送」。下拉通知中心或锁屏,能看到锁屏样式的实时活动。带灵动岛的机型回到主屏幕,灵动岛左右出现图标和倒计时。
- 长按灵动岛看展开样式。
- 回到 App 点「骑手已取餐」,灵动岛会短暂展开提示,锁屏上的状态和进度跟着变。
- 点「已送达,结束」,灵动岛上的活动消失,锁屏上保留最终状态。
调试时注意几点。
- F5 调试的是 App。实时活动的界面在扩展进程里渲染,App 里的断点管不到它。界面只负责显示,状态的计算放在 App 或服务器上。
- 看 push token。用了
pushType: .token的,启动后 VS Code「输出 → KXBuild Console」里会打印Live Activity push token:加一串十六进制,下一节要用。 - 没反应先看开关。「设置」里找到这个 App,确认实时活动是打开的。
怎么用 APNs 推送更新实时活动?
App 不在前台时,最可靠的更新方式是服务器推送。流程如下。
- App 用
pushType: .token启动活动,从pushTokenUpdates拿到这个活动专属的 push token,上报给服务器。 - 订单状态变化时,服务器用这个 token 给 APNs 发一条
liveactivity类型的推送。 - 系统收到后唤醒扩展,按推送里的
content-state重新渲染界面。
push token 属于某一个活动,和普通推送的设备令牌不是一回事,每启动一个新活动都要上报新的 token。它在活动期间也可能变化,收到新 token 后服务器要换掉旧的。
请求头
推送请求和普通推送用同一个 APNs 地址和 .p8 密钥签的 JWT,只是三个请求头不同。
| 请求头 | 值 | 说明 |
|---|---|---|
apns-push-type |
liveactivity |
固定值 |
apns-topic |
com.example.myapp.push-type.liveactivity |
App 的 Bundle ID 加 .push-type.liveactivity,不是扩展的 Bundle ID |
apns-priority |
5 或 10 |
10 立即送达,计入每小时的推送额度;5 不计入额度 |
地址按环境选。F5 装上去的 Debug 包用 https://api.sandbox.push.apple.com,TestFlight 和 App Store 包用 https://api.push.apple.com。.p8 密钥的创建见 158 篇的「创建 APNs 认证密钥」。
请求体
1{
2 "aps": {
3 "timestamp": 1792370815,
4 "event": "update",
5 "content-state": {
6 "status": "骑手已取餐",
7 "progress": 0.7,
8 "eta": 814064095
9 },
10 "stale-date": 1792371715
11 }
12}
| 字段 | 作用 |
|---|---|
timestamp |
当前时间,1970 年起的秒数。系统据此只显示最新的一次更新 |
event |
update 更新,end 结束 |
content-state |
字段要和 ContentState 完全对应,系统用默认规则解码,不能用自定义的 JSON 编码策略 |
stale-date |
可选,到这个时间界面变成过时状态 |
dismissal-date |
可选,只用于 end,结束后在这个时间从锁屏移除,填过去的时间就是立即移除 |
alert |
可选,带上后灵动岛机型会展开,其他机型显示横幅 |
eta 是 Date 类型,最容易出错。系统用默认规则解码 Date,它要的是从 2001 年 1 月 1 日起算的秒数,不是 Unix 时间戳。服务器上用 Unix 秒数减去 978307200 再填进去。不想处理这个差值,可以把字段改成 Double 类型的 Unix 秒数,界面里再用 Date(timeIntervalSince1970:) 转换。
从电脑发一条测试推送
在 158 篇用过的文件夹里(里面已经有 .p8 密钥),新建 la.mjs,改掉开头四个常量。脚本只用 Node.js 自带模块,会自动填好 timestamp 和换算好的 eta。
1import crypto from 'node:crypto'
2import fs from 'node:fs'
3import http2 from 'node:http2'
4
5const TEAM_ID = 'ABCDE12345' // Team ID
6const KEY_ID = 'XYZ1234567' // Key ID
7const KEY_FILE = 'AuthKey_XYZ1234567.p8' // .p8 文件
8const BUNDLE_ID = 'com.example.myapp' // App 的 Bundle ID,不是扩展的
9const HOST = 'https://api.sandbox.push.apple.com' // F5 装的 Debug 包走开发环境
10
11function makeJwt() {
12 const b64 = (obj) => Buffer.from(JSON.stringify(obj)).toString('base64url')
13 const unsigned = b64({ alg: 'ES256', kid: KEY_ID }) + '.' + b64({ iss: TEAM_ID, iat: Math.floor(Date.now() / 1000) })
14 const key = crypto.createPrivateKey(fs.readFileSync(KEY_FILE))
15 const sig = crypto.sign('sha256', Buffer.from(unsigned), { key, dsaEncoding: 'ieee-p1363' })
16 return unsigned + '.' + sig.toString('base64url')
17}
18
19const [event, token, status = '骑手已取餐', minutes = '8', progress = '0.7'] = process.argv.slice(2)
20if (!['update', 'end'].includes(event) || !token) {
21 console.log('用法 node la.mjs update|end <push token> [状态] [剩余分钟] [进度]')
22 process.exit(1)
23}
24
25const now = Math.floor(Date.now() / 1000)
26const aps = {
27 timestamp: now,
28 event,
29 'content-state': {
30 status,
31 progress: Number(progress),
32 // Swift 的 Date 默认按 2001-01-01 起的秒数解码
33 eta: now + Number(minutes) * 60 - 978307200,
34 },
35}
36if (event === 'update') aps['stale-date'] = now + 15 * 60
37if (event === 'end') aps['dismissal-date'] = now + 60 * 60
38const payload = JSON.stringify({ aps })
39console.log(payload)
40
41const client = http2.connect(HOST)
42client.on('error', (err) => { console.error(err.message); process.exit(1) })
43const req = client.request({
44 ':method': 'POST',
45 ':path': '/3/device/' + token,
46 authorization: 'bearer ' + makeJwt(),
47 'apns-topic': BUNDLE_ID + '.push-type.liveactivity',
48 'apns-push-type': 'liveactivity',
49 'apns-priority': '10',
50})
51let code
52let body = ''
53req.on('response', (headers) => { code = headers[':status'] })
54req.setEncoding('utf8')
55req.on('data', (chunk) => { body += chunk })
56req.on('end', () => { console.log(code, body); client.close() })
57req.end(payload)
在 App 里点「开始配送」,从 KXBuild Console 复制 push token,然后锁上手机,执行下面的命令。
1node la.mjs update 粘贴的push token "骑手距您 1 公里" 3 0.9
返回 200 表示 APNs 已经接收,锁屏上的状态、倒计时和进度几秒内就会变化。再执行一次 end 结束活动。
1node la.mjs end 粘贴的push token "已送达" 0 1
返回 400、403 时,响应里的 reason 说明了原因,对照 158 篇的「APNs 返回错误」一节处理。最常见的是 apns-topic 写成了扩展的 Bundle ID,或者 Debug 包发到了生产地址。
更新频率
系统给每个 App 的实时活动推送设了每小时额度,具体数字没有公开。用 apns-priority: 10 的推送计入额度,超了会被限流;5 不计入额度,送达没那么及时。
- 不需要用户马上看到的变化(进度从 60% 到 65%)用 5,关键节点(骑手已到楼下)用 10。
- 倒计时用
Text(timerInterval:)交给系统,不要靠推送每秒更新。 - 比分直播这类每分钟要更新多次的场景,在 App 的 Info.plist 里加
NSSupportsLiveActivitiesFrequentUpdates为 YES,申请更高的频率。用户可以在「设置」里关掉它,App 里用ActivityAuthorizationInfo().frequentPushesEnabled检查。
推送可能因为没网送不到,活动结束后到达的推送也会被忽略。App 回到前台时再用 update 同步一次,避免显示过时的信息。
用推送启动活动
iOS 17.1 及更早的系统只能用推送更新和结束实时活动,不能用推送启动。
更新的系统上,App 可以从 Activity<DeliveryAttributes>.pushToStartTokenUpdates 拿到一个启动用的令牌,服务器用它发 event 为 start 的推送,请求体要带 attributes-type(这里是 DeliveryAttributes)、attributes 和 alert。这一步做完基础流程后再加。
实时活动不显示或报错怎么排查?
| 现象 | 原因 | 处理 |
|---|---|---|
Activity.request 抛错,提示不支持或未授权 |
App 的 Info.plist 没有 NSSupportsLiveActivities,或用户关了实时活动 |
按上文检查构建设置,「设置」里打开该 App 的实时活动 |
| 启动成功但锁屏、灵动岛都不显示 | 扩展没装上,或手机系统低于扩展要求的 iOS 17 | 确认 F5 时扩展也签名成功,手机系统是 iOS 17 及以上 |
| 只有锁屏样式,没有灵动岛 | 机型没有灵动岛 | 正常现象,用带灵动岛的机型测试 |
| 启动失败,提示达到上限 | 同时进行的活动太多 | 结束旧的活动,request 要处理错误 |
| 一直拿不到 push token | App 没开推送能力或没带 aps-environment |
开通 Push Notifications,重新生成描述文件,加 entitlement |
| 推送返回 200,界面没变 | content-state 字段名或类型和 ContentState 对不上,或者 Date 填成了 Unix 秒数 |
逐个核对字段,Date 用 2001 年起的秒数 |
推送返回 400 TopicDisallowed 或 DeviceTokenNotForTopic |
apns-topic 不对,或 token 和环境不匹配 |
topic 用 App 的 Bundle ID 加 .push-type.liveactivity,Debug 包用 sandbox 地址 |
| 构建时报扩展的描述文件找不到 | keychain 里没有 com.example.myapp.myappwidget 的描述文件 |
注册 App ID 并生成描述文件,放进 keychain |
| 活动几小时后自己消失 | 到了 8 小时上限 | 正常现象,长时间的事情拆成多个活动,或改用推送通知 |
签名和安装相关的报错见 iOS 签名报错与 ipa 安装失败,16 条常见错误及解决办法。
带实时活动的 App 怎么打包上架?
上架时 App 和扩展都要有 App Store 类型的描述文件,放进 keychain 目录后,在命令面板或工程的右键菜单运行 KXBuild: 生成 App Store ipa,或者执行下面的命令。
1kxbuild build .\MyApp.xcodeproj -c Release --export app-store
生成的 build\MyApp.ipa 里已经带着 iOS 实时活动扩展。正式版的推送要发到 https://api.push.apple.com,用环境为 Production 的 .p8 密钥签 JWT,令牌也换成正式包上报的新令牌。扩展和 App 的版本号要一致,改版本号时在工程设置编辑器里把 MyApp 和 MyAppWidget 一起改。
常见问题
实时活动最低支持哪个 iOS 版本?
ActivityKit 从 iOS 16.1 开始提供,本文用的 ActivityContent 写法要 iOS 16.2。用 KXBuild 添加的扩展要求 iOS 17 及以上,所以按本文做的实时活动在 iOS 17 及以上的 iPhone 上显示。
实时活动最长能显示多久?
一个实时活动最多进行 8 小时,到时系统自动结束并从灵动岛移除。结束后还能在锁屏上停留最多 4 小时,所以锁屏上最长 12 小时。
没有灵动岛的 iPhone 能用实时活动吗?
能。没有灵动岛的机型显示锁屏样式,手机解锁时带提醒的更新会以横幅出现。灵动岛样式只在 iPhone 14 Pro 起带灵动岛的机型上显示。
实时活动推送的 apns-topic 怎么写?
写成 App 的 Bundle ID 加 .push-type.liveactivity,例如 com.example.myapp.push-type.liveactivity,同时把 apns-push-type 设为 liveactivity。不要用扩展的 Bundle ID,令牌要用 pushTokenUpdates 拿到的活动令牌。
推送返回 200 但实时活动没更新是什么原因?
多半是 content-state 和 ContentState 的字段名或类型对不上,系统解码失败就不更新。Date 字段要填从 2001 年 1 月 1 日起的秒数,timestamp 要用当前时间,活动已经结束的推送也会被忽略。