跳到主要内容
教程目录

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,按下面的顺序操作。

  1. 给扩展注册 App ID。在 Identifiers 页点加号,选 App IDs → App,Bundle ID 选 Explicit,填 com.example.myapp.myappwidget。
  2. App 的 App ID 开启 Push Notifications。要用推送更新时才需要,在 App 的 App ID(com.example.myapp)的 Capabilities 里勾选 Push Notifications,保存。
  3. 生成描述文件。给扩展的 App ID 新建 Development 描述文件。App 开启推送后,把 App 原来的描述文件 Edit 后直接 Save,重新下载。
  4. 放进 keychain 目录。两份描述文件都放进 KXBuild 的 keychain 目录,删掉 App 的旧描述文件。构建时按 Bundle ID 自动给 App 和扩展各配一份。

用推送还要给 App 加 aps-environment entitlement,做法和普通推送完全一样,按给 iOS App 加推送通知的「给工程加 entitlements」一节操作。实时活动的推送令牌由 ActivityKit 提供,App 不用调用 registerForRemoteNotifications 来拿它。

扩展签名的完整规则见 iOS App 扩展的签名配置。

第 7 步:在 iPhone 上验证

  1. 在状态栏选好 iPhone,按 F5。KXBuild 会把 App 和扩展一起构建、签名并安装,然后启动 App。
  2. 点「开始配送」。下拉通知中心或锁屏,能看到锁屏样式的实时活动。带灵动岛的机型回到主屏幕,灵动岛左右出现图标和倒计时。
  3. 长按灵动岛看展开样式。
  4. 回到 App 点「骑手已取餐」,灵动岛会短暂展开提示,锁屏上的状态和进度跟着变。
  5. 点「已送达,结束」,灵动岛上的活动消失,锁屏上保留最终状态。

调试时注意几点。

  • F5 调试的是 App。实时活动的界面在扩展进程里渲染,App 里的断点管不到它。界面只负责显示,状态的计算放在 App 或服务器上。
  • 看 push token。用了 pushType: .token 的,启动后 VS Code「输出 → KXBuild Console」里会打印 Live Activity push token: 加一串十六进制,下一节要用。
  • 没反应先看开关。「设置」里找到这个 App,确认实时活动是打开的。

怎么用 APNs 推送更新实时活动?

App 不在前台时,最可靠的更新方式是服务器推送。流程如下。

  1. App 用 pushType: .token 启动活动,从 pushTokenUpdates 拿到这个活动专属的 push token,上报给服务器。
  2. 订单状态变化时,服务器用这个 token 给 APNs 发一条 liveactivity 类型的推送。
  3. 系统收到后唤醒扩展,按推送里的 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 要用当前时间,活动已经结束的推送也会被忽略。