Live Activities 是状态机,不是徽章
类型:已发布代码。本文记录了我为 Return 构建的 Live Activity——这是一款 SwiftUI 冥想计时器,我妻子在用,我妈妈在用,还有几千个陌生人也在用。1 文中的模式都是经受住生产环境考验的。残酷诚实的尾注列出了我目前还不知道的内容。
Return 的 Live Activity 在锁屏和灵动岛上看起来像一个倒计时数字。2 它不是一个数字。它是一个具有五种生命周期状态的机器,带有三条外部解除路径,以及一条必须自我防御的可重入启动路径。
我发布的 v1 版本把 Live Activity 当作徽章对待。”当前剩余时间”是数据;其余的都是装饰。那个版本中我在 TestFlight 里抓到了三个 bug,在生产环境抓到了一个:
- 在启动调用进行中再次点击启动按钮,会创建第二个 activity,孤立第一个。
- 倒计时在灵动岛上渲染正常,但锁屏视图对暂停的计时器命中
endTime <= Date(),在用户恢复之前一直显示0:00。 - 用户重置计时器后很久 Live Activity 依然可见,因为解除策略是
.default,Apple 会让它保留可见一段时间,最长可达四小时。 - (生产环境。)在从右到左语言区域设置(阿拉伯语、希伯来语)下,灵动岛紧凑尾部区域的数字反向渲染。拉丁数字配 RTL 布局。修复只需一行代码。
这每一个都是状态机 bug。倒计时数字本身没问题。数字不是产品。状态才是产品。
下面的状态机就是那些 bug 修复后留下的产物。
TL;DR
- 已发布的
LiveActivityManager暴露 5 个转换方法(startActivity、updateActivity、showCycleComplete、showFinalCompletion、endActivity),加上 1 个读取方法(hasActiveActivity)。这 224 行生产代码守护着startActivity内部的一个特定隐患:并发的启动调用,加上该方法中跨越每个await边界的取消检查。3 ContentState携带 6 个字段:endTime、currentCycle、totalCycles、isPaused、isCompleted、remainingSeconds。前五个是状态机的标签。第六个(remainingSeconds)是 ActivityKit 的实时timerInterval无法服务的静态显示回退。- 解除策略的决策才是真正的产品决断。用户重置用
.immediate,完成后用.after(Date().addingTimeInterval(3)),绝不用系统默认值。 - 灵动岛的紧凑尾部区域需要在计时器文本上加
.environment(\.layoutDirection, .leftToRight),在 RTL 系统区域设置下保持拉丁数字从左到右排列。
状态机
已发布的 Live Activity 有一个空闲状态、三个用户可观察的实时状态、一个终止状态,以及一个开发者必须留意的可重入门控:
┌──────────────────────────────────────────────────────────────────┐
│ Lifecycle states │
├──────────────────────────────────────────────────────────────────┤
│ IDLE currentActivity == nil; no Live Activity present │
│ RUNNING isPaused=false, endTime > Date() │
│ PAUSED isPaused=true, remainingSeconds=N │
│ CYCLE_END isPaused=false, endTime <= Date(), isCompleted=false│
│ COMPLETE isCompleted=true (terminal; transitions to IDLE) │
└──────────────────────────────────────────────────────────────────┘
│
↓
┌──────────────────────────────────────────────────────────────────┐
│ Dismissal policies (Apple) │
├──────────────────────────────────────────────────────────────────┤
│ .immediate user reset │
│ .after(now + 3s) completion display window │
│ .default system decides; can stay up to 4 hours │
└──────────────────────────────────────────────────────────────────┘
Reentrancy gate inside startActivity():
isStartingActivity flag + cancellable startActivityTask
prevents two concurrent startActivity() calls from creating
two Live Activities for one timer. Cancellation checks across
each await keep the in-flight task safe to abort.
渲染路径首先检查 isPaused;这种顺序确保当墙上时钟已越过 endTime 时,暂停的计时器不会被渲染为 CYCLE_END。7
状态名不是数字上的标签。状态名是 LiveActivityManager(应用端,我的 SwiftUI 视图所在之处)和 ReturnLiveActivity(widget 扩展,Apple 的进程在此渲染界面)之间的契约。
契约就是 TimerActivityAttributes.ContentState,全部 6 个字段:3
public struct ContentState: Codable, Hashable {
var endTime: Date
var currentCycle: Int
var totalCycles: Int?
var isPaused: Bool
var isCompleted: Bool = false
var remainingSeconds: Int = 0
}
每次状态转换都会变更这个结构体,并请求 ActivityKit 跨进程边界将其传递到 widget 扩展。然后 widget 重新渲染。没有共享内存。没有回调。只有一个 Codable 结构体在每次转换时跨越进程边界。
这一事实排除了我可能想用闭包、视图模型、可观察对象或计算属性所做的任何事。状态必须可表达为可序列化的数据。如果它无法被编码,它就无法转换。
可重入的启动
Live Activities 对并发活动数有硬性限制,对在调用 Activity.request 进行中再次调用会发生什么有软性限制。硬性限制有详尽的文档。4 软性限制是”第二次调用可能成功并创建一个孤儿”。所谓孤儿就是不再与你的管理器中 currentActivity 关联的 Live Activity。它存活着。它没有路径返回到你的代码。它最终会通过自己的过期计时器解除。在那之前,用户会看到一个重复的计时器。
孤儿就是 Return 在 v1 中发布的那个 bug。修复方案是在 LiveActivityManager.swift 中加入可重入门控加上一个可取消的 Task:3
private var isStartingActivity = false
private var startActivityTask: Task<Void, Never>?
func startActivity(...) {
#if os(iOS)
guard ActivityAuthorizationInfo().areActivitiesEnabled else { return }
guard !isStartingActivity else { return }
isStartingActivity = true
startActivityTask?.cancel()
startActivityTask = Task {
defer {
isStartingActivity = false
startActivityTask = nil
}
guard !Task.isCancelled else { return }
await endActivity() // explicit cleanup of any prior state
guard !Task.isCancelled else { return }
// ... build attributes + contentState ...
do {
let activity = try Activity.request(...)
guard !Task.isCancelled else { return }
currentActivity = activity
} catch {
// log; flag clears via defer
}
}
#endif
}
关于这种模式有三件事是文档没有指出的:
isStartingActivity 标志是主动保护;startActivityTask?.cancel() 是防御性清理。当第一次调用进行中时,该标志会让任何第二次 startActivity 调用短路返回,所以你实际上不会在公共路径上发生竞争。取消再替换的操作仍然重要,因为进行中的 Task 是异步的,可能比短命的调用方活得更久;取消可防止过期的 Task 在调用方已经离开后仍然继续运行。
跨越每个 await 边界的 guard !Task.isCancelled 检查。Swift 中的取消是协作式的。即使调用了 cancel,Task 也会一直运行,直到它显式检查为止。每个 await 都是一次检查机会。没有这些 await 后的检查,被取消的 Task 仍会继续构建活动状态,调用 Activity.request,并在成功时悄无声息地创建一个孤儿。
defer 在 Task 主体完成前清除标志。没有 defer 的话,提前 return(来自取消检查)会让 isStartingActivity = true 永久保留,活动直到应用重新启动才能再次开始。该标志是一个锁;锁必须在每条退出路径上释放。
pushType: nil 参数。Return 不使用 APNs 推送的 Live Activity 更新。应用通过 activity.update 在本地更新活动。如果你需要推送驱动的更新(配送跟踪、体育比分、实时数据),类型则是 pushType: .token,契约也会大幅更复杂。5 本地更新更简单,并且能覆盖任何计时器/计数器/单一应用工作流。
暂停问题
ActivityKit 提供了一个漂亮的 Text(timerInterval: Date()...endTime, countsDown: true) 视图,无需应用更新即可渲染实时倒计时。6 你设置结束时间,系统就渲染一个实时计时器。无需 Timer.publish,无需 widget 刷新,无需电池消耗。
计时器运行时这非常棒。但计时器暂停时它就错了。
无论状态中有什么”暂停”信号,timerInterval 文本都会朝 endTime 倒数。Apple 的 API 中没有”冻结在 10:23”模式。如果你传入 endTime = Date().addingTimeInterval(623),用户在 10:23 处暂停,widget 中的计时器文本仍会继续倒数到零。状态字段说已暂停。widget 渲染为运行中。
修复是从相同的状态渲染两个不同的视图:7
if context.state.isPaused {
// static text
Text(formatTime(context.state.remainingSeconds))
.monospacedDigit()
} else if context.state.endTime > Date() {
// live countdown
Text(timerInterval: Date()...context.state.endTime, countsDown: true)
.monospacedDigit()
} else {
// post-end static
Text("0:00")
.monospacedDigit()
}
双轨渲染就是 ContentState 把 remainingSeconds 作为单独字段携带的原因。计时器运行时它是冗余的(系统从 endTime 计算它)。计时器暂停时它是唯一的真理来源。结构体的两半服务于两种不同的渲染模式;isPaused 布尔值在两者之间选择。
解除策略
activity.end(_:dismissalPolicy:) 接受三个 ActivityUIDismissalPolicy 值之一,选错了就是让我的 v1 在用户重置后于锁屏上停留得仿佛永恒的元凶:13
| 策略 | 何时使用 | 你会得到什么 |
|---|---|---|
.immediate |
用户重置、错误、应用切到后台且无活动可追踪 | 活动立即消失。没有缓冲窗口 |
.after(date) |
完成显示:”您的冥想已完成”需要可读片刻。日期必须在 Apple 允许的四小时窗口内 | 活动显示最终状态,然后在 date 时解除 |
.default |
当你确实想让 Apple 的启发式逻辑来决定时 | 系统让其保持可见”一段时间”(Apple 的措辞),调用 end 后最长四小时 |
Return 在自然完成路径上使用 .after(Date().addingTimeInterval(3)):3
await activity.end(
.init(state: contentState, staleDate: nil),
dismissalPolicy: .after(Date().addingTimeInterval(3))
)
三秒是用户瞥一眼锁屏、注意到计时器结束、感受到对勾带来的满足感所需的时间。少于三秒太突兀。多于三秒就让人觉得活动不知道自己已经结束了。
对于用户触发的重置,调用是 dismissalPolicy: .immediate。没有窗口。用户已经知道了。
v1 中的错误选择是 .default。对于一个已完成的冥想计时器,系统让活动可见的时间长到用户以为应用根本没注册到完成。Apple 的文档说 .default 让已结束的活动”保持可见一段时间”,最长四小时;13 对计时器来说,正确的姿态是显式地解除。
灵动岛紧凑区域
灵动岛有三种渲染模式,即使是简单的计时器也三种全都需要:2
- 紧凑(默认灵动岛形状):前导图标 + 尾部计时器
- 最小(当另一个 Live Activity 与之竞争同一个灵动岛时):仅前导图标
- 展开(长按):四个命名区域(
leading、trailing、center、bottom)
在 Return 中赢得一席之地的模式是让展开视图与紧凑视图几乎一致:8
DynamicIsland {
DynamicIslandExpandedRegion(.leading) {
Image("AppIconSmall")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 16, height: 16)
.clipShape(RoundedRectangle(cornerRadius: 4))
}
DynamicIslandExpandedRegion(.trailing) {
TimerText(...)
}
DynamicIslandExpandedRegion(.center) { EmptyView() }
DynamicIslandExpandedRegion(.bottom) { EmptyView() }
} compactLeading: {
Image("AppIconSmall")...
} compactTrailing: {
TimerText(...)
} minimal: {
Image("AppIconSmall")...
}
大多数 Live Activity 教程都把展开视图视作”真正的”设计,在 bottom 区域填入丰富内容。对于冥想计时器来说,展开就是死重。用户通过长按打开展开视图,长按本身已经给了他们触觉反馈,告诉他们发生了什么。添加内容会让展开说出用户没问的话。展开模式下的空区域不是设计的失败;它就是设计本身。
RTL bug
那个生产环境的 bug。iOS 上的阿拉伯语和希伯来语用户报告说灵动岛的紧凑尾部计时器把数字反向渲染了。拉丁数字字符串 5:23 被渲染为 32:5,因为紧凑尾部布局方向继承了系统区域设置的 RTL 设置。
SwiftUI 在 widget 进程内继承系统布局方向,所以当用户的手机设为阿拉伯语或希伯来语时,灵动岛计时器文本会接受 RTL。即使在 RTL UI 中,拉丁数字也应当从左到右渲染。修复是在数字文本视图上固定布局方向:7
.environment(\.layoutDirection, .leftToRight)
该覆盖加在 TimerText(灵动岛紧凑/展开)和锁屏视图内的数字 Text 视图上,而不是整个视图。无论用户的系统区域设置如何,拉丁数字都从左向右读;像”Cycle 2 of 3”这样的循环标签则保持本地化,从而遵循系统布局方向。
这个 bug 在国内区域设置的 TestFlight 中不会浮现。它在真实的 RTL 用户打开计时器的那一刻才浮现。教训是:在任何可能在 RTL 区域设置下运行的 Live Activity 中,把 LTR 锁定的环境覆盖加到每一个拉丁数字文本视图上。
本地化故事
TimerActivityAttributes 携带一个 languageCode: String 字段,由应用在创建活动时设置:9
let attributes = TimerActivityAttributes(
timerDuration: duration,
languageCode: settings.appLanguage // app's selected language, not system's
)
widget 扩展读取它来渲染本地化字符串:
private var locale: Locale {
let code = context.attributes.languageCode
return code.isEmpty ? .current : Locale(identifier: code)
}
private func localized(_ key: String.LocalizationValue) -> String {
String(localized: key, locale: locale)
}
为什么应用要传入自己的语言代码而不是让 widget 读取 Locale.current:widget 扩展运行在自己的进程中。它的 Locale.current 是系统区域设置,而不是应用所选的区域设置。如果用户把 Return 设为韩语而 iPhone 是英语,没有这个覆盖的话 widget 会说英语。应用的语言偏好通过活动属性传递;widget 尊重它。
Localizable.xcstrings 与应用的并存于 widget 目标中,但它们是不同的文件。即使相同字符串存在于 Return/Localizable.xcstrings,widget 中使用的字符串也必须存在于 ReturnWidgets/Localizable.xcstrings。忘记这一点意味着 widget 会回退到开发语言,而应用却在说韩语。
我会有哪些不同的做法
让 ContentState 更小。六个字段太多了。endTime 与 remainingSeconds 之间的冗余是为了绕过 timerInterval 中无暂停模式所付出的代价。如果重新开始,我会携带一个 displayMode 枚举(running、paused(remainingSeconds: Int)、cycleEnd、complete),让渲染代码按案例分发。让六个字段在五个转换方法中保持正确变更比让四个案例保持正确难得多。
添加交互式 Live Activity 按钮(iOS 17+)。Return 目前没有在灵动岛中暴露暂停/恢复控件。用户必须打开应用才能暂停。iOS 17 为 Live Activities 内的 App Intents 添加了 Button(intent:)。10 交互式暂停控件是显而易见的扩展,也是我接下来要为 Return 发布的下一件事。
用于跨设备计时器同步的推送更新 Live Activities。Return 通过 NSUbiquitousKeyValueStore 在 iPhone、iPad、Watch 和 Apple TV 之间同步会话(详见 Five Apple Platforms, Three Shared Files)。今天,活动是从 iPhone 或 iPad 应用本地启动并在本地更新的。在 Apple Watch 上启动计时器的用户,理想情况下应当能在 iPhone 上实时看到 Live Activity 反映这一点。APNs 推送到 Live Activity 是路径。5 还没有构建。
何时不要使用 Live Activities
一次性瞬态。一个”已保存!”的 toast 不值得用 Live Activity。系统有横幅。用它就行。
没有时间维度的频繁变化数据。Live Activities 最适合具有清晰时间锚点的事物(计时器、配送预计到达时间、比赛时钟、通话时长)。股票行情和体育比分能用是因为它们有会话窗口。通用仪表板则不行。
没有锁屏/待机使用场景的应用。Live Activities 需要真正的工程投入(目标设置、ContentState 设计、解除策略决策、RTL 处理、本地化管线)。用户在使用过程中从不查看锁屏而直接打开的应用就不是合适的形态。照片编辑器不需要。健身追踪器需要。
在非 iOS 表面上,但有附加条件。Return 的 LiveActivityManager 把它的实现放在 #if os(iOS) 之后发布,因为计时器是从 iPhone 或 iPad 应用启动的。ActivityKit 本身把锁屏横幅、灵动岛、Apple Watch 智能叠放、Mac 和 CarPlay 都描述为呈现表面;iOS 26 扩展了其中几项。4 watchOS 仍然有自己的 complications API 用于全屏渲染。macOS 有菜单栏应用。iPadOS 自 iPadOS 17 起支持 Live Activities,但没有灵动岛区域。Return 的管理器在一个 224 行的文件中有 8 个 #if os(iOS) 守卫。
这个模式对在 iOS 26+ 上发布的应用意味着什么
两点要点。
-
把 Live Activity 当作状态机,而不是一个数字。状态机有清晰的状态、清晰的转换和清晰的解除规则。屏幕上的数字是某个状态的某种渲染。先把状态搞对。
-
可重入守卫就是你还没遇到的那个 bug。我在外界见过的每一个不实现
isStartingActivity+ 可取消 Task 的 Live Activity 管理器,至少都发布过一个孤儿活动 bug。守卫只有 6 行。写一次。
把本文与我为同一系列应用写过的早期作品一起阅读:用于 Apple Intelligence 的类型化 App Intents;用于跨 LLM 代理的 MCP 服务器;用于视觉层的 Liquid Glass 模式;用于跨设备覆盖的多平台发布。Live Activities 是同一栈中的 iOS 锁屏和灵动岛层。完整集合位于 Apple Ecosystem Series 中心。关于更广泛的 iOS 与 AI 代理的上下文,参见 iOS Agent Development guide。
常见问题
Live Activities 与 WidgetKit widgets 有什么区别?
WidgetKit widgets 在由 TimelineProvider 定义的间隔渲染;系统决定何时刷新,widget 从静态时间线重新渲染。11 Live Activities 响应特定的应用驱动 activity.update(...) 调用进行渲染,并在底层活动(计时器、配送、健身)的持续时间内存活。两者都在 widget 扩展目标中发布;区别在于触发模型。
Live Activities 在 iPad 上能用吗?
可以,在 iPadOS 17+ 中。锁屏横幅是主要渲染表面;iPad 没有灵动岛。相同的 ActivityConfiguration 代码可以用;只需预期灵动岛区域在 iPad 上永远不会渲染。
Live Activity 能在我的应用进程之外存活吗?
可以。一旦 Activity.request 成功,ActivityKit 就拥有该活动。应用进程可以被系统终止;活动会继续在锁屏和灵动岛上渲染,直到你显式结束它(或系统过期规则解除它)。显式的 endActivity() 调用因此变得重要;如果在应用重置时没有显式结束,活动会比计时器活得更久。
为什么本文不涉及推送更新的 Live Activities?
我还没有在 Return 中发布推送更新的 Live Activities。按照本系列的类型规则:已发布代码类的文章只记录生产代码所做的事。推送更新列在”我会有哪些不同的做法”中;未来发布之后,我会写一篇文章专门介绍。
SwiftUI 应用中 Live Activities 的实际文件布局是什么样的?
- 在主应用目标中:
LiveActivityManager.swift(管理活动生命周期)、TimerActivityAttributes.swift(与 widget 共享的ActivityAttributes结构体;两个目标都编译此文件)。 - 在 widget 扩展目标中:
ReturnLiveActivity.swift(带ActivityConfiguration主体的Widget一致性)、ReturnWidgetsBundle.swift(@main WidgetBundle)。 - 配置:应用目标中带
NSSupportsLiveActivities = YES的Info.plist。
widget 扩展目标需要 ActivityKit 和 WidgetKit 导入。TimerActivityAttributes 是两个目标之间唯一共享的文件;其他一切都按目标隔离。
Live Activity 不是锁屏上的一个数字。它是每次转换都跨越进程边界的状态机。把状态搞对,守卫可重入性,刻意选择解除策略,并固定布局方向。数字会照顾自己。
参考资料
-
作者的 Return,一款 SwiftUI 冥想计时器,于 2026 年 4 月 21 日在 App Store 发布,可在 iPhone、iPad、Mac、Apple Watch 和 Apple TV 上使用。Live Activities 仅在 iOS 目标上发布。 ↩
-
Apple Developer,“ActivityKit framework”。锁屏横幅、灵动岛紧凑/最小/展开模式、活动生命周期。iOS 16.1+ 可用;灵动岛在 iPhone 14 Pro 及更高版本上可用。 ↩↩
-
生产代码位于
Return/Return/LiveActivityManager.swift(224 行,8 个#if os(iOS)块)和Return/Return/TimerActivityAttributes.swift(43 行)。通过目标成员资格在应用目标和 widget 扩展目标之间共享。 ↩↩↩↩↩ -
Apple Developer,“Displaying live data with Live Activities”。并发限制、支持的平台(iOS 16.1+、iPadOS 17+)、
NSSupportsLiveActivitiesInfo.plist 键。 ↩↩ -
Apple Developer,“Updating and ending your Live Activity with ActivityKit push notifications”。
pushType: .token路径需要单独的 APNs 鉴权密钥、服务器端推送令牌注册,以及与本地activity.update(...)调用不同的更新协议。 ↩↩ -
Apple Developer,“Text(timerInterval:pauseTime:countsDown:showsHours:)”。实时系统渲染的倒计时计时器;活动运行期间无需应用更新即可渲染。 ↩
-
生产代码位于
Return/ReturnWidgets/ReturnLiveActivity.swift(232 行)。widget 扩展的Widget一致性,带有ActivityConfiguration<TimerActivityAttributes>主体。第 61-102 行的TimerText视图处理暂停/运行/结束后三态渲染。 ↩↩↩↩ -
Apple Developer,“DynamicIsland”。四个命名展开区域(
leading、trailing、center、bottom)加上三个紧凑模式视图(compactLeading、compactTrailing、minimal)。 ↩ -
widget 扩展运行在自己的进程中并继承系统区域设置,而不是应用所选的区域设置。支持应用内语言切换的应用(Return 支持 27 种语言)必须通过
ActivityAttributes传递语言代码,使 widget 能用用户选择的语言渲染。模式:Locale(identifier: context.attributes.languageCode)而非Locale.current。 ↩ -
Apple Developer,“Button(intent:)”。从 iOS 17+ 起在 widget 和 Live Activity 视图中可用。在不要求应用前台运行的情况下,将 App Intents 桥接到锁屏/灵动岛控件中。 ↩
-
Apple Developer,“TimelineProvider”。早于 Live Activities 的 widget 刷新模型;带有系统管理重新加载窗口的预计算条目。 ↩
-
生产代码位于
Return/ReturnWidgets/ReturnWidgetsBundle.swift(16 行)。@main WidgetBundle注册ReturnLiveActivity作为 widget 扩展的唯一 widget。这是 widget 扩展所必需的模式;bundle 是系统加载的内容。 ↩ -
Apple Developer,“ActivityUIDismissalPolicy”。三种情况:
.default、.immediate、.after(_:)。Apple 声明.default让结束的 Live Activity 保持可见”一段时间”,最长四小时,而.after(_:)接受同一四小时窗口内的日期。 ↩↩