Live Activities 是狀態機,不是徽章
Return 中的 Live Activity 看起來像是鎖定畫面與動態島上的倒數數字。12但它不是一個數字。它是一台擁有五種生命週期狀態、三條外部解除路徑,以及一條必須自我防禦的可重入啟動路徑的狀態機。下方所述的模式是在生產環境中存活下來的那些。文末殘酷誠實的註腳會說明我尚未弄清楚的部分。
我曾推出一個 v1 版本,把 Live Activity 當成徽章來處理。「目前剩餘時間」是資料;其餘都是裝飾。那個版本有三個我在 TestFlight 抓到的錯誤,以及一個在生產環境才抓到的錯誤:
- 在啟動還在傳輸中時又點選啟動,會建立第二個活動,並讓第一個變成孤兒。
- 倒數計時在動態島上顯示正確,但鎖定畫面檢視在計時器暫停時會踩到
endTime <= Date(),於是顯示0:00直到使用者恢復計時為止。 - 使用者重設計時器後,Live Activity 仍長時間停留在畫面上,因為解除政策是
.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 系統地區下保持拉丁數字 LTR。
狀態機
上線版本的 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 結構,會在每次轉換時跨越程序邊界。
這個事實排除了我可能想用閉包、view model、observable object 或計算屬性來做的任何事情。狀態必須以可序列化的資料表示。如果無法編碼,就不能轉換。
可重入啟動
Live Activities 對並發活動有硬上限,對在傳輸途中重複呼叫 Activity.request 會發生什麼事則有軟上限。硬上限有完整文件記載。4軟上限是「第二次呼叫可能成功並建立一個孤兒。」這個孤兒就是不再與您的 manager 中 currentActivity 關聯的那個 Live Activity。它會繼續存在,沒有任何路徑能重新回到您的程式碼中。它最終會自行依過時計時器解除。在那之前,使用者會看到一個重複的計時器。
這個孤兒就是 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 卡住,活動就再也不會啟動,直到應用重啟。這個旗標是一把鎖;鎖必須在每條離開路徑上釋放。
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 錯誤
那個生產環境的錯誤。iOS 上的阿拉伯文與希伯來文使用者回報,動態島緊湊尾端的計時器數字以反向呈現。拉丁數字字串 5:23 被繪製成 32:5,因為緊湊尾端的版面方向繼承了系統地區的 RTL 設定。
SwiftUI 在 widget 程序內會繼承系統的版面方向,因此當使用者的手機設定為阿拉伯文或希伯來文時,動態島的計時器文字也會跟著套用 RTL。即使在 RTL 介面中,拉丁數字也應該以 LTR 繪製。修正方式是在數字文字檢視上鎖定版面方向:7
.environment(\.layoutDirection, .leftToRight)
這個覆寫應加在 TimerText 內的數字 Text 檢視(動態島緊湊/展開)以及鎖定畫面檢視內,而不是整個檢視。拉丁數字無論使用者的系統地區為何,都應該由左至右閱讀;像「Cycle 2 of 3」這類的循環標籤則保持本地化,因此會跟隨系統的版面方向。
這個錯誤在本國地區的 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),讓繪製程式碼依 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 中詳述)。今天活動是從 iPhone 或 iPad 應用程式本機啟動並本機更新。理想情況下,使用者在 Apple Watch 上啟動計時器時,能在 iPhone 上即時看到 Live Activity 反映出該狀態。透過 APNs 推送至 Live Activity 就是路徑。5我還沒做。
何時不要使用 Live Activities
一次性的暫態狀態。「已儲存!」這類的 Toast 不值得用 Live Activity。系統已經有橫幅。請用它。
沒有時間維度的頻繁變動資料。Live Activities 最適合具有清晰時間錨點的事物(計時器、送貨 ETA、比賽計時、通話時間)。股價跑馬燈與運動賽事比分能用,是因為它們有一個工作階段窗口。通用的儀表板則不行。
沒有鎖定畫面/待機使用情境的應用程式。Live Activities 需要實實在在的工程投入(目標設定、ContentState 設計、解除政策決定、RTL 處理、本地化管線)。使用者直接打開應用、使用過程中從未查看過鎖定畫面的應用,並不是合適的形狀。相片編輯器不需要。健身追蹤器需要。
在非 iOS 介面上,但有附帶條件。Return 的 LiveActivityManager 將實作放在 #if os(iOS) 之後,因為計時器是從 iPhone 或 iPad 應用啟動的。ActivityKit 本身將鎖定畫面橫幅、動態島、Apple Watch Smart Stack、Mac 與 CarPlay 描述為呈現介面;iOS 26 擴充了其中數個。4watchOS 仍有自己的 complications API 用於全螢幕繪製。macOS 有選單列應用。iPadOS 自 iPadOS 17 起支援 Live Activities,但沒有動態島區域。Return 的 manager 在一個 224 行的檔案中有 8 個 #if os(iOS) 守衛。
對於在 iOS 26+ 上推出的應用,這個模式意味著什麼
兩個重點。
-
把 Live Activity 當作狀態機,不是數字。狀態機有清楚的狀態、清楚的轉換與清楚的解除規則。畫面上的數字只是某個狀態的一種繪製。先把狀態做對。
-
可重入守衛是您還沒踩到的那個錯誤。我在實際環境中看過的每一個沒有實作
isStartingActivity加可取消 Task 的 Live Activity manager,至少都出過一次孤兒活動的錯誤。這個守衛只有 6 行。寫一次就好。
請將這篇與我為同一系列應用所寫的文章一起閱讀:給 Apple Intelligence 用的型別化 App Intents;用於跨 LLM 代理的 MCP 伺服器;視覺層的 Liquid Glass 模式;跨裝置觸及的多平台推出。Live Activities 是同一個堆疊中的 iOS 鎖定畫面與動態島層。完整的系列在 Apple Ecosystem Series 中樞。要了解更廣泛的 iOS 加 AI 代理脈絡,請參閱 iOS Agent Development 指南。
FAQ
Live Activities 與 WidgetKit widget 有何不同?
WidgetKit widget 是依 TimelineProvider 定義的間隔繪製;系統決定何時重新整理,widget 從靜態時間軸重新繪製。11Live 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)。 - 設定:在應用目標中加入
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. ↩↩