← 所有文章

Live Activities是状态机,不是徽章

Return中的Live Activity看起来像锁屏和灵动岛上的一个倒计时数字。12它不是一个数字。它是一个五生命周期状态机,具有三条外部退出路径和一条必须自我防御的可重入启动路径。下文的模式都是在生产环境中存活下来的。文末的残酷诚实部分会说明我尚未掌握的内容。

我发布的v1版本将Live Activity当作徽章对待。”当前剩余时间”是数据;其余都是装饰。那个版本有三个我在TestFlight中发现的bug,以及一个在生产环境中发现的bug:

  1. 在启动正在进行中时再次点击启动,会创建第二个activity并使第一个变成孤儿。
  2. 倒计时在灵动岛上正确渲染,但锁屏视图对暂停的计时器命中了endTime <= Date(),在用户恢复之前一直显示0:00
  3. Live Activity在用户重置计时器后仍长时间可见,因为退出策略是.default,Apple会让其保持可见一段时间,最长达4小时。
  4. (生产环境。)在从右到左的语言区域(阿拉伯语、希伯来语)中,数字在灵动岛的紧凑尾部区域反向渲染。拉丁数字,RTL布局。修复只需一行代码。

这每一个都是状态机bug。倒计时数字本身没问题。数字不是产品。状态才是产品。

下面的状态机就是从那些bug中存活下来的版本。

TL;DR

  • 已发布的LiveActivityManager公开了5个转换方法(startActivityupdateActivityshowCycleCompleteshowFinalCompletionendActivity)外加1个读取方法(hasActiveActivity)。这224行生产代码守护着startActivity内部的一个特定隐患:并发的启动调用,以及在该方法中每个await边界处的取消检查。3
  • ContentState携带6个字段:endTimecurrentCycletotalCyclesisPausedisCompletedremainingSeconds。前五个是状态机的标签。第六个(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_END7

状态名称不是数字上的标签。状态名称是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)(长按):四个命名区域(leadingtrailingcenterbottom)

在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更小。 六个字段太多了。endTimeremainingSeconds之间的冗余是为绕过timerInterval中没有暂停模式而付出的代价。如果重新开始,我会携带一个displayMode枚举(runningpaused(remainingSeconds: Int)cycleEndcomplete),让渲染代码根据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+上发布的应用意味着什么

两个要点。

  1. 将Live Activity视为状态机,而不是数字。 状态机有清晰的状态、清晰的转换和清晰的退出规则。屏幕上的数字是某个状态的一种渲染方式。先把状态搞对。

  2. 可重入守卫是你尚未踩到的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的实际文件布局是什么样的?

三个部分:3712

  • 在主应用目标中: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


  1. 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. 

  2. 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. 

  3. Production code in Return/Return/LiveActivityManager.swift (224 lines, 8 #if os(iOS) blocks) and Return/Return/TimerActivityAttributes.swift (43 lines). Shared between the app target and the widget extension target via target membership. 

  4. Apple Developer, “Displaying live data with Live Activities”. Concurrency limits, supported platforms (iOS 16.1+, iPadOS 17+), NSSupportsLiveActivities Info.plist key. 

  5. Apple Developer, “Updating and ending your Live Activity with ActivityKit push notifications”. The pushType: .token path requires a separate APNs auth key, server-side push token registration, and a different update protocol from local activity.update(...) calls. 

  6. Apple Developer, “Text(timerInterval:pauseTime:countsDown:showsHours:)”. Live system-rendered countdown timer; renders without app updates while the activity is running. 

  7. Production code in Return/ReturnWidgets/ReturnLiveActivity.swift (232 lines). The widget extension’s Widget conformance with ActivityConfiguration<TimerActivityAttributes> body. The TimerText view at lines 61-102 handles the paused / running / post-end three-state rendering. 

  8. Apple Developer, “DynamicIsland”. The four named expanded regions (leading, trailing, center, bottom) plus three compact-mode views (compactLeading, compactTrailing, minimal). 

  9. 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 ActivityAttributes so the widget can render in the user’s chosen language. Pattern: Locale(identifier: context.attributes.languageCode) rather than Locale.current

  10. 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. 

  11. Apple Developer, “TimelineProvider”. The widget refresh model that predates Live Activities; pre-computed entries with system-managed reload windows. 

  12. Production code in Return/ReturnWidgets/ReturnWidgetsBundle.swift (16 lines). The @main WidgetBundle that registers ReturnLiveActivity as the widget extension’s only widget. Required pattern for widget extensions; the bundle is what the system loads. 

  13. Apple Developer, “ActivityUIDismissalPolicy”. Three cases: .default, .immediate, .after(_:). Apple states .default keeps an ended Live Activity visible “for some time” up to four hours, and .after(_:) accepts a date within the same four-hour window. 

相关文章

iOS 26 的小组件表面:一个 App Intent,多处呈现

iOS 26 的小组件、控制中心控件和实时活动都是 App Intents 的呈现表面。一个 intent 即可驱动按钮、控件和实时活动操作。

3 分钟阅读

SwiftUI 中的 Liquid Glass:在 iOS 26 上交付 Return 总结的三种模式

苹果的 Liquid Glass 是一行 SwiftUI API。Return 的三种模式超越了 .glassEffect():通过 Core Text 字形路径在文本上实现玻璃效果、镜面反射,以及 HUD 叠层。

7 分钟阅读

循环工程:在验证成本低廉处,循环才能取胜

循环工程,对照 Boris Cherny 的完整访谈记录来检验:他点名的每一个循环,验证成本都很低。正是这一约束决定了什么值得自动化。

4 分钟阅读