watchOS 執行時是一份契約,而非背景任務
類型: 實戰程式碼。本文記錄 Return 在生產環境中採用的 watchOS 執行時模式。Return 是已上架 App Store 的 SwiftUI 冥想計時器;其 Watch app 執行的是多週期計時器,必須在使用者放下手腕時繼續計時。1 能在這項限制下存活的模式,就是 WKExtendedRuntimeSession 加上一個全域、應用程式層級的 delegate。其他做法在手錶進入睡眠的瞬間就會失效。
watchOS 並不是螢幕較小的 iOS。其執行時模型截然不同。iOS 提供應用程式充裕的前景預算,並透過音訊會話、位置更新、BGTaskScheduler 與少數其他機制,提供一段逐漸縮減但確實存在的背景執行時間。2 watchOS 在抬腕放下後,給予前景應用程式的預算僅以秒計,之後若應用程式未與系統簽訂執行時契約,便會被暫停。這裡沒有「我只是在背景做點事」的便利機制。只有「我正在執行健身、正念、智慧鬧鐘、導航路線或健康監測任務」,僅此而已。3
Return 的 Watch 目標是一個正念計時器。會話契約為 WKBackgroundModes: mindfulness。執行時 API 為 WKExtendedRuntimeSession。讓 Watch app 從手腕一放下就壞掉變成能撐過 25 分鐘冥想的模式,正是本文要描述的內容。
TL;DR
- watchOS 沒有 iOS 風格的背景執行機制。手腕放下後不久前景執行時間就會結束,只有已註冊的會話類型可以繼續執行。
WKExtendedRuntimeSession是 API 介面。會話必須宣告一個會話類型;以冥想計時器而言,類型是透過Info.plist中的WKBackgroundModes: mindfulness隱性宣告的。- 會話管理器必須存活於應用程式層級,而非檢視層級。SwiftUI 的檢視生命週期會在導航時釋放檢視所擁有的物件;被釋放的會話 delegate 等同於失效的會話,即便會話本身仍在執行。
WKExtendedRuntimeSessionDelegate的回呼即為契約:didStart、willExpire、didInvalidateWith。expire 回呼會在系統強制使會話失效之前觸發;Apple 將其描述為應用程式「結束並清理」的時機。- 沒有作用中擴充會話時,手腕放下會暫停計時器;有作用中擴充會話時,手腕放下計時器會繼續。會話的存在與否,正是「成功上架的產品」與「第二次使用就壞掉」的差別。
watchOS 並未以 iOS 方式解決的背景問題
當 iOS 應用程式需要在螢幕關閉時繼續執行時,會借助多種背景機制:2
- 採用
.playback類別的AVAudioSession,可在播放音樂時讓音訊應用程式持續運作。 CLLocationManager的背景更新,配合藍色狀態列,可讓導航應用程式持續運作。BGTaskScheduler將短期維護工作排入佇列,由系統依自身排程執行。- 前景 UI 擴充功能(Live Activity、CallKit、PushKit)將應用程式行程橋接至系統控制的呈現介面。
上述機制在 watchOS 上並不會以您預期的方式發揮作用。Watch 應用程式沒有相同的背景任務排程器;沒有可在靜音時讓計時器持續計數的背景 AVAudioSession.playback 模式。對於「我希望使用者放下手腕後仍能繼續執行」這個需求,watchOS 只有一個結構性原語,那就是搭配宣告會話類型的 WKExtendedRuntimeSession。3
Apple 透過 WKBackgroundModes 支援的會話類型,刻意設計得相當狹窄:4
workout-processing(搭配實際健身用的HKWorkoutSession)mindfulness(用於冥想計時器與呼吸練習)self-care(用於引導式日常)physical-therapy(用於物理治療會話應用程式)alarm(用於以時間為基礎的喚醒鬧鐘)underwater-depth(用於潛水與深度追蹤應用程式)
不符合上述類別之一的應用程式,無法使用 WKExtendedRuntimeSession 在手腕放下後繼續執行。音訊應用程式採用的是 mediaPlayback 音訊會話類別搭配 Now Playing 整合的另一條程式路徑;導航應用程式則使用 CLLocationManager 背景更新。Apple Watch 並非通用型電腦;它是一台受電池限制、且由執行時模型強制執行這些限制的裝置。
冥想計時器符合 mindfulness。契約是:在 Info.plist 宣告背景模式、請求 WKExtendedRuntimeSession、處理 delegate 回呼、計時器結束時結束會話。系統會授予每個會話最多約一小時的執行時間,並可能因熱量或電量壓力,依系統判斷縮短該時間。3
Return 採用的模式
模式從 Info.plist 宣告開始:4
<key>WKBackgroundModes</key>
<array>
<string>mindfulness</string>
</array>
模式宣告是讓會話類型有效的關鍵。少了它,呼叫 WKExtendedRuntimeSession().start() 會默默失敗,應用程式會在手腕放下時被暫停,與完全沒有宣告背景模式的 Watch app 沒兩樣。
會話管理器本身必須存活於應用程式層級。SwiftUI 的檢視生命週期對長期存活的有狀態物件並不友善:@StateObject 與 @State 的範圍受限於擁有它們的檢視,而取代檢視的導航 push 會連同狀態一起釋放。其 delegate 在會話進行中被釋放的 WKExtendedRuntimeSession 並不會崩潰;會話會繼續執行,但 delegate 回呼(willExpire、didInvalidateWith)會抵達一個已被釋放的物件,這代表清理工作永遠不會發生,下一次 startSession() 呼叫會以為沒有作用中的會話,於是啟動了一個重複的會話。
實戰採用的模式是應用程式層級的單例。下方程式片段是結構雛形;生產環境會在每個方法內加入記錄以利觀測:
import SwiftUI
import WatchKit
final class WatchSessionManager: NSObject, WKExtendedRuntimeSessionDelegate {
static let shared = WatchSessionManager()
private var session: WKExtendedRuntimeSession?
private override init() {
super.init()
}
var isSessionActive: Bool {
session != nil
}
func startSession() {
guard session == nil else { return }
let newSession = WKExtendedRuntimeSession()
newSession.delegate = self
newSession.start()
session = newSession
}
func endSession() {
guard let existing = session else { return }
existing.invalidate()
session = nil
}
// MARK: - WKExtendedRuntimeSessionDelegate
func extendedRuntimeSessionDidStart(_ session: WKExtendedRuntimeSession) {}
func extendedRuntimeSessionWillExpire(_ session: WKExtendedRuntimeSession) {
// Apple's "about to expire / finish and clean up" hook
}
func extendedRuntimeSession(
_ session: WKExtendedRuntimeSession,
didInvalidateWith reason: WKExtendedRuntimeSessionInvalidationReason,
error: Error?
) {
self.session = nil
}
}
關於這個單例,文件中沒有點明的三項細節:
static let shared 加上 @main App 層級的 @State private var sessionManager = WatchSessionManager.shared,可在 Watch app 行程的整個生命週期內讓管理器保持存活。 SwiftUI 不會僅因為某個檢視持有單例就保留它;上述繫結正是告訴執行時保留參照的關鍵。少了應用程式層級的繫結,當沒有檢視持有管理器時,ARC 可能會將其釋放。
session 屬性是防止重複會話的保護機制。 帶有「重新開始」按鈕的計時器可能從多條路徑呼叫 startSession();guard session == nil 檢查就是鎖。兩個並行的擴充會話會造成不可預測的行為:有時第二個成功而第一個變成孤兒,有時啟動呼叫會默默失敗。單一會話不變式可消除整類問題。
Delegate 回呼會記錄但少有動作。 didStart 回呼於每次會話發生一次,是觀測的好掛鉤;willExpire 回呼在系統強制使會話失效之前觸發,是 Apple 預期應用程式「結束並清理」的時機;didInvalidateWith 回呼則是清除會話參照、讓下一次 startSession() 呼叫可正常運作的位置。生產環境中的模式是回呼更新狀態,狀態機完成工作,而非回呼直接完成工作。
計時器管理器在每個會改變計時器是否正在計數的轉換點,呼叫會話管理器:
final class WatchTimerManager: ObservableObject {
func start() {
startExtendedSession() // -> WatchSessionManager.shared.startSession()
// ... start the timer state machine ...
}
func pause() {
timer?.invalidate()
isRunning = false
endExtendedSession() // -> WatchSessionManager.shared.endSession()
}
func reset() {
// ... clear timer state ...
endExtendedSession()
}
private func completeCycle() {
// ... last cycle handling ...
endExtendedSession() // ends on final completion
}
}
會話會在暫停、重設與最後一個週期完成時結束。產品上的考量:暫停中的冥想不需要繼續佔用系統依 mindfulness 授予的執行時預算;從暫停恢復時會重新取得新會話。產品代價是:以放下手腕方式暫停的計時器,無法僅靠抬腕恢復;使用者必須將應用程式帶回前景才能恢復。產品收穫是:暫停中的計時器電量成本歸零,系統也不會看到失效的會話。
手腕放下才是真正的測試
watchOS 在模擬器中的測試是禮貌性的虛構。模擬器並未像實體 Apple Watch 那樣強制執行抬腕放下的執行時模型。只要模擬器視窗保持焦點,模擬器就會讓應用程式留在前景;模擬器中的擴充執行時會話與沒有會話看起來毫無區別,因為前景應用程式無論如何都會繼續執行。
真正的測試在實體 Apple Watch 上:5
- 啟動計時器。
- 放下手腕(或按下側邊按鈕鎖定螢幕)。
- 等待 30 秒。
- 重新抬起手腕。
沒有作用中擴充執行時會話時,Watch 應用程式會被暫停;計時器狀態凍結在手腕放下的那一刻,並從凍結狀態恢復。對於一個讓使用者閉眼的 5 分鐘冥想而言,在使用者閉眼時間之長尚未呈現於計時器之前,這個錯誤是看不見的。
有作用中擴充執行時會話時,計時器會繼續計數。抬起手腕時可看到計時器處於正確的經過時間位置。音訊提示(若計時器在完成時播放)會在正確的牆上時鐘時間觸發,而非抬腕時間。
手腕放下情境就是 Return v1 出貨時的錯誤,並在 v2 修復。修復方式即上方的單例模式;錯誤是某個 SwiftUI 檢視持有的 WatchSessionManager 實例在導航 push 時被釋放。會話從系統端來看技術上仍在執行,但 delegate 已被釋放;下一次會話啟動呼叫默默變成空操作,因為管理器的 session 屬性已被設定在一個如今已死的物件上。實機測試能在數秒內顯現這個錯誤。模擬器測試永遠揭露不了它。
Delegate 回呼到底告訴您什麼
WKExtendedRuntimeSessionInvalidationReason 列舉了會話結束的各種方式:6
| 原因 | 何時發生 |
|---|---|
none |
應用程式呼叫 invalidate() 明確使會話失效 |
sessionInProgress |
已有相同類型的會話正在執行 |
expired |
達到系統規定的時間上限 |
resignedFrontmost |
會話執行時另一個應用程式成為前景 |
suppressedBySystem |
系統抑制了會話(低電量、熱量壓力) |
error |
發生不可恢復的錯誤;請檢查 error 參數 |
對產品設計重要的原因:
expired 代表使用者獲得了您所要求的完整會話。 會話自然執行至結束。Return 最長的冥想時長為 60 分鐘,正好處於 mindfulness 會話通常被授予的時間邊緣。90 分鐘的冥想會例行性地觸發 expired,計時器會在會話中途死亡。產品決策是將可用時長限制在執行時模型實際能交付的範圍內。
resignedFrontmost 代表使用者打開了另一個 Watch 應用程式,您的會話因此失效。 Watch 使用者很擅長滑到別的應用程式然後忘了。產品決策是要在失去前景時暫停(保留狀態,使用者可回來繼續),或是失去前景時結束(會話結束,使用者得到「您提早停止」的訊號)。Return 選擇暫停,讓使用者可以在冥想中接電話後再回來。
suppressedBySystem 是「手錶很燙」的禮貌版本。 處於熱量壓力或低電量的 watchOS 裝置,即便應用程式沒有誤用,也可能撤銷擴充執行時會話。會話管理器必須優雅處理:清除參照、提示非阻斷性警告,並避免進入嘗試重啟系統剛拒絕之會話的狀態。
willExpire 回呼在會話即將過期時觸發,文件記載這是應用程式「結束並清理」的時機。3 此回呼是應用程式可寫入最終狀態快照、播放結束音訊提示,或呈現「會話即將結束」UI 的位置。Return 目前僅記錄此回呼;更豐富的清理工作(HealthKit 記錄條目、音訊淡出)發生在計時器的重設與完成路徑上,並列於若重來會做不同設計的 willExpire 視窗清單中。
若重來會做不同設計
如果 Return 從零開始,有兩件事會不同。
對於價值會因 HealthKit 整合而提升的會話,使用 HKWorkoutSession。 冥想計時器處於 mindfulness 與 workout-processing 的邊緣。對 v1 而言,mindfulness 是正確選擇,因為資料模型較單純,且使用者預期是「這是冥想,不是健身」。HKWorkoutSession 提供更細緻的 HealthKit 整合(會話開始、結束、區段、事件),並提供更豐富的 LiveWorkoutBuilder 介面以累積資料。這是架構上的判斷,並非 Apple 文件保證:對於價值取決於詳細會話遙測的應用程式,workout-session 路徑能處理 WKExtendedRuntimeSession 無法處理的結構。
從第一天就加入會話狀態的可觀測介面。 Return 第一版將會話事件記錄至主控台。第二版加入了裝置上會話狀態可見性以利除錯。第三版會新增一個開發者模式切換,當出問題時將會話原因歷史展示給使用者,而不是把會話失效視為黑盒。watchOS 執行時是不透明的;除錯介面必須補上這份缺口。
WKExtendedRuntimeSession 不適合的情況
有三種會話類型不符的情況:
需要區段標記、心率串流或活動卡路里追蹤的健身。 直接使用 HKWorkoutSession 搭配 HKLiveWorkoutBuilder。Workout API 是 Apple 為實際健身(以及步行冥想或劇烈活動)所記載的路徑;WKExtendedRuntimeSession 是為非健身會話(如正念或鬧鐘)所記載的路徑。冥想應用程式不需要健身;Couch-to-5K 應用程式則需要。
需要 Now Playing 介面的音訊播放。 使用設定為 playback 的 AVAudioSession,搭配 watchOS 音訊會話權限;Now Playing 整合加上系統播放介面正是音訊應用程式想要的,且音訊路徑與 WKExtendedRuntimeSession 完全分離。WKExtendedRuntimeSession 並不提供 Now Playing 或系統音訊路由。
使用者並未感知的長時間資料同步。 使用 WKApplicationRefreshBackgroundTask 由系統排程的週期性重新整理視窗。使用者並不在應用程式中;應用程式不需要持續執行;它需要的是短暫喚醒並重新整理。背景任務與擴充執行時會話這兩個模型,服務的需求大不相同。
此模式對 watchOS 11+ 上架應用程式的意義
三項要點。
-
Watch 執行時模型是選擇加入的。挑一個會話類型,並在其規則內生活。 嘗試在 watchOS 上做「通用背景工作」的應用程式必敗。請從
mindfulness、workout-processing、self-care、physical-therapy、alarm或underwater-depth中挑選,並依您所選會話類型附帶的執行時預算來設計使用者體驗。 -
會話 delegate 必須存活於應用程式層級。SwiftUI 的檢視生命週期無法保護長期存活的有狀態物件。 在
@main App層級繫結的static let shared單例,是能在導航 push、檢視取代與 SwiftUI 一般釋放行為下存活的最簡模式。 -
在實機上測試。模擬器不會強制執行抬腕放下的執行時模型。 Watch 應用程式無法在模擬器中測出的錯誤,正是它會交付給使用者的錯誤。
請將本文與我先前針對同系列應用程式的撰文一併閱讀:跨平台 SwiftUI 上架(Return 上架於 iPhone、iPad、Watch、Mac 與 Apple TV);Live Activities 狀態機(同一個計時器在 iOS 端的介面);HealthKit 模式(Watch 的正念會話如何落入使用者的健康資料)。完整系列位於 Apple 生態系列首頁。如需更廣泛的 iOS 結合 AI agent 脈絡,請見 iOS Agent 開發指南。
常見問題
什麼是 watchOS 擴充執行時會話?
watchOS 擴充執行時會話(WKExtendedRuntimeSession)是 Watch 應用程式用來在使用者放下手腕後繼續執行的 API。會話必須透過 Info.plist 中的 WKBackgroundModes 宣告類型(mindfulness、workout-processing、alarm 等)。沒有作用中的擴充會話時,watchOS 會在手腕放下後不久暫停應用程式。
為什麼使用者放下手腕時,我的 watchOS 計時器會停止計時?
除非有支援類型的作用中 WKExtendedRuntimeSession 正在執行,否則 Watch 應用程式會在手腕放下後不久被暫停。沒有啟動此類會話的計時器管理器,背景執行時間會被切斷,計時器狀態會凍結於手腕放下的那一刻,直到使用者再次抬腕為止。
WKExtendedRuntimeSession 與 HKWorkoutSession 有何差異?
WKExtendedRuntimeSession 是用於非健身會話(如正念、鬧鐘或自我照護)的通用擴充執行時 API。HKWorkoutSession 是用於實際健身的 API;它整合 HealthKit、支援區段標記,是步行冥想或劇烈活動的官方記載路徑。沒有健身等級遙測需求的正念應用程式使用前者;健身應用程式使用後者。
系統可以撤銷我的擴充執行時會話嗎?
可以。WKExtendedRuntimeSessionInvalidationReason 包含 expired(達到系統時間上限)、resignedFrontmost(另一個 Watch 應用程式成為前景)與 suppressedBySystem(低電量或熱量壓力)。會話管理器必須妥善處理每一種:清除參照、計時器狀態做出適當反應,下一次會話啟動呼叫也能正常運作。
在 SwiftUI 的 watchOS 應用程式中,會話管理器應該存活於哪裡?
存活於應用程式層級,作為從 @main App 結構繫結的單例。SwiftUI 檢視層級的狀態(@State、@StateObject)會在導航 push、檢視取代或應用程式進入背景時被釋放。在會話進行中被釋放、由檢視擁有的會話 delegate,會導致會話參照外洩,並使後續會話無法乾淨啟動。
參考資料
-
作者的 Return,一款 SwiftUI 冥想計時器,於 2026 年 4 月 21 日於 App Store 發布,支援 iPhone、iPad、Mac、Apple Watch 與 Apple TV。Watch 應用程式使用
WKExtendedRuntimeSession搭配mindfulness背景模式,作為週期計時器的執行時。 ↩ -
Apple Developer,「About the background execution sequence」。iOS 端的背景執行時機制(音訊會話、位置、BGTaskScheduler),以及它們與 watchOS 的差異。 ↩↩
-
Apple Developer,「WKExtendedRuntimeSession」。會話類型、生命週期、delegate 回呼、執行時上限,以及
WKBackgroundModesInfo.plist 鍵。 ↩↩↩↩ -
Apple Developer,「Information Property List: WKBackgroundModes」。支援的會話類型字串:
workout-processing、mindfulness、self-care、physical-therapy、alarm、underwater-depth。 ↩↩ -
Apple Developer,「Building a watchOS app」 與 WatchKit 測試指南。實機執行時行為無法在 watchOS 模擬器中重現;模擬器不會強制執行手腕放下的暫停行為。 ↩
-
Apple Developer,「WKExtendedRuntimeSessionInvalidationReason」。列舉項目:
none、sessionInProgress、expired、resignedFrontmost、suppressedBySystem、error。 ↩