← 所有文章

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个转换方法(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+上发布的应用意味着什么

两个要点。

  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 即可驱动按钮、控件和实时活动操作。

8 分钟阅读

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

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

13 分钟阅读

安装与更新 Codex CLI:Mac、Linux、Windows

codex update 可升级通过脚本、npm 和 Homebrew 安装的版本,通过 winget 安装的则用 winget upgrade OpenAI.Codex 升级。本文讲解如何在任意操作系统上安装、更新、锁定版本与卸载 Cod…

17 分钟阅读