Live Activities是状态机,不是徽章
Return中的Live Activity看起来像锁屏和灵动岛上的一个倒计时数字。12它不是一个数字。它是一个五生命周期状态机,具有三条外部退出路径和一条必须自我防御的可重入启动路径。下文的模式都是在生产环境中存活下来的。文末的残酷诚实部分会说明我尚未掌握的内容。
我发布的v1版本将Live Activity当作徽章对待。”当前剩余时间”是数据;其余都是装饰。那个版本有三个我在TestFlight中发现的bug,以及一个在生产环境中发现的bug:
- 在启动正在进行中时再次点击启动,会创建第二个activity并使第一个变成孤儿。
- 倒计时在灵动岛上正确渲染,但锁屏视图对暂停的计时器命中了
endTime <= Date(),在用户恢复之前一直显示0:00。 - Live Activity在用户重置计时器后仍长时间可见,因为退出策略是
.default,Apple会让其保持可见一段时间,最长达4小时。 - (生产环境。)在从右到左的语言区域(阿拉伯语、希伯来语)中,数字在灵动岛的紧凑尾部区域反向渲染。拉丁数字,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
}
每次状态转换都会变更这个struct,然后请求ActivityKit跨进程边界将其传递给widget扩展。widget随即重新渲染。没有共享内存。没有回调。只有一个Codable struct在每次转换时跨越进程边界。
这个事实排除了我可能想用闭包、视图模型、observable对象或计算属性做的任何事情。状态必须能够表达为可序列化的数据。如果它无法被编码,它就无法转换。
可重入的启动
Live Activities对并发activity数量有硬性限制,对在飞行中调用两次Activity.request的行为有软性限制。硬性限制有详尽的文档说明。4软性限制是”第二次调用可能成功,并创建一个孤儿”。这个孤儿就是不再与你的manager中的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在调用者已经离开后继续执行。
guard !Task.isCancelled检查跨越每个await边界。 Swift中的取消是协作式的。即使调用了cancel,Task也会继续运行,直到它显式检查为止。每个await都是一个检查机会。如果没有await后的检查,被取消的Task会继续构建activity状态,调用Activity.request,并在成功时悄悄创建一个孤儿。
defer在Task主体完成前清除标志。 没有defer的话,提前的return(来自取消检查)会让isStartingActivity = true永久保留,而activity直到应用重启都无法再次启动。该标志是一把锁;锁必须在每条退出路径上都被释放。
pushType: nil参数。 Return不使用APNs推送的Live Activity更新。应用通过activity.update在本地更新activity。如果你需要推送驱动的更新(配送追踪、体育比分、实时数据),类型应为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计算)。当计时器暂停时,它是唯一的真实来源。这个struct的两半服务于两种不同的渲染模式;isPaused布尔值在它们之间进行选择。
退出策略
activity.end(_:dismissalPolicy:)接受三个ActivityUIDismissalPolicy值之一,选错就是为什么我的v1版本在重置后会在用户的锁屏上停留一段感觉永恒的时间:13
| 策略 | 何时使用 | 你将得到什么 |
|---|---|---|
.immediate |
用户重置、出错、应用进入后台且没有要追踪的activity | activity立即消失。没有缓冲窗口 |
.after(date) |
完成显示:”您的冥想已完成”需要保留片刻可读。该日期必须在Apple允许的四小时窗口内 | activity显示最终状态,然后在date时退出 |
.default |
当你确实希望由Apple的启发式逻辑来决定时 | 系统让其保持可见”一段时间”(Apple的措辞),从end被调用后最长四小时 |
Return在自然完成路径上使用.after(Date().addingTimeInterval(3)):3
await activity.end(
.init(state: contentState, staleDate: nil),
dismissalPolicy: .after(Date().addingTimeInterval(3))
)
三秒钟是用户瞥一眼锁屏、意识到计时器结束并感受到对勾带来的满足感所需的时间。少于三秒会显得跳跃。多于三秒则让人感觉activity不知道自己已经完成。
对于用户触发的重置,调用为dismissalPolicy: .immediate。没有窗口。用户已经知道了。
v1版本的错误选择是.default。对于已完成的冥想计时器,系统让activity保持可见的时间太长,以至于用户认为应用根本没有注册到完成。Apple的文档说.default让已结束的activity”在一段时间内保持可见”,最长四小时;13对于计时器而言,正确的姿态是让退出明确化。
灵动岛紧凑区域
灵动岛有三种渲染模式,即便是简单的计时器,你也都需要这三种:2
- 紧凑(Compact)(默认的灵动岛形状):前导图标 + 尾部计时器
- 极简(Minimal)(当另一个Live Activity与同一灵动岛竞争时):仅前导图标
- 展开(Expanded)(长按):四个命名区域(
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字段,由应用在activity创建时设置: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将以英文显示。应用的语言偏好通过activity attributes传递;widget会遵从它。
Localizable.xcstrings与应用的一起存放在widget目标中,但它们是不同的文件。widget中使用的字符串必须存在于ReturnWidgets/Localizable.xcstrings中,即使同样的字符串也存在于Return/Localizable.xcstrings中。忘记这一点意味着widget会回退到开发语言,而应用却在说韩语。
我会做出的不同选择
让ContentState更小。 六个字段太多了。endTime和remainingSeconds之间的冗余是为绕过timerInterval中没有暂停模式而付出的代价。如果重新开始,我会携带一个displayMode枚举(running、paused(remainingSeconds: Int)、cycleEnd、complete),让渲染代码根据case进行分发。要在五个转换方法中保持六个字段的正确变更,比保持四个case难得多。
添加交互式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中有介绍)。今天,activity在iPhone或iPad应用中本地启动,在本地更新。理想情况下,在Apple Watch上启动计时器的用户应当能在iPhone上实时看到Live Activity反映这一点。APNs推送到Live Activity就是路径。5还没有构建。
何时不使用Live Activities
一次性的瞬时状态。 “已保存!”提示不值得使用Live Activity。系统有横幅通知。用它就好。
没有时间维度的频繁变化数据。 Live Activities最适合具有清晰时间锚点的事物(计时器、配送预计到达时间、比赛时钟、通话时长)。股票行情和体育比分能用是因为它们有会话窗口。通用仪表盘则不行。
没有锁屏/待机使用场景的应用。 Live Activities需要真正的工程投入(目标设置、ContentState设计、退出策略决策、RTL处理、本地化布线)。用户在使用过程中从不查看锁屏而直接打开的应用,形态不对。照片编辑器不需要。运动追踪器需要。
在非iOS界面上,有些注意事项。 Return的LiveActivityManager将其实现放在#if os(iOS)后面发布,因为计时器是从iPhone或iPad应用启动的。ActivityKit本身将锁屏横幅、灵动岛、Apple Watch智能堆栈、Mac和CarPlay描述为展示界面;iOS 26扩展了其中几个。4watchOS仍有自己的API复杂功能用于全屏渲染。macOS有菜单栏应用。iPadOS自iPadOS 17起支持Live Activities,但没有灵动岛区域。Return的manager在一个224行的文件中有8个#if os(iOS)守卫。
该模式对在iOS 26+上发布的应用意味着什么
两个要点。
-
将Live Activity视为状态机,而不是数字。 状态机有清晰的状态、清晰的转换和清晰的退出规则。屏幕上的数字是某个状态的一种渲染方式。先把状态搞对。
-
可重入守卫是你尚未踩到的bug。 我在野外见过的所有不实现
isStartingActivity+ 可取消Task的Live Activity manager,都至少发布了一个孤儿activity bug。守卫只有6行。写一次就好。
将本文与我为同一系列应用所写的早期文章配合阅读:用于Apple Intelligence的类型化App Intents;用于跨LLM代理的MCP服务器;用于视觉层的Liquid Glass模式;用于跨设备覆盖的多平台发布。Live Activities是同一技术栈中iOS锁屏与灵动岛层。完整集合位于Apple生态系列中心。关于更广泛的iOS与AI代理上下文,请参阅iOS Agent Development指南。
FAQ
Live Activities与WidgetKit widgets有什么区别?
WidgetKit widgets按TimelineProvider定义的间隔渲染;系统决定何时刷新,widget从静态时间线重新渲染。11 Live Activities在响应应用驱动的特定activity.update(...)调用时渲染,并在底层activity(计时器、配送、训练)的持续时间内存活。两者都在widget扩展目标中发布;区别在于触发模型。
Live Activities在iPad上能用吗?
可以,iPadOS 17+。锁屏横幅是主要渲染界面;iPad没有灵动岛。同样的ActivityConfiguration代码可以工作;只需预期灵动岛区域永远不会在iPad上渲染。
Live Activity能比我的应用进程活得更久吗?
可以。一旦Activity.request成功,ActivityKit就拥有了该activity。应用进程可以被系统终止;activity会继续在锁屏和灵动岛上渲染,直到你显式结束它(或直到系统的过期规则将其退出)。显式的endActivity()调用因此非常重要;在应用重置时若没有显式结束,activity会比计时器活得更久。
为什么这篇文章没有涵盖推送更新的Live Activities?
我没有在Return中发布过推送更新的Live Activities。按照本系列的体裁规则:已发布代码的文章只记录生产代码所做的事情。推送更新列在”我会做出的不同选择”中;未来的文章会在我发布后再涵盖。
SwiftUI应用中Live Activities的实际文件布局是什么样的?
- 在主应用目标中:
LiveActivityManager.swift(管理activity生命周期)、TimerActivityAttributes.swift(与widget共享的ActivityAttributes结构;两个目标都编译这个文件)。 - 在widget扩展目标中:
ReturnLiveActivity.swift(带有ActivityConfiguration主体的Widget一致性)、ReturnWidgetsBundle.swift(@main WidgetBundle)。 - 配置:应用目标中
Info.plist中的NSSupportsLiveActivities = YES。
widget扩展目标需要ActivityKit和WidgetKit的导入。TimerActivityAttributes是唯一在两个目标之间共享的文件;其他所有内容都按目标隔离。
Live Activity不是锁屏上的一个数字。它是一个每次转换都跨越进程边界的状态机。把状态搞对,守住可重入,有目的地选择退出策略,锁定布局方向。数字会自己照顾好自己。
References
-
Author’s Return, a SwiftUI meditation timer published on the App Store on April 21, 2026, available for iPhone, iPad, Mac, Apple Watch, and Apple TV. Live Activities ship on the iOS target only. ↩
-
Apple Developer, “ActivityKit framework”. Lock Screen banner, Dynamic Island compact / minimal / expanded modes, activity lifecycle. Available iOS 16.1+; Dynamic Island available iPhone 14 Pro and later. ↩↩
-
Production code in
Return/Return/LiveActivityManager.swift(224 lines, 8#if os(iOS)blocks) andReturn/Return/TimerActivityAttributes.swift(43 lines). Shared between the app target and the widget extension target via target membership. ↩↩↩↩↩ -
Apple Developer, “Displaying live data with Live Activities”. Concurrency limits, supported platforms (iOS 16.1+, iPadOS 17+),
NSSupportsLiveActivitiesInfo.plist key. ↩↩ -
Apple Developer, “Updating and ending your Live Activity with ActivityKit push notifications”. The
pushType: .tokenpath requires a separate APNs auth key, server-side push token registration, and a different update protocol from localactivity.update(...)calls. ↩↩ -
Apple Developer, “Text(timerInterval:pauseTime:countsDown:showsHours:)”. Live system-rendered countdown timer; renders without app updates while the activity is running. ↩
-
Production code in
Return/ReturnWidgets/ReturnLiveActivity.swift(232 lines). The widget extension’sWidgetconformance withActivityConfiguration<TimerActivityAttributes>body. TheTimerTextview at lines 61-102 handles the paused / running / post-end three-state rendering. ↩↩↩↩ -
Apple Developer, “DynamicIsland”. The four named expanded regions (
leading,trailing,center,bottom) plus three compact-mode views (compactLeading,compactTrailing,minimal). ↩ -
The widget extension runs in its own process and inherits the system locale, not the app’s selected locale. Apps that support in-app language switching (Return supports 27 languages) must pass the language code through
ActivityAttributesso the widget can render in the user’s chosen language. Pattern:Locale(identifier: context.attributes.languageCode)rather thanLocale.current. ↩ -
Apple Developer, “Button(intent:)”. Available in widget and Live Activity views from iOS 17+. Bridges App Intents into Lock Screen / Dynamic Island controls without requiring app foregrounding. ↩
-
Apple Developer, “TimelineProvider”. The widget refresh model that predates Live Activities; pre-computed entries with system-managed reload windows. ↩
-
Production code in
Return/ReturnWidgets/ReturnWidgetsBundle.swift(16 lines). The@main WidgetBundlethat registersReturnLiveActivityas the widget extension’s only widget. Required pattern for widget extensions; the bundle is what the system loads. ↩ -
Apple Developer, “ActivityUIDismissalPolicy”. Three cases:
.default,.immediate,.after(_:). Apple states.defaultkeeps an ended Live Activity visible “for some time” up to four hours, and.after(_:)accepts a date within the same four-hour window. ↩↩