即時動態是狀態機,而非徽章
類型: shipped-code。本文記錄我為 Return 打造的即時動態(Live Activity),這是一款 SwiftUI 冥想計時器,我太太用、我媽用,還有數千位陌生人也在使用。1這些模式都是在生產環境中存活下來的。文末的殘酷誠實段落會說明我目前還不知道的部分。
Return 的即時動態看起來像鎖定畫面與動態島(Dynamic Island)上的倒數數字。2它不是一個數字。它是一台具有五種生命週期狀態、三種外部解除路徑,以及一條必須自我防禦的可重入啟動路徑的狀態機。
我曾交付一個將即時動態當作徽章處理的 v1 版本。「目前剩餘時間」是資料,其餘都是裝飾。那個版本有三個我在 TestFlight 階段抓到的錯誤,以及一個在生產環境才發現的:
- 在啟動尚未完成時再次點擊啟動,會建立第二個活動,讓第一個變成孤兒。
- 倒數在動態島上正確渲染,但鎖定畫面的視圖在計時器暫停時會碰到
endTime <= Date(),於是顯示0:00直到使用者恢復。 - 即時動態在使用者重置計時器後仍長時間可見,因為解除策略是
.default,而 Apple 會讓它持續顯示一段時間,最長可達四小時。 - (生產環境。)在從右至左語系(阿拉伯文、希伯來文)中,動態島緊湊尾端區域的數字顯示為反向。拉丁數字、RTL 排版。修正只需要一行。
這每一個都是狀態機錯誤。倒數數字本身沒有問題。數字不是產品。狀態才是產品。
下方的狀態機就是從這些錯誤中存活下來的版本。
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 系統語系下保持拉丁數字由左至右。
狀態機
已交付的即時動態具有一個閒置狀態、三個使用者可觀察的活動狀態、一個終止狀態,以及一個開發者必須遵守的可重入閘門:
┌──────────────────────────────────────────────────────────────────┐
│ 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(小工具擴充功能,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 將其跨行程邊界傳遞至小工具擴充功能。小工具接著重新渲染。沒有共享記憶體。沒有 callback。只有一個在每次轉換時跨越行程邊界的 Codable 結構。
這個事實排除了我可能想用 closure、view model、observable object 或計算屬性做的任何事情。狀態必須能以可序列化的資料表達。如果無法被編碼,就無法轉換。
可重入啟動
即時動態對並發活動數量設有硬性上限,而對於兩次飛行中(in flight)呼叫 Activity.request 會發生什麼事,則是軟性上限。硬性上限的文件寫得很清楚。4軟性上限是「第二次呼叫可能成功並建立一個孤兒」。這個孤兒就是不再與管理器中 currentActivity 關聯的即時動態。它存活著。它沒有路徑可以回到您的程式碼中。它最終會依其本身的過期計時器自行解除。在那之前,使用者會看到重複的計時器。
孤兒就是 Return v1 交付時的錯誤。修正方式是 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 永久存在,直到 app 重新啟動之前活動再也不會啟動。旗標就是鎖;鎖必須在每一條退出路徑上釋放。
pushType: nil 引數。 Return 不使用 APNs 推送的即時動態更新。應用程式透過 activity.update 在本機更新活動。如果您需要由推送驅動的更新(配送追蹤、運動賽事比分、即時資料),類型就是 pushType: .token,而契約會複雜得多。5本機更新較為簡單,且足以涵蓋任何計時器、計數器或單一 app 的工作流程。
暫停問題
ActivityKit 提供了一個漂亮的 Text(timerInterval: Date()...endTime, countsDown: true) 視圖,可在不需要 app 任何更新的情況下渲染即時倒數。6您設定結束時間,系統就會渲染即時計時器。沒有 Timer.publish、沒有小工具刷新、沒有耗電問題。
當計時器執行中時,這非常棒。但當計時器暫停時,這就錯了。
timerInterval 文字會朝 endTime 倒數,無視於狀態中任何「暫停」訊號。Apple 的 API 中沒有「凍結在 10:23」的模式。如果您傳入 endTime = Date().addingTimeInterval(623) 而使用者在 10:23 標記處暫停,小工具中的計時器文字仍會繼續倒數至零。狀態欄位顯示已暫停。小工具卻渲染為執行中。
修正方式是從同一個狀態渲染兩種不同的視圖: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 |
使用者重置、錯誤、app 進入背景且無活動需追蹤 | 活動立即消失。沒有寬限時間 |
.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。對於已完成的冥想計時器,系統讓活動可見的時間長到使用者以為 app 根本沒有登錄完成。Apple 的文件說 .default 會讓已結束的活動「在一段時間內保持可見」最長四小時;13計時器的正確姿態是讓解除明確化。
動態島緊湊區域
動態島具有三種渲染模式,即使是簡單的計時器也都需要這三種:2
- 緊湊(預設動態島形狀):前導圖示 + 尾端計時器
- 最小化(當另一個即時動態爭奪同一個動態島時):僅前導圖示
- 展開(長按):四個具名區域(
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")...
}
大多數即時動態教學會將展開視圖視為「真正的」設計,在 bottom 區域放置豐富內容。對冥想計時器而言,展開是無謂的負擔。使用者透過長按開啟展開視圖,而長按本身已經給他們事件發生的觸覺回饋。增加內容會讓展開「說出」使用者沒有要求的內容。展開模式中的空白區域不是設計失敗;它就是設計本身。
RTL 錯誤
生產環境的錯誤。iOS 上的阿拉伯文與希伯來文使用者回報動態島緊湊尾端的計時器數字以反向顯示。拉丁數字字串 5:23 渲染為 32:5,因為緊湊尾端的排版方向繼承了系統語系的 RTL 設定。
SwiftUI 在小工具行程中繼承系統排版方向,因此當使用者的手機設為阿拉伯文或希伯來文時,動態島計時器文字會採用 RTL。拉丁數字理應在其他 RTL UI 中也以 LTR 渲染。修正方式是在數字文字視圖上固定排版方向:7
.environment(\.layoutDirection, .leftToRight)
這個覆寫要放在 TimerText(動態島緊湊/展開)內的數字 Text 視圖,以及鎖定畫面視圖內,而不是整個視圖上。拉丁數字無論使用者的系統語系為何都應由左至右閱讀;像「Cycle 2 of 3」這類循環標籤則保持本地化,以便遵循系統排版方向。
這個錯誤不會在本國語系的 TestFlight 中出現。它會在真正的 RTL 使用者打開計時器的那一刻浮現。教訓是:在任何可能於 RTL 語系下執行的即時動態中,為每一個拉丁數字文字視圖加上 LTR 固定的環境覆寫。
本地化的故事
TimerActivityAttributes 攜帶一個 languageCode: String 欄位,在活動建立時由 app 設定:9
let attributes = TimerActivityAttributes(
timerDuration: duration,
languageCode: settings.appLanguage // app's selected language, not system's
)
小工具擴充功能讀取此欄位以渲染本地化字串:
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)
}
為什麼 app 要傳遞自己的語言代碼,而不是讓小工具讀取 Locale.current:小工具擴充功能在自己的行程中執行。它的 Locale.current 是系統語系,不是 app 選擇的語系。如果使用者將 Return 設為韓文,而 iPhone 是英文,小工具就會以英文呈現,除非有此覆寫。app 的語言偏好透過活動屬性傳遞;小工具會尊重它。
Localizable.xcstrings 與 app 的版本一起放在小工具目標中,但它們是分開的檔案。在小工具中使用的字串必須存在於 ReturnWidgets/Localizable.xcstrings,即使相同字串已經存在於 Return/Localizable.xcstrings。忘了這點意味著小工具會回退到開發語言,而 app 卻說韓文。
我會用什麼方式重建
讓 ContentState 變得更小。 六個欄位太多了。endTime 與 remainingSeconds 之間的冗餘是繞過 timerInterval 沒有暫停模式的代價。如果重新開始,我會攜帶單一個 displayMode 列舉(running、paused(remainingSeconds: Int)、cycleEnd、complete),讓渲染程式碼依 case 分派。在五個轉換方法中正確地保持六個欄位的變更,比四個 case 更困難。
新增互動式即時動態按鈕(iOS 17+)。 Return 目前並未在動態島中暴露暫停/恢復控制項。使用者必須打開 app 才能暫停。iOS 17 為即時動態內的 App Intent 加入了 Button(intent:)。10互動式暫停控制項是顯而易見的延伸,也是我接下來會為 Return 交付的功能。
推送更新的即時動態以實現跨裝置計時器同步。 Return 透過 NSUbiquitousKeyValueStore 在 iPhone、iPad、Watch 與 Apple TV 之間同步 session(請參閱五個 Apple 平台,三個共享檔案)。今日活動是從 iPhone 或 iPad app 在本機啟動並在本機更新。理想情況下,使用者在 Apple Watch 上啟動計時器,iPhone 上的即時動態應能即時反映。APNs 推送至即時動態就是這條路徑。5尚未建構。
何時不該使用即時動態
一次性的瞬時狀態。 「已儲存!」的 toast 不值得用即時動態。系統已經有橫幅了。用它就好。
沒有計時器維度的頻繁變化資料。 即時動態最適合具有清晰時間錨點的事物(計時器、配送 ETA、比賽時鐘、通話時長)。股票報價與運動賽事比分之所以有效,是因為它們有 session 視窗。一個通用儀表板則不適合。
沒有鎖定畫面或待機使用情境的 app。 即時動態需要實際的工程投入(target 設定、ContentState 設計、解除策略決策、RTL 處理、本地化管線)。使用者直接打開、使用過程中從未查看鎖定畫面的 app 並不是合適的形狀。一個照片編輯器不需要它。一個健身追蹤器需要。
在非 iOS 平台上,有但書。 Return 的 LiveActivityManager 將其實作放在 #if os(iOS) 之後,因為計時器是從 iPhone 或 iPad app 啟動的。ActivityKit 本身將鎖定畫面橫幅、動態島、Apple Watch Smart Stack、Mac 與 CarPlay 描述為呈現平面;iOS 26 擴展了其中數項。4watchOS 仍有自己的 complications API 用於全螢幕渲染。macOS 有選單列 app。iPadOS 自 iPadOS 17 起支援即時動態,但沒有動態島區域。Return 的管理器在一個 224 行的檔案中有 8 處 #if os(iOS) 守護。
這個模式對於在 iOS 26+ 上交付的 app 意味著什麼
兩個重點。
-
將即時動態視為狀態機,而非數字。 狀態機具有明確的狀態、明確的轉換,以及明確的解除規則。螢幕上的數字是某個狀態的某種渲染。先把狀態做對。
-
可重入守護是您尚未遇到的錯誤。 我在野外看到的每一個沒有實作
isStartingActivity+ 可取消 Task 的即時動態管理器,都至少交付過一個孤兒活動錯誤。守護只需要 6 行。寫一次就好。
請將本文與我為同一系列 app 撰寫的先前文章配對閱讀:類型化的 App Intents 用於 Apple Intelligence;MCP 伺服器用於跨 LLM 代理;Liquid Glass 模式用於視覺層;多平台交付用於跨裝置覆蓋。即時動態是同一堆疊中 iOS 鎖定畫面與動態島的層級。完整集合位於 Apple 生態系列中心。如需更廣泛的 iOS 與 AI 代理脈絡,請參閱 iOS 代理開發指南。
FAQ
即時動態與 WidgetKit 小工具之間有什麼差異?
WidgetKit 小工具以 TimelineProvider 定義的間隔渲染;系統決定何時刷新,小工具則從靜態時間軸重新渲染。11即時動態則回應由 app 驅動的特定 activity.update(...) 呼叫進行渲染,並在底層活動的持續時間內存活(計時器、配送、健身)。兩者都在小工具擴充功能 target 中交付;差別在於觸發模型。
即時動態能在 iPad 上運作嗎?
可以,從 iPadOS 17 起。鎖定畫面橫幅是主要的渲染表面;iPad 沒有動態島。相同的 ActivityConfiguration 程式碼可以運作;只是預期動態島區域永遠不會在 iPad 上渲染。
即時動態能比我的 app 行程活得更久嗎?
是的。一旦 Activity.request 成功,ActivityKit 就擁有該活動。app 行程可被系統終止;活動會繼續在鎖定畫面與動態島上渲染,直到您明確結束它(或系統的過期規則將其解除)。明確的 endActivity() 呼叫因此重要;若 app 重置時未明確結束,活動會比計時器活得更久。
為什麼本文沒有涵蓋推送更新的即時動態?
我尚未在 Return 中交付推送更新的即時動態。依此叢集的類型規則:shipped-code 文章只記錄生產程式碼實際做的事。推送更新已列在「我會用什麼方式重建」中;未來會在我交付後撰寫一篇文章涵蓋這個主題。
SwiftUI app 中即時動態的實際檔案配置是什麼?
- 在主 app target 中:
LiveActivityManager.swift(管理活動生命週期)、TimerActivityAttributes.swift(與小工具共享的ActivityAttributes結構;兩個 target 都會編譯這個檔案)。 - 在小工具擴充功能 target 中:
ReturnLiveActivity.swift(具有ActivityConfiguration主體的Widget一致性實作)、ReturnWidgetsBundle.swift(@main WidgetBundle)。 - 設定: app target 的
Info.plist中設有NSSupportsLiveActivities = YES。
小工具擴充功能 target 需要 ActivityKit 與 WidgetKit 匯入。TimerActivityAttributes 是兩個 target 之間唯一共享的檔案;其他一切都是 target 隔離的。
即時動態不是鎖定畫面上的一個數字。它是一台每次轉換都跨越行程邊界的狀態機。把狀態做對、守護可重入性、有意識地選擇解除策略,並固定排版方向。數字會自己照顧自己。
參考資料
-
作者的 Return,一款於 2026 年 4 月 21 日發佈於 App Store 的 SwiftUI 冥想計時器,適用於 iPhone、iPad、Mac、Apple Watch 與 Apple TV。即時動態僅在 iOS target 上交付。 ↩
-
Apple Developer,「ActivityKit framework」。鎖定畫面橫幅、動態島緊湊/最小化/展開模式、活動生命週期。可用於 iOS 16.1+;動態島可用於 iPhone 14 Pro 及以後機型。 ↩↩
-
生產程式碼位於
Return/Return/LiveActivityManager.swift(224 行,8 個#if os(iOS)區塊)與Return/Return/TimerActivityAttributes.swift(43 行)。透過 target 成員資格在 app target 與小工具擴充功能 target 之間共享。 ↩↩↩↩↩ -
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 驗證金鑰、伺服器端推送 token 註冊,以及與本機activity.update(...)呼叫不同的更新協定。 ↩↩ -
Apple Developer,「Text(timerInterval:pauseTime:countsDown:showsHours:)」。系統渲染的即時倒數計時器;在活動執行期間無需 app 更新即可渲染。 ↩
-
生產程式碼位於
Return/ReturnWidgets/ReturnLiveActivity.swift(232 行)。小工具擴充功能的Widget一致性實作,具有ActivityConfiguration<TimerActivityAttributes>主體。位於第 61-102 行的TimerText視圖處理暫停/執行中/結束後的三狀態渲染。 ↩↩↩↩ -
Apple Developer,「DynamicIsland」。四個具名展開區域(
leading、trailing、center、bottom)加上三個緊湊模式視圖(compactLeading、compactTrailing、minimal)。 ↩ -
小工具擴充功能在自己的行程中執行,並繼承系統語系,而非 app 選擇的語系。支援 app 內語言切換的 app(Return 支援 27 種語言)必須透過
ActivityAttributes傳遞語言代碼,小工具才能以使用者選擇的語言渲染。模式為Locale(identifier: context.attributes.languageCode)而非Locale.current。 ↩ -
Apple Developer,「Button(intent:)」。可用於 iOS 17+ 的小工具與即時動態視圖中。將 App Intent 橋接至鎖定畫面/動態島控制項,無需將 app 帶到前景。 ↩
-
Apple Developer,「TimelineProvider」。早於即時動態的小工具刷新模型;預先計算的項目搭配系統管理的重新載入時段。 ↩
-
生產程式碼位於
Return/ReturnWidgets/ReturnWidgetsBundle.swift(16 行)。@main WidgetBundle將ReturnLiveActivity註冊為小工具擴充功能的唯一小工具。小工具擴充功能必備的模式;系統載入的就是這個 bundle。 ↩ -
Apple Developer,「ActivityUIDismissalPolicy」。三種 case:
.default、.immediate、.after(_:)。Apple 表示.default會讓已結束的即時動態「在一段時間內保持可見」最長四小時,而.after(_:)接受同一個四小時範圍內的日期。 ↩↩