灵动岛从 iPhone 14 Pro 发布到现在,已经不是一个新概念,但对开发者来说,它仍然是一个“看起来容易、做起来容易踩坑”的功能。很多人只是把灵动岛理解为挖孔屏上的黑色药丸动画,实际上它是基于系统级 UI 渲染、Live Activity 状态管理和动态交互的一套完整能力。换句话说,灵动岛的开发价值不在于把按钮塞进那个“岛”里,而在于怎么用 Live Activities 把后台任务实时地呈现给用户,并且在一套系统规范下安全、稳定地更新。
这篇文章会把灵动岛拆成开发向的问题来看:它能做什么、做不了什么、需要哪些前置条件、用 SwiftUI 怎么搭、用 ActivityKit 怎么创建和更新 Live Activity、真机调试要注意什么、常见崩溃和显示异常怎么排查。如果你正在做外卖进度、骑行导航、运动记录、会议提醒、音乐播放这类需要“锁屏也能看进度”的 App,这篇文章可以直接当成一篇落地参考来用。
1. 灵动岛核心能力速览
动岛是 iOS 在系统层面提供的一种交互式状态展示区域。它的常用载体是 Live Activity(实时活动),开发者通过 ActivityKit 和 WidgetKit 来驱动显示内容。
| 能力项 | 说明 |
|---|---|
| 系统要求 | 支持 Live Activity 的系统版本通常为 iOS 16.1 及以上,灵动岛显示以带灵动岛的机型为准 |
| 开发语言 | Swift、SwiftUI,部分场景需要 Widget Extension |
| 主要框架 | ActivityKit、WidgetKit、SwiftUI,远程更新场景需要 APNs |
| 启动方式 | 在 App 中调用 ActivityKit 请求启动 Live Activity,灵动岛区域由系统接管 |
| 核心功能 | 锁屏实时活动、灵动岛紧凑显示、灵动岛扩展显示、状态更新、结束时删除 |
| 交互能力 | 点击灵动岛区域可唤起 App,也可配合 Deep Link 跳转指定页面 |
| 推送更新 | 支持通过远程推送更新动态岛内容,也支持本地刷新 |
| 是否支持批量任务 | 单个 App 可同时存在多个 Live Activity,系统按顺序展示在灵动岛和锁屏上 |
| 适合场景 | 外卖配送、打车、运动计步、航班动态、计时器、下载进度、赛事比分、会议提醒等 |
2. 适用场景与使用边界
灵动岛适合展示的是“用户离开 App 之后仍然关心的短时任务”。典型特征是:任务有明确的状态变化、变化频率不需要太高、用户希望在不解锁或不清扫的情况下就能看到结果。
比较典型的场景包括:
- 外卖或快递订单的配送进度。
- 打车时查看司机位置和预计到达时间。
- 运动 App 记录跑步距离和时长。
- 航班 App 展示登机口和起飞时间变化。
- 音乐播放器在切到后台后显示播放状态。
- 倒计时和番茄钟场景。
灵动岛不适合做长驻通知,也不适合做高频刷新的信息流。它本质上是“实时活动”的展示壳层,苹果对 Live Activity 有明确的系统级限制,比如自动过期时间、更新频率限制、展示位置策略等。把它当成普通通知中心来用,很快就会遇到任务被系统自动清理,或灵动岛区域被多次活动挤占的问题。
这里也要提醒一句:不要用灵动岛强制引导用户点击广告或诱导开启 Live Activity。展示内容如果涉及订单、定位、运动数据或用户隐私,必须在合法授权的前提下使用。骑手位置、司机信息、用户行程这类数据,单独看一个字段可能没问题,但组合起来能推断出用户行为轨迹,发布前要做隐私合规评估。涉及真实人物照片、声音、人脸识别等相关功能时,上线前必须确认已经取得明确授权。
3. 开发环境与前置条件
3.1 硬件与系统前提
灵动岛是硬件和系统配合的结果,不是所有 iPhone 都能显示。做开发时至少需要一台带灵动岛的 iPhone 真机,因为模拟器不能完全验证灵动岛在真实亮度、锁屏状态、通知共存下的表现。
如果你要在真机上调试 Live Activity,需要把系统升级到 iOS 16.1 及以上。Xcode 也要升级到支持 ActivityKit 的版本,过于旧的 Xcode 连 framework 都找不到。
3.2 Xcode 开发配置
开发灵动岛功能,会在一个普通 iOS App 工程里做这些事:
- 使用 ActivityKit 发起 Live Activity。
- 新建一个 Widget Extension,用来渲染锁屏和灵动岛。
- 在 Widget Extension 中配置 ActivityConfiguration。
- 在主 App 中根据业务事件调用更新和结束方法。
创建项目和 Extension 时可以按以下路径操作:
File -> New -> Target- 选择
Widget Extension - 勾选
Include Live Activity - 填写 Extension 名称
如果项目已经建好但没勾选 Include Live Activity,也可以通过手动创建 ActivityConfiguration 的方式来补,但更稳妥的做法是重新生成一个带 Live Activity 的 Widget Target,然后再把代码迁进去。
Widget Extension 需要独立的 Bundle Identifier,通常是在主 App 的 Bundle ID 后面加.Widget。签名、授权、Team ID 配置好之后,App 和 Widget Extension 才能共享 ActivityKit 的数据。
3.3 部署目标建议
由于 ActivityKit 是 iOS 16.1 以后才有的能力,开发时不要直接把 Widget 的 deployment target 设到更低版本,否则需要在代码里做可用性判断:
if #available(iOS 16.1, *) { // 使用 ActivityKit } else { // 降级到普通通知或本地推送 }这是很关键的一点。灵动岛能力只对支持 Live Activity 的系统版本生效,老系统用户需要走原来的通知逻辑,不能把灵动岛当成唯一的状态出口。
4. 灵动岛 UI 设计与 SwiftUI 布局
灵动岛并不是一块自由绘制的屏幕区域。苹果把它分成几类形态,开发者的控制范围取决于当前状态。
4.1 灵动岛的三种主要形态
| 形态 | 使用时机 | 可布置内容 |
|---|---|---|
| 紧凑形态 | 只有一个 Live Activity 且不是最前时 | 左侧图标或文字 + 右侧图标或文字 |
| 最小形态 | 多个 Live Activity 并存时 | 单一小图标或极简信息 |
| 扩展形态 | 长按或处于前台时展开 | 左侧、右侧、底部区域可放更多内容 |
这种系统接管的设计,决定你不应该用自定义 View 把整个岛盖住。做 UI 时应该先遵守系统给的区域划分和布局约束,否则在部分机型上会出现截断、偏移或内容显示不全的问题。
4.2 SwiftUI 基础布局
灵动岛区域通常通过DynamicIslandExpandedRegion来组织内容。下面是一段典型的 SwiftUI 布局示意:
DynamicIsland { DynamicIslandExpandedRegion(.leading) { Label("配送中", systemImage: "shippingbox.fill") .font(.headline) } DynamicIslandExpandedRegion(.trailing) { Text("剩余 800 米") .font(.caption) } DynamicIslandExpandedRegion(.bottom) { ProgressView(value: 0.8) .progressViewStyle(.linear) .tint(.green) Text("骑手正在配送,请保持电话畅通") .font(.caption2) .foregroundColor(.secondary) } } compactLeading: { Image(systemName: "shippingbox.fill") } compactTrailing: { Text("800m") .font(.caption2) } minimal: { Image(systemName: "shippingbox.fill") }上面这段代码是在构建订单配送场景的灵动岛扩展区域。注意,SwiftUI 布局不能超过安全区域边界,信息过多时宁可精简,也不要为了“显示完整”而使用会被裁剪的复杂布局。
做灵动岛 UI 时,一个实际经验是:先做锁屏实时活动视图,再做灵动岛视图。因为锁屏视图代码比较直接,灵动岛区域则依赖系统状态切换,调试起来成本更高。
5. ActivityKit 接入与 Live Activity 开发
UI 只是外观,真正控制灵动岛生命周期的是 ActivityKit。
5.1 定义 ActivityAttributes
每个 Live Activity 需要在工程中定义一个遵循ActivityAttributes协议的类型。这个类型里要区分两类数据:
- 固定数据:启动 Live Activity 之后不变化的属性。
- ContentState:任务过程中会不断变化的状态数据。
比如订单配送:
import ActivityKit struct DeliveryActivityAttributes: ActivityAttributes { public struct ContentState: Codable, Hashable { var statusText: String var progress: Double } var orderNumber: String var deliveryAddress: String }orderNumber适合放在外部固定数据中,因为这一单启动后不会变。配送文案、进度条数值则要放进ContentState,方便每次更新活动时替换。
5.2 启动 Live Activity
当用户下单成功或进入某个实时任务时,主 App 调用 ActivityKit 启动活动。
在 iOS 16.2 之前,常见写法是:
let initialState = DeliveryActivityAttributes.ContentState( statusText: "商家已接单", progress: 0.1 ) let activity = try? Activity<DeliveryActivityAttributes>.request( attributes: DeliveryActivityAttributes( orderNumber: "A10086", deliveryAddress: "杭州市余杭区" ), contentState: initialState, pushType: nil )在 iOS 16.2 以后,建议使用ActivityContent:
let initialContent = ActivityContent( state: DeliveryActivityAttributes.ContentState( statusText: "骑手已取餐", progress: 0.6 ), staleDate: nil ) let activity = try? Activity<DeliveryActivityAttributes>.request( attributes: DeliveryActivityAttributes( orderNumber: "A10086", deliveryAddress: "杭州市余杭区" ), content: initialContent, pushType: nil )启动成功后,系统会返回一个Activity实例。后面所有更新和结束操作都基于这个实例完成。
注意,启动 Live Activity 并不是一定成功。系统在磁盘空间不足、后台活动过多、状态受限等情况下可能拒绝创建。代码里不要用try!,应该对失败做降级处理,比如退回本地通知。
5.3 更新 Live Activity
当业务状态发生变化时,主 App 要创建新的ContentState并调用更新。以骑手到达为例:
let newContent = ActivityContent( state: DeliveryActivityAttributes.ContentState( statusText: "骑手已送达", progress: 1.0 ), staleDate: nil ) Task { await activity?.update(newContent) }实时状态更新不要太频繁。灵动岛面向的是短时任务,不需要在几百毫秒内连续刷新几十次。频繁更新会造成电量消耗上升,也容易触发系统对 Live Activity 的刷新限制。
如果 App 在前台,状态变化可以用本地 API 更新;如果 App 退到后台甚至被杀死,要依赖推送来唤醒系统更新。
5.4 结束 Live Activity
任务一旦到达终止状态,就应该及时调用结束方法,避免占用系统资源:
let finalContent = ActivityContent( state: DeliveryActivityAttributes.ContentState( statusText: "订单已完成", progress: 1.0 ), staleDate: nil ) Task { await activity?.end(finalContent, dismissalPolicy: .immediate) }结束策略有三种常用情况:
| 终止方式 | 适用场景 |
|---|---|
.default | 由系统决定何时移除 |
.immediate | 任务已经完成,立即移除 |
.after(Date) | 延迟一段时间后再移除,适合展示完成后的奖励或总结 |
不要忘记结束逻辑。尤其是外卖、打车、倒计时这类最终会终止的任务,如果用户已经取消订单但 Live Activity 还挂在岛上,体验会非常错乱。
6. Widget Extension 中渲染灵动岛
ActivityKit 只是控制生命周期,真正画出来的是 Widget Extension。因为灵动岛会被系统在多个区域、多种形态下调度,所有 UI 必须收敛到同一个ActivityConfiguration中。
6.1 注册 Live Activity Widget
在 Widget Bundle 中,需要把锁屏 Widget 和 Live Activity Widget 都放进去:
@main struct DeliveryWidgetBundle: WidgetBundle { var body: some Widget { DeliveryLockScreenWidget() DeliveryLiveActivity() } } struct DeliveryLiveActivity: Widget { var body: some WidgetConfiguration { ActivityConfiguration(for: DeliveryActivityAttributes.self) { context in // 这里是锁屏上的实时活动视图 LockScreenDeliveryView(context: context) } dynamicIsland: { context in // 这里是灵动岛视图 } } }这里的ActivityConfiguration是整个开发的桥接点。context.state对应ContentState,context.attributes对应自定义的固定属性。
6.2 锁屏实时活动视图
锁屏视图是一块相对宽裕的展示区域,可以放置更多信息:
struct LockScreenDeliveryView: View { let context: ActivityViewContext<DeliveryActivityAttributes> var body: some View { HStack(spacing: 12) { Image(systemName: "shippingbox.fill") .font(.title2) .foregroundColor(.blue) VStack(alignment: .leading, spacing: 4) { Text("订单 \(context.attributes.orderNumber)") .font(.headline) Text(context.state.statusText) .font(.subheadline) .foregroundColor(.secondary) ProgressView(value: context.state.progress) .tint(.blue) } } .padding() } }锁屏视图和普通 Widget 一样会被系统缓存,不要在里面发起网络请求或执行耗时操作。它只是状态的“投影”,不是业务逻辑执行器。
6.3 让灵动岛能点击跳转
灵动岛不是纯展示,点击它可以唤起 App。这一步要做的是在动态岛区域里添加 SwiftUI 的Link或Button,并通过 deep link 让 App 打开对应页面。
DynamicIslandExpandedRegion(.bottom) { HStack { Text(context.state.statusText) Spacer() Link(destination: URL(string: "yourapp://order/\(context.attributes.orderNumber)")!) { Text("查看详情") } } }主 App 侧需要用onOpenURL处理这个 deep link,根据订单号跳转到对应的详情页。如果 App 被灵动岛唤起时进程已经被系统清理,就要在冷启动路由中解析 URL,保证用户点进去后能看到正确页面。
7. 远程推送更新与动态内容下发
在实际业务中,App 进程不一定常驻。订单状态往往由服务端更新,这时候不能依赖 App 自己刷新 Live Activity,必须通过推送更新灵动岛内容。
远程更新 Live Activity 会用到 APNs 推送的content-state数据。服务端发送推送时,请求体里包含活动对应的push-type、activity-id和状态字段。不同业务字段需要与 App 里的ContentState字段保持一致,否则系统可能解析失败或展示旧数据。
从开发流程上看,需要先请求pushToken:
Task { let pushToken = try await activity.pushToken // 把 pushToken 上传到服务端 }请求到 token 之后,由服务端保存并关联到当前订单。后面每个订单状态节点,服务端都往 APNs 发一条 Live Activity 推送。
推送内容不需要携带全部 UI 数据,只需携带变化的ContentState字段。这套机制能把实时性从“App 还活着”扩展到“App 被杀死之后依然能更新”。
远程推送更新需要后端配合,所以做灵动岛功能时建议和后端把推流协议先对齐,不要把字段定义放在客户端开发最后阶段才确认。
8. 功能测试与效果验证
灵动岛功能必须在真机上验证,不能只看 SwiftUI 预览。建议按下面的顺序做测试:
8.1 基础启动测试
先在 App 里点击一个按钮启动 Live Activity,确认控制台没有报错,随后按电源键锁屏,观察灵动岛上是否出现紧凑形态。如果屏幕上同时存在多个 Live Activity,长按灵动岛区域,观察是否能展开并看到自己 App 的内容。
8.2 状态更新测试
启动 Live Activity 后,切到后台,再通过通知或本地模拟按钮触发状态更新。每更新一次,锁屏和灵动岛上的文字、进度条都应该变化。若内容不变,先检查是不是同一个 Activity 实例更新。
一个常见错误是:每次状态变化都调用request重新创建 Activity,结果旧的 Activity 一直不结束,导致灵动岛被多个重复活动占满。正常业务里应该把activity实例做成订单维度的单例或由管理器持有。
8.3 结束测试
结束 Activity 后,锁屏和灵动岛上的内容都应消失。如果结束后仍然残留,很可能是结束的不是同一个实例,或者结束方法没有走完。
测试时可以用这个顺序:
- 创建 Activity。
- 写入日志记录 activity.id。
- 用这个 id 执行更新和结束。
- 检查 UI 是否按预期消失。
8.4 推送测试
推送更新是最容易出问题的环节。测试时不要直接从 App 内部调用更新,而要模拟服务端推送,从 APNs 发一条 payload 过来,观察 UI 是否正常变化。
一些推送服务会阻塞在证书或 token 校验阶段,排错时先检查 APNs 响应码,不要只看客户端是否报错。
9. 资源占用与性能观察
灵动岛日常承载的 UI 非常轻量,它不是无限动画的画布。系统对 Live Activity 的更新频率、运行时长度、展示数据量都有限制,这也是为了控制耗电和系统资源占用。
在开发时观察性能和稳定性,可以重点关注这几点:
- 大量动画是否卡顿。灵动岛不是动画播放器,尽量避免使用复杂的 Spring 动画。
- 高频更新是否被系统丢弃。如果业务要求每秒钟刷新多次位置,建议先降到 5 到 10 秒一次再观察效果。
- 多个 Activity 并存时是否出现覆盖。测试时至少要创建两个 Activity,确认系统如何排列和切换。
- App 被杀死后,Activity 是否还能正常更新。这个场景依赖推送链路,本地更新无法覆盖。
如果想分析耗电,可以在真机上用 Xcode 的 Energy Log 或位置跟踪记录,对比有 Live Activity 和无 Live Activity 时的耗电差异。但需要注意,这跟推送频率、定位权限、屏幕常亮都有关系,不能单看灵动岛一个变量。
9.1 降低资源占用的常见做法
- 禁用不必要的动态效果。
- 减少
Text和图片资源的频繁变化。 - 同一时间尽量只保留一个 Live Activity。
- 状态已经结束时就立即调用结束方法。
- 推送频率控制在业务可容忍的最低水平。
10. 常见问题与排查方法
开发灵动岛功能时,最经常遇到的是下面这些问题。
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
| Activity.request 无法创建活动 | 系统版本低于 iOS 16.1,或系统资源受限 | 检查版本、检查磁盘可用空间和后台活动数量 | 降级到 iOS 16.1 以下的通知;重启设备后再试 |
| 灵动岛区域不显示自己的 App | Widget Target 没有包含 Live Activity 配置 | 检查 Widget Bundle 中是否注册 ActivityConfiguration | 注册对应 ActivityConfiguration |
| 动态内容更新后 UI 没变化 | 更新了旧的 Activity 实例 | 对比控制台打印的 activity.id | 更新正确的活动实例,必要时统一管理 Activity |
| 更新后 UI 延迟或丢失 | 更新频率过高或被系统节流 | 检查业务侧是否每几百毫秒触发一次更新 | 降低更新频率,合并多次中间状态 |
| 多个活动同时存在时被挤掉 | 单 App 或系统同时活动数量限制 | 创建多个 Activity 观察切换 | 优先保留最关键的实时活动,及时结束不重要的活动 |
| 锁屏正常但灵动岛空白 | Widget 中没有实现 dynamicIsland 闭包 | 检查 ActivityConfiguration 的 dynamicIsland 部分代码 | 补充 compactLeading、compactTrailing、minimal 实现 |
| 推送更新失败 | pushToken 未上传、payload 字段不一致或 APNs 证书错误 | 检查服务端 APNs 返回值 | 按 APNs 文档修正 payload 和签名 |
| 模拟器无法完整验证 | 模拟器没有真实硬件形态和环境 | 换真机测试 | 所有最终验证必须在支持灵动岛的 iPhone 真机上执行 |
此外,许多在 SwiftUI 预览中正常显示的布局,放到真机后可能被截断。排查时先把文本数量、字体大小、自定义 padding 都降到系统默认状态,确认是不是自建布局超出安全区域导致的问题。
11. 最佳实践与使用建议
如果团队第一次接灵动岛,建议按这套流程来做:
- 先做锁屏实时活动,再做灵动岛。这样能把 ActivityKit 生命周期和 UI 渲染两件事拆开,减少一次调多个变量的排查成本。
- 一个 Activity 对应一个有明确 id 的业务实体,不要散落在任意 View 里。
- 创建、更新、结束三组方法统一封装成服务类,方便在业务层调用,也方便打日志。
- 所有 ContentState 字段需要设计成可 Codable 的稳定结构,因为推送更新和服务端共用同一套 JSON 字段。
- 在线状态变化时,把中间态压缩为最终态。比如配送过程里骑手位置连续变化了几十次,推到 Live Activity 上时只保留最近几次关键状态即可。
- 测试推送更新时,从 APNs 返回的失败信息开始排查,而不是反复在客户端里尝试。
- 上线前要检查深链接是否在冷启动、热启动、后台恢复三种状态下都能正确跳转。
- 涉及订单、地理位置、用户身份等数据时,确认推送内容里不包含不必要的敏感字段。即使推送内容需要展示地址信息,也最好在前端截取展示字段,不要把原始完整地址传给临时推送链路。
- 发布前留出真机回归时间,灵动岛最终效果依赖真实硬件,自动化测试很难完全覆盖。
灵动岛这项技术本身不复杂,难点在于和业务生命周期耦合。只要 Live Activity 的启动时机、状态更新频率、结束条件这三个点设计得足够清楚,开发过程就不会太痛苦。接下来的重点可以继续放在远程推送链路和业务稳定性上,先把一个高频场景跑通,再逐步扩展到更多任务类型。