ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

iOS灵动岛开发全解析:Live Activities与ActivityKit实战指南

iOS灵动岛开发全解析:Live Activities与ActivityKit实战指南 灵动岛Dynamic Island是 iOS 16.1 之后很多 App 开发者绕不开的系统交互概念。表面上它只是把 iPhone 14 Pro 系列顶部的药丸形挖孔区域变成了可扩展的状态展示区但从开发角度看它同时改变了通知系统、实时任务展示和锁屏信息的组织方式。本文以 iOS 开发者的视角拆解灵动岛它到底能做什么、不能做什么、通过哪套系统框架接入、如何用 ActivityKit 完成一个最小可运行示例以及上线前容易踩到哪些坑。适合已经掌握 SwiftUI、WidgetKit 并准备接入实时活动Live Activities的团队阅读。文章不会只停留在“把 ActivityAttributes 搭起来”的层面还会把灵动岛的展示边界、锁屏状态、远程推送更新、权限判断和调试方法放在一起讲。理解清楚这套机制后再决定你的 App 是否适合以实时活动形式常驻系统界面会更稳妥。1. 先建立概念灵动岛解决的是状态可见性问题1.1 灵动岛是多个系统能力叠加后的交互容器在讨论开发之前需要把灵动岛从“新外观”还原成“新机制”。它不是一个 iOS 提供给第三方自由绘制的控件而是挖孔硬件区域、系统状态栏、通知中心、实时活动、手势交互等多层能力叠加后的统一容器。从硬件来看灵动岛对应的物理区域是 Tycoon? 不iPhone 14 Pro 系列的实体组件包括前置摄像头、Face ID 点阵投影器、红外镜头等。这些硬件在屏幕顶部形成了无法正常显示内容的挖孔区域。苹果没有把这块区域当作缺陷去隐藏而是把它纳入 UI 规划在它周围渲染可动态变化的黑边效果使药丸形状在视觉上可以扩大、缩小、拆分。屏幕像素在硬件区域之外仍然由系统控制App 的自定义视图不能直接覆盖到传感器开口内部。从信息架构来看灵动岛要解决的是三个原始问题持续发生的任务不能一直占用整个 App 页面比如导航、通话、录音、播放音乐。这些任务又不能完全退到后台因为用户仍然需要快速知晓状态。通知弹窗会打断当前操作但很多状态变化只需要轻量提醒不需要完整弹窗。动态岛把这些时间敏感、需要跨页面保持可见的任务压缩在屏幕顶部。用户不需要主动打开 App也能在切换页面、回到桌面甚至锁屏后看到状态。对用户来说这是“可见性”的提升对开发者来说这是系统在锁屏和状态栏区域开放出来的新容器。1.2 用户交互形态决定可承载的任务类型灵动岛上的交互不必进入整个页面。在紧凑形态下用户看到的是左右两段信息或一组图标。长按或点按后它可以展开成更大的卡片展示更多文字、进度或操作按钮。系统还允许它在音频播放、通话等场景中变成更宽的状态区域也可以在连续任务出现时按优先级合并展示。这意味着 App 开发者不能像设计普通页面那样设计灵动岛内容。设计时首先要想清楚这个任务在后台或者锁屏场景下是否仍然值得展示它需要用户立即处理吗它有没有明确的开始和结束时间如果任务只是“当前页面状态的一种装饰”不适合放进灵动岛。适合展示的典型任务包括外卖配送进度从商家接单、骑手取餐到送达。打车行程状态包括司机接单、等待上车、行程中。体育赛事比分从开赛、中场到终场。运动记录包括跑步时长、配速、心率区间。计时器比如专注倒计时、煎蛋计时。航班动态、登机口变更。这类任务有共同点系统页面离开后用户仍然需要关注它更新是低频但有明确意义的任务有生命周期最终会结束。与之相反聊天输入框要不要显示、某个 Feed 流里的卡片标题、用户不主动开启的内部状态都不适合放进灵动岛。这些内容要么没有持续价值要么会过度占用系统关键区域。1.3 App 开发者能控制的只是“实时活动”不是整块屏幕很多刚接触灵动岛的同学会问能不能像画普通 View 一样在挖孔周围写自定义 UI答案是不能。第三方 App 的无障碍入口是“实时活动Live Activities WidgetKit”而不是一块自由的 canvas。通过实时活动 API开发者可以在灵动岛上展示紧凑内容和展开内容。在锁屏界面展示一个常驻小组件区域。在活动进行期间多次更新内容。通过本地更新或远程推送通知推动内容变化。在活动结束时移除灵动岛或锁屏区域内容。不能做的包括在灵动岛区域自由放置按钮、文本框和任意可点击区域。让 App 在没有用户授权的情况下长时间占用灵动岛。绕过系统的资源调度一直在后台刷新内容。借用灵动岛展示营销信息或广告。也就是说第三方接入的核心介质是“一个被系统管理的结构化任务”不是一张临时截图也不是一块共享小画板。系统的授权、生命周期、展示位置都受框架约束这既是限制也是体验一致性的保障。2. 开发前要理清边界Live Activities 与 WidgetKit 的关系2.1 实时活动的抽象模型实时活动在数据模型上可以拆成两部分静态属性和动态状态。静态属性描述的是“这个活动本身是谁”。比如一份外卖订单订单号、商家名称、商品列表可以算静态属性。它们通常不变化或者变化频率很低。动态状态描述的是“当前阶段到哪一步了”。例如配送进度中的“已下单”“商家已接单”“骑手取货中”“配送中”“已送达”。即使在同一个配送订单里动态状态会多次变化实时活动 UI 需要依据它重绘。Apple 官方定义分别叫 ActivityAttributes 和 ActivityAttributes.ContentState。ActivityAttributes 是创建活动时传入的固定信息ContentState 是随更新变化的状态。两者都必须满足 Codable 和 Hashable因为系统需要把活动数据持久化并且在后台处理更新和恢复场景。可以用一个外卖订单作为例子import ActivityKit struct DeliveryAttributes: ActivityAttributes { public struct ContentState: Codable, Hashable { // 当前配送阶段例如 0 已下单, 1 商家制作, // 2 骑手取货, 3 配送中, 4 已送达 var stage: Int // 预计送达时间 var estimatedArrival: Date // 进度 0.0 ... 1.0 var progress: Double } // 归属的订单标识 var orderNumber: String // 商家名称 var merchantName: String }这里DeliveryAttributes是活动类型ContentState表示某一时刻的进度。ActivityAttributes 本身也遵循 Codable 和 Hashable。系统通过 orderNumber 这样的属性区分是哪个活动不同的 ContentState 决定锁屏和灵动岛区域要显示什么内容。实时活动的核心特性是它并不专属于锁屏或灵动岛它可以在 App 运行前后台、锁屏、灵动岛多个场景下存在并由系统决定使用哪种布局。2.2 ActivityKit 是关键但 Widget Extension 决定入口第三方 App 接入灵动岛通常会遇到两套 API 名称ActivityKit 和 WidgetKit。ActivityKit 是在 iOS 16.1 引入的框架用于创建、更新和结束实时活动。开发者使用它控制活动的生命周期。例如 App 内的下单流程走完后可以用 ActivityKit 请求系统展示外卖配送状态。WidgetKit 则负责呈现 UI。实时活动的视图需要放在 Widget Extension 里通过 WidgetConfiguration 和 ActivityConfiguration 描述。SwiftUI 视图会同时出现在灵动岛、锁屏或待机 StandBy 等场景。简单理解ActivityKit 是控制层WidgetKit 是渲染层。如果要完整接入实时活动至少需要两部分工程App Target负责在合适时机调用 ActivityKit 创建活动。Widget Extension Target负责定义 Widget并在其中声明支持 ActivityConfiguration 的实时活动视图。在 Xcode 中创建一个 Widget Extension 后默认文件结构如下MyWidgetExtension/ MyWidget.swift MyWidgetBundle.swift DeliveryWidget.swift Info.plistMyWidgetBundle会把普通 Widget 和 Live Activity 的 ActivityConfiguration 注册在一起main struct MyWidgetBundle: WidgetBundle { var body: some Widget { DeliveryLiveActivity() // 如果还有普通 Widget可以继续写在这里 } }如果项目只需要 Live Activities也需要建立 Widget Extension Target。因为 Live Activities 的渲染环境依赖 WidgetKit 的运行容器。系统不会把 App Target 里的普通 SwiftUI View 直接搬到灵动岛上去渲染。创建 Target 时需要关注“Supports Live Activities”能力是否开启。在 Info.plist 中通常用NSSupportsLiveActivities控制整个 App 是否允许实时活动NSSupportsLiveActivitiesFrequentUpdates控制是否支持频繁更新。频繁更新涉及系统调度和电量消耗不能随意开启只有导航、运动等高频刷新场景才适合。2.3 系统层面的活动显示策略一个实时活动创建之后系统会判断合适的展示位置。不是所有活动都会出现在灵动岛。如果 iPhone 处于锁屏状态它优先出现在锁屏小组件区域如果用户正在使用手机并且设备支持灵动岛它才会出现在灵动岛区域。部分设备或系统版本没有灵动岛实时活动只是锁屏区域内容不会报错。所以开发时不能假设“创建了实时活动就一定会出现在灵动岛”。系统展示策略会受以下因素影响设备机型是否支持灵动岛。iOS 版本是否支持 Live Activities。用户是否在系统设置中关闭了某个 App 的实时活动权限。同一时间已存在的实时活动数量。用户是否处于专注模式或低电量模式。App 是否在 Info.plist 中声明支持实时活动。为了避免混淆开发调试时要区分“实时活动创建成功”和“已显示在灵动岛”。前者可以通过 ActivityKit 的返回结果确认后者需要看系统是否进入灵动岛展示状态。常见验证工具是直接启动 App 后切回桌面再观察屏幕顶部或者在锁屏状态查看实时活动区域。2.4 实时活动不等于后台任务这是新手最容易踩的概念坑。Live Activities 不是为了绕过 iOS 后台限制而设计。App 一旦进入后台系统不会因为你创建了实时活动就持续给 App 分配 CPU 资源。活动本身是“状态的可视化”不是“App 在后台运行的证据”。更新活动依靠两种方式本地更新App 在前台或短期后台运行期间通过 ActivityKit 的update方法更新内容状态。远程推送更新服务端向 Apple Push Notification serviceAPNs发送带有实时活动标识的推送拉起系统更新活动 UI。此时 App 不必在前台但服务端需要合法地获得支持实时活动的推送 token。生产环境通常使用推送更新因为后台活动需要体现“服务端知道的最新进度”。比如说外卖骑手到了哪个路口客户端没法自行预测只能等服务端推送。服务端推送也不要求 App 进程始终活着系统负责把推送转换成新的 ContentState 并刷新 UI。这跟 App 使用 push 通知做弹窗提醒不同不能用普通通知替换 Live Activity 推送需要走专门的推送 payload 结构并且注意这个 Live Activity 会话是否还合法。3. 最小接入创建、更新、结束灵动岛实时活动3.1 创建实时活动的过程下面基于前面定义的外卖 DeliveryAttributes 写一个最小示例。实际业务中活动创建通常发生在用户下单成功后。先获取对应 iOS 版本支持然后调用 ActivityKit API。在 iOS 16.1 及以上ActivityKit 的入口是Activity类型泛型参数是 ActivityAttributes 的子类结构。以下是创建活动的 Swift 代码import ActivityKit func startDeliveryActivity(orderNumber: String, merchantName: String) { guard ActivityAuthorizationInfo().areActivitiesEnabled else { print(当前设备或系统不允许实时活动) return } let initialState DeliveryAttributes.ContentState( stage: 0, estimatedArrival: Date().addingTimeInterval(60 * 30), progress: 0.1 ) let attributes DeliveryAttributes( orderNumber: orderNumber, merchantName: merchantName ) do { let activity try Activity.request( attributes: attributes, contentState: initialState, pushType: .token ) print(已创建实时活动activity.id \(activity.id)) } catch { print(创建失败\(error.localizedDescription)) } }这里有几个关键点。ActivityAuthorizationInfo().areActivitiesEnabled用来判断设备、用户设置是否允许实时活动。如果没有判断即使后续 request 失败也只能通过 do-catch 拿到错误。更合理的逻辑是先在入口处做一次权限检查并在用户设置页面引导用户开启。Activity.request(attributes:contentState:pushType:)的 pushType 参数很关键。如果不传 pushType表示活动只支持本地更新如果传.token系统会为该活动生成一个推送令牌服务端可以用它推送 ContentState。使用 push 更新时必须传入该值否则服务端无法找到可推送的实时活动 token。创建成功后返回值是ActivityDeliveryAttributes。它的id是活动实例的唯一标识需要在本地持久化。App 崩溃、重启或系统清理后如果要更新某个活动的状态需要能根据 id 找回对应的活动对象。推荐把 activity.id、订单号和其他业务标记一起存到 UserDefaults 或本地数据库中。3.2 更新 ContentState当外卖进度变化时比如骑手已经取到餐业务层会拿到一个 stage 3 的新状态然后调用活动更新func updateDeliveryActivity(activity: ActivityDeliveryAttributes, stage: Int, progress: Double) { let newState DeliveryAttributes.ContentState( stage: stage, estimatedArrival: Date().addingTimeInterval(60 * 15), progress: progress ) Task { await activity.update(using: newState) } }在并发环境下ActivityKit 的 update 是异步方法。如果 App 同时触发多个更新要等待上一次更新完成再发送下一次避免状态覆盖顺序错乱。另一个常见问题是如果 Activity 已经被系统结束或系统关闭了该活动再调用 update 会失败。更新前可以检查activity.activityState确认它仍处于 active 状态。远程推送更新时不走 App 内 update 方法。服务端向 APNs 发送 Live Activity 专用 payloadAPNs 唤起系统更新实时活动。App 在该过程中不一定需要启动。服务端需要存储每个实时活动的 pushToken并在活动创建时接收来自客户端上传的 token。3.3 结束活动与清理活动在业务完成后必须结束。如果不主动结束系统会根据你在配置中设定的时间自动结束或者等待用户手动移除但这会造成状态不准确。结束动作应发生在业务终点比如外卖已经送达、赛事已经完赛。func endDeliveryActivity(activity: ActivityDeliveryAttributes, finalState: DeliveryAttributes.ContentState) { Task { await activity.end( using: finalState, dismissalPolicy: .immediate ) } }dismissalPolicy决定活动结束后以什么方式从系统界面消失。常用值.immediate立即移除当前实时活动。.after(Date)延时到指定时间移除比如让用户多看一眼最终状态再自动清除。.default交给系统决定。结束活动后最好把本地缓存的 activity.id 同步删除。不要保留大量已经失效的活动 ID。服务端也要从 pushToken 表中把这个活动对应的记录移除避免后续推送落到已结束的活动上引发无意义的错误日志。3.4 在 Widget Extension 中配置 View创建完活动只是确保系统知道有“一件正在发生的事”还需要告诉系统怎么渲染。以一个 Widget 文件为例使用 ActivityConfiguration 把刚才的 DeliveryAttributes 映射到 SwiftUI 视图。常规 Widget 和 Live Activity 的展示配置有些差异在实现时要注意 ActivityConfiguration 是专门为实时活动提供的容器。真实代码结构类似下面这样为了聚焦关系实际项目需要把视图拆成更小的组件import WidgetKit import SwiftUI import ActivityKit struct DeliveryLiveActivity: Widget { var body: some WidgetConfiguration { ActivityConfiguration(for: DeliveryAttributes.self) { context in // 锁屏界面的实时活动区域 LockScreenDeliveryView(context: context) } dynamicIsland: { context in DynamicIsland { // 展开后的灵动岛内容 DynamicIslandExpandedDeliveryView(context: context) } compactLeading: { Text(外卖) } compactTrailing: { Text(\(context.state.progress * 100, specifier: %.0f)%) } minimal: { Image(systemName: takeoutbag.and.cup.and.straw) } } } }这里context是系统传入的上下文通过context.attributes可以读取静态属性通过context.state可以读取最新动态状态。需要注意的是Live Activity 的 SwiftUI 视图运行在受系统管理的进程里不要在这块视图内部发起网络请求、执行定时器或读取大量外部数据。它主要是一个“根据 state 渲染 UI”的纯展示层。真实数据应该通过 ContentState 从 App 或推送端传进来而不是在视图内部去拉取。上面的紧凑布局中compactLeading 对应灵动岛左侧的区域compactTrailing 对应右侧区域minimal 对应被系统简化成小圆点或图标时的展示。展开区域有更多空间可以展示比紧凑区域更多信息。实际代码里往往用 VStack、HStack、ProgressView 描述配送文案、商家名和进度条。4. 把外观做对三种形态的 SwiftUI 布局与自适应4.1 DynamicIsland 布局分类和展示限制接入灵动岛最常见的外观问题是不清楚每种布局的可视空间到底有多大。在 ActivityConfiguration 的 dynamicIsland 闭包里可以控制三种状态expanded灵动岛展开成一张大卡片后的内容区域。compactLeading紧凑状态下的左侧展示区域。compactTrailing紧凑状态下的右侧展示区域。minimal当多个实时活动同时出现且系统选择合并展示时用最小化图标展示的区域。不同布局的空间限制差异很大。compact 区域只能展示非常短的一两个信息块适合图标、短文本、进度数字expanded 区域有更大空间可以放两行或三行文字、进度条、小按钮但仍然不能像页面一样随意堆内容。minimal 区域空间最小一般只放一个 Image 或一个短 Label。由于灵动岛本身是在屏幕“挖孔”附近做视觉扩展设计时不要画一个超宽页面。内容上下要尽量紧凑最好根据系统真实宽度留出边界。尽量不要在两个紧凑区域放入太多文字避免出现截断和压缩。给compactLeading放“美团外卖”三个字时某些设备上可能被截断写Text(外卖)反而更稳妥。要测试真实空间建议在支持灵动岛的模拟器或真机上多设备运行。不要只看 Xcode 预览布局。灵动岛展开/收起动画是系统触发开发时无法直接以动画预览的形式看到全部状态最好手动操作验证。4.2 使用 SwiftUI 保证锁屏与灵动岛风格统一一个实时活动的 SwiftUI 视图会同时用于锁屏和灵动岛。锁屏空间比灵动岛 compact 区域大却不能直接套用灵动岛的 expanded 文案长度。设计时要把“内容状态”和“布局槽位”解耦同一个 ContentState 可以被多个视图读取但不同视图选取的信息可以不同。锁屏视图一般展示更完整的信息struct LockScreenDeliveryView: View { let context: ActivityViewContextDeliveryAttributes var body: some View { VStack(alignment: .leading, spacing: 8) { HStack { Text(context.attributes.merchantName) .font(.headline) Spacer() Text(预计 \(context.state.estimatedArrival, style: .relative)) .font(.caption) } ProgressView(value: context.state.progress) .progressViewStyle(.linear) HStack { Text(stageText(context.state.stage)) Spacer() Text(\(Int(context.state.progress * 100))%) } .font(.caption2) } .padding() } private func stageText(_ stage: Int) - String { switch stage { case 0: return 已下单 case 1: return 商家制作中 case 2: return 骑手取货 case 3: return 配送中 default: return 已送达 } } }在灵动岛紧凑区域锁屏里的 merchantName 和完整进度条都不适合直接塞进去更适合拆成左侧商家首字/图标右侧百分比。展开区域可以显示完整的配送阶段和预计时间。代码中把 stageText 作为视图内部函数没有问题但要注意 Live Activity 内容里的文案应当尽量短并且要处理好不同语言长度。系统在灵动岛中的显示区域固定语言拉长会带来适配成本。建议上线前做中英文和长文案的回归测试。4.3 处理低电量模式和辅助功能适配灵动岛区域的显示既要节省电量也要保证可读性。Live Activity 的 SwiftUI 视图可以使用Environment(\.isLuminanceReduced)判断系统是否处于低亮度模式。在低亮度模式下界面会降低视觉亮度变化以减少 OLED 屏幕功耗。如果开发者在内容里做高对比度颜色或者频繁闪动会违背系统在灵动岛的显示策略。建议在低亮度模式下使用系统提供的默认前景色减少自定义色彩和动画。类似写法Environment(\.isLuminanceReduced) private var isLuminanceReduced var body: some View { HStack(spacing: 4) { Image(systemName: takeoutbag.and.cup.and.straw) Text(配送中) } .tint(isLuminanceReduced ? nil : Color.orange) }这里的isLuminanceReduced是从 WidgetKit 环境继承来的一个标记。它只在系统处于低亮度状态时变为 true。若没有这个判断自定义颜色可能在用户驾驶模式、低电量模式下造成额外功耗。辅助功能方面实时活动内容需要支持 VoiceOver。系统会阅读视图中的文本、状态和按钮语义。不要把关键信息只画在 Image 上而不提供 accessibilityLabel。在灵动岛中一段文字Text(配送中)会被朗读但如果是一个纯装饰性图标应设置为accessibilityHidden(true)避免朗读无意义内容。注意Live Activity 的 UI 不适合做交互密集的控件。它的核心价值是“告诉用户正在发生什么”而不是让用户在灵动岛里完成完整业务流程。把点击后的操作放到展开卡片或跳转到 App 内更合理。4.4 主题色与 keylineTint 的边界灵动岛外观除了内容本身还有外部描边线条。通过.keylineTint(_:)可以给灵动岛的边线添加颜色但应谨慎使用。如果每个来源都使用高饱和颜色会让屏幕顶部显得非常杂乱。推荐默认使用系统强调色不额外设置 keylineTint。特定品牌色场景下可使用一种低饱和的 tint 强调状态比如运动记录中使用绿色表示目标完成骑行导航中使用蓝色表示进行中。不要在一个展开卡片里混合多种高亮色系统整体交互会失去层次。5. 验证、排错与生产化建议5.1 从现象倒推根因的排查链路实时活动接入过程中最常见的现象是“代码没有报错但灵动岛没有出现”。需要沿着链路逐层排查。下面是一张常用排查表问题现象可能原因检查方式处理建议request 报错无法创建活动用户关闭了 App 的实时活动权限或系统不支持检查ActivityAuthorizationInfo().areActivitiesEnabled在 App 设置页引导用户开启权限request 成功但没有显示在灵动岛当前设备非灵动岛机型或系统版本低于 iOS 16.1查看设备型号、iOS 版本以及锁屏区域真机调试时选择灵动岛设备没有灵动岛时只能看到锁屏创建成功但 UI 一直不刷新ContentState 没有正确更新或更新顺序错乱打印状态更新日志查看activity.activityState确认每次 update 都使用最新 ContentState本地更新正常远程推送不更新服务端没有拿到 pushToken 或 push payload 不合法检查 pushType 是否.token客户端是否上传 token按 Live Activities 推送 payload 格式发送活动被系统提前移除系统资源调度、用户手动移除或 dismissalPolicy 设置不当观察 activityState 变化和系统日志不依赖无限常驻结束前刷新最终状态展开卡片 UI 截断、重叠在 compact 或 expanded 中塞入了过多内容真机观察不同形态布局精简文案分别设计 compact/expanded 视图模拟器上灵动岛布局和真机不一致模拟器 Live Activities 支持不完全且有分辨率差异使用真机测试关键展示效果在 Xcode 模拟器中确认逻辑真机确认视觉调试时优先确认三件事当前设备是否真的支持灵动岛。iPhone 14 标准版、iPhone SE 等机型不支持锁屏区域仍然是主要出口。系统设置中是否允许当前 App 的实时活动。是否在 Widget Extension 的 Info.plist / Target 配置里启用了 Live Activities。这三项都确认后再看代码逻辑。常见做法是在业务层增加实时活动日志记录创建时的 activity.id、每次 ContentState 更新、结束原因和 dismissalPolicy。有了这些日志后续服务端排查推送问题会顺畅很多。5.2 学习环境、开发环境和生产环境的差异实时活动的接入在不同阶段有不一样的工作量。学习阶段建议先在 Widget Extension 里写好静态 UI把 ContentState 硬编码成几个固定值重点看不同形态的布局效果。可以先不接服务端推送只测试本地 update。这样可以快速建立“实时活动有哪几种状态、哪几种布局”的体感。等 UI 稳定后再引入服务端推送。开发环境应该做到创建、更新、结束活动都有完整链路。有统一的状态模型层ActivityAttributes 和 ContentState 只作为传输载体。使用 Debug 日志打印 activity.id 和 activityState。支持在 App 内部触发模拟事件方便测试相邻状态切换。测试环境要额外覆盖异常路径用户在创建活动过程中退出 App。用户手动移除实时活动后再尝试更新。多个实时活动同时存在时的合并展示。锁屏、待机、灵动岛三种区域的切换。系统语言变化后的文案长度。生产环境不能只把 Demo 搬过去。至少还要考虑以下问题服务端要持久化实时活动 token并与业务 ID 关联。服务端必须处理活动被结束后的推送失败不能再继续发送 payload。推送通道需要幂等和重试策略避免一次业务状态变化触发重复内容。要考虑用户主动关闭实时活动后的用户引导不要在 App 内反复弹窗要求开启。设置合理的活动时长上限避免活动无限期驻留。线上日志指标应包含创建成功数、更新失败数、活动被系统移除数、推送失败数。5.3 生产落地时的参数判断和开关接入实时活动前建议先想清楚四个生产问题。第一个问题是更新频率。如果你判断自己的业务状态每 10 秒就要刷新一次需要认真评估是否真的适合实时活动。导航、运动这类场景会有频繁更新需求系统为此提供了 Frequent Updates 相关能力但需要额外配置且会受到电量管理限制。对于外卖进度正常频率可能是一两分钟一次不需要开启高频更新。ActivityKit 创建活动时指定 pushType 后更新频率更多是服务端推送策略的决策而不是客户端随意调用 update。第二个问题是文案。灵动岛区域不适合长文案尤其是 compact 形态。建议所有出现在灵动岛的文本不超过 6 到 8 个中文字符必要时只保留数字和图标。在锁屏区域可以稍长但仍要保留关键动作结果。第三个问题是业务价值判断。对用户真正有价值的是“当前订单会不会准时到达”“还剩多长时间结束”。不要在灵动岛放折扣信息、活动倒计时、广告文案。用户会主动关闭这类无价值实时活动进而影响后续真实业务的展示机会。从长期看滥用实时活动是对用户注意力的消耗。第四个问题是授权变化的处理。用户可以随时在系统设置中关闭实时活动权限。App 下次启动时如果areActivitiesEnabled返回 false应主动检查之前创建的活动是否被系统移除并清理本地缓存。不要在下一个业务推进时继续对一个无效活动 update否则会产生大量无意义错误日志。比较稳妥的做法是把活动 ID 和权限变化绑定到业务状态中当权限被关闭时主动把业务状态切换成 App 内普通页面的展示。5.4 一个推荐的实时活动状态生命周期一个生产可用的实时活动生命周期建议按以下六个阶段管理前置判断检查系统版本、设备能力、用户权限、业务状态。创建活动向 ActivityKit 请求活动实例并持久化活动 ID。上报 token如果是远程推送场景把活动的 pushToken 上传到服务端。更新状态监听业务变化通过本地 update 或服务端推送更新 ContentState。结束活动业务到达终态时调用 end并设置合理的 dismissalPolicy。清理记录移除本地活动 ID更新服务端 token 表。其中第 3 步经常被忽略。如果 App 在创建活动后没有第一时间把 pushToken 上传到服务端后面的远程推送更新就无法工作。而 pushToken 的有效期与活动实例绑定不是普通的设备推送 token。活动结束后这个 token 也就失效了。为了减少开发复杂度建议所有活动相关代码集中在一个 Manager 内部不要散落在多个页面。下面是一个简化的 Manager 骨架final class DeliveryActivityManager { static let shared DeliveryActivityManager() private var currentActivity: ActivityDeliveryAttributes? func begin(orderNumber: String, merchantName: String) { // 1. 权限判断 // 2. 创建活动 // 3. 保存 activityID // 4. 上报 pushToken } func refresh(stage: Int, progress: Double) { // 1. 从缓存取活动 // 2. 检查 activityState // 3. 更新 ContentState } func finish(stage: Int) { // 1. 更新最终状态 // 2. end // 3. 清理本地缓存 } }这个骨架把 ActivityKit 的使用收敛到一个类中能减少 UI 层直接依赖系统框架的耦合。业务层只需要调用 begin、refresh、finish不需要关心 Activity 实例是 active 还是 ended。5.5 扩展方向把实时活动延伸到更多平台场景灵动岛只是实时活动的一种展示形态。iOS 16.1 之后的 iPhone 在锁屏、StandBy 待机界面也能展示 Live Activity。iPad 在更新系统版本后实时活动也会出现在锁屏区域。Mac 在部分版本中也支持 Live Activities。这种跨场景能力意味着如果你的 ActivityAttributes 设计得当、ContentState 更新策略合理同一套数据模型可以服务于多种设备形态。跨场景适配时建议在业务数据层就保持“状态机 展示模型”分离业务状态机管理配送阶段、比赛阶段等业务推进。展示模型决定当前状态下哪些信息最为关键。Widget 视图只消费展示模型不直接操作业务状态。例如配送 App 内部有订单模型它包含很多字段包括配送员电话、支付金额、优惠券信息等。但推送给实时活动网络或上传到服务端的 ContentState 不应包含全部隐私字段。服务端、客户端之间传输的 ContentState 只保留灵动岛展示所需的字段即可。这样既能降低流量也避免过度暴露订单内部信息。设计时尽量把订单模型和 ActivityAttributes 分开实时活动只是订单状态的一个对外投影。建议在服务端不要直接依赖客户端上传的 ContentState 作为唯一状态来源。服务端需要维护自己的业务状态机客户端上传 token、业务 ID 和对应状态后服务端决定何时下发正式活动更新。这样能防止客户端伪造阶段也能保证最终用户体验一致。结语先想清楚数据和生命周期再写 UI灵动岛给 iOS 开发者带来最核心的变化是把系统的“状态栏区域”变成了可组合的“时间敏感任务容器”。但真正决定一个实时活动体验好坏的不只是 SwiftUI 布局而是背后的数据模型和生命周期管理。先把 ActivityAttributes 和 ContentState 分别设计清楚再看 UI 在 compact、expanded、锁屏三种状态下的表现最后补上服务端推送链路是更稳妥的顺序。对于首次接触灵动岛的团队建议先做一个小范围试点挑一个用户真正会持续关注的业务字段实现从创建到结束的完整闭环再继续铺开到更多业务场景。这样既不会把灵动岛变成广告位也能在真实使用中验证这套机制是否适合你的产品。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进