← 所有文章

watchOS 執行階段是契約,而非背景任務

Return 的 Watch App 執行的是多週期冥想計時器,必須在使用者放下手腕後仍持續計時。1 在這項限制下能夠存活的模式,是 WKExtendedRuntimeSession 搭配一個全域、App 範圍的 delegate。其他做法在手錶休眠的瞬間就會死亡。

watchOS 不是螢幕較小的 iOS。其執行階段模型截然不同。iOS 給予 App 充裕的前景預算,並透過音訊工作階段、位置更新、BGTaskScheduler 以及其他幾種便利機制,提供逐漸縮減但確實存在的背景執行時間。2 watchOS 在使用者放下手腕後,僅給予前景 App 以秒計算的預算,之後 App 就會被暫停,除非它已與系統簽訂執行階段契約。沒有「我只是在背景做點事」這種便利機制。只有「我正在執行健身、正念、智慧鬧鐘、導航路線或健康監測任務」,僅此而已。3

Return 的 Watch target 是一款正念計時器。其工作階段契約為 WKBackgroundModes: mindfulness。執行階段的 API 則是 WKExtendedRuntimeSession。讓 Watch App 從放下手腕就壞掉,蛻變為能撐過 25 分鐘冥想的模式,正是本文要描述的那一種。

TL;DR

  • watchOS 沒有 iOS 那種背景模式。前景執行時間在使用者放下手腕後不久便結束,只有已註冊的工作階段類型才會持續執行。
  • WKExtendedRuntimeSession 是執行階段的 API 介面。Apple 支援四種工作階段類型:self-caremindfulnessphysical-therapyalarm。對於冥想計時器,工作階段類型為 mindfulness,透過 Info.plist 中的 WKBackgroundModes 宣告。
  • 工作階段管理器必須存活於 App 範圍,而非 view 範圍。SwiftUI 的 view 生命週期會在導覽時釋放 view 擁有的物件;一個被釋放的 session delegate 等同於死亡的工作階段,即使工作階段本身仍在執行也是如此。
  • WKExtendedRuntimeSessionDelegate 的 callback 就是契約:didStartwillExpiredidInvalidateWith。expire callback 會在系統強制 invalidate 之前觸發;Apple 在「Using extended runtime sessions」中的範例程式碼將其定位為「在工作階段結束前完成並清理任何任務」的時機。3
  • 沒有作用中延伸工作階段時放下手腕,計時器會暫停。有作用中延伸工作階段時放下手腕,計時器會繼續。工作階段就是「上架產品」與「第二次使用就壞掉」之間的差別。

watchOS 不會用 iOS 方式解決的背景問題

iOS App 在需要讓 App 在螢幕關閉時持續執行時,可運用多種背景機制:2

  • 採用 .playback 類別的 AVAudioSession 可在播放音樂時讓音訊 App 保持運作。
  • CLLocationManager 的背景更新可讓導覽 App 在藍色狀態列下持續運作。
  • BGTaskScheduler 可排入由系統依其自身節奏排程的短期維護工作。
  • 前景 UI 擴充功能(Live Activity、CallKit、PushKit)將 App 行程橋接到系統控管的繪製介面。

以上這些在 watchOS 上的作用,可能與您所想像的並不相同。Watch App 沒有相同的背景任務排程器;沒有讓計時器在無聲中持續計數的背景化 AVAudioSession.playback 模式。它們僅有一個結構性原語可表達「我希望在使用者放下手腕後仍持續執行」,那就是搭配宣告工作階段類型的 WKExtendedRuntimeSession3

Apple 為 WKExtendedRuntimeSession 支援的工作階段類型刻意設計得很狹窄:3

  • self-care(簡短的健康活動,前景執行階段,10 分鐘上限)
  • mindfulness(靜默冥想,前景執行階段,1 小時上限)
  • physical-therapy(伸展與關節活動,背景執行階段,1 小時上限)
  • alarm(智慧鬧鐘,背景執行階段,30 分鐘上限,可透過 start(at:) 排程至 36 小時後)

健身 App 使用 HKWorkoutSession 搭配獨立的 workout-processing 背景模式;該路徑為實際健身用途所記載,並非 WKExtendedRuntimeSession 的類型。4 underwater-depth 背景模式則透過潛水工作階段的 API 路徑支援潛水與深度追蹤 App,同樣也不是透過 WKExtendedRuntimeSession。一款 App 可以將 workout-processing 與一種延伸執行階段類型結合,但每款 App 不能挑選一種以上的延伸執行階段類型。4

不屬於上述類別的 App,無法使用 WKExtendedRuntimeSession 在放下手腕後繼續執行。音訊 App 走的是另一條程式碼路徑,採用 AVAudioSession.Category.playback 音訊工作階段類別與 Now Playing 整合;導覽 App 則使用 CLLocationManager 的背景更新。Apple Watch 不是通用電腦,而是受電池限制的裝置,這項限制由執行階段模型強制執行。

冥想計時器符合 mindfulness。契約:在 Info.plist 中宣告背景模式、請求 WKExtendedRuntimeSession、處理 delegate callback、計時器結束時結束工作階段。Apple 文件記載 mindfulness 工作階段上限為 1 小時,且系統得依溫度或電池壓力自行縮短。3

Return 上架的模式

模式始於 Info.plist 的宣告:4

<key>WKBackgroundModes</key>
<array>
    <string>mindfulness</string>
</array>

模式宣告是工作階段類型生效的關鍵。少了它,呼叫 WKExtendedRuntimeSession().start() 會無聲地失敗,App 在放下手腕時會像完全沒有背景模式的 Watch App 一樣被暫停。

工作階段管理器本身必須存活於 App 範圍。SwiftUI 的 view 生命週期對長壽的有狀態物件並不友善:@StateObject@State 的範圍限定在擁有它們的 view,而取代該 view 的導覽 push 會把狀態一併丟棄。一個 delegate 在工作階段中途被釋放的 WKExtendedRuntimeSession 不會崩潰;工作階段會持續執行,但 delegate callback(willExpiredidInvalidateWith)會找上一個已被釋放的物件,這意味著清理永遠不會發生,這意味著下一次 startSession() 呼叫會以為沒有作用中工作階段,然後啟動一個重複的工作階段。

上架的模式是 App 範圍的單例。下方程式片段是結構雛形;正式版本會在每個方法內部加入 logging 以利觀測:

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 實例透過靜態儲存而在 Watch App 行程的生命週期內被保留;ARC 不會釋放它。 App 層級綁定買到的並非額外的保留,而是穩定的觀測點。這預防的 bug 樣態是:工作階段管理器只被一個會在工作階段中途被 pop 的暫時性 view 持有,view 死亡而 static let shared 仍存活,副作用是任何以 @StateObject 包裝的管理器會失去觀測週期,不再正確重新渲染。請使用單例搭配 App 層級的 @Observable 存取器,讓 UI 持續觀測那個正典實例。

session 屬性是防止重複工作階段的保護機制。 一個帶有「重新開始」按鈕的計時器可能從多條路徑呼叫 startSession()guard session == nil 檢查就是那把鎖。兩個並行的延伸工作階段會造成不可預測的行為:有時第二個成功而第一個變成孤兒,有時 start 呼叫會無聲失敗。單一工作階段不變式可預防整類問題。

Delegate callback 會記錄 log 但鮮少採取行動。 didStart callback 在每個工作階段觸發一次,是觀測上的實用 hook;willExpire callback 在系統強制 invalidate 之前觸發,是 Apple 範例期望 App「在工作階段結束前完成並清理任何任務」的所在;didInvalidateWith callback 則是工作階段參考被清除的所在,好讓下一次 startSession() 呼叫得以運作。上架的模式是由 callback 更新狀態、由狀態機執行工作,而非由 callback 直接執行工作

計時器管理器在每個會改變計時器是否實際在計數的轉變點上呼叫工作階段管理器:

@Observable final class WatchTimerManager {
    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 下授予的執行時間預算;從暫停恢復時會重新取得一個全新的工作階段。產品代價是手腕放下時暫停的計時器無法只靠舉起手腕恢復;使用者必須將 App 重新帶回前景才能恢復。產品收益是暫停計時器的電池成本歸零,且系統不會看到一個過時的工作階段。

放下手腕才是真正的測試

watchOS 在模擬器上的測試只是禮貌性的虛構。模擬器不會像真正的 Apple Watch 那樣強制執行放下手腕的執行階段模型。只要模擬器視窗保有焦點,模擬器就會把 App 維持在前景;在模擬器中,一個延伸執行階段工作階段看起來與完全沒有工作階段毫無二致,因為前景 App 兩種情況下都會繼續執行。

真正的測試在實機 Apple Watch 上:5

  1. 啟動計時器。
  2. 放下手腕(或按下側邊按鈕鎖定螢幕)。
  3. 等待 30 秒。
  4. 重新舉起手腕。

沒有作用中延伸執行階段工作階段時,Watch App 會被暫停;計時器狀態凍結在放下手腕的瞬間,並從該凍結狀態恢復。對於一段讓使用者閉上雙眼的 5 分鐘冥想而言,這個 bug 在計時器誤差等於閉眼時間之前是看不見的。

有作用中延伸執行階段工作階段時,計時器會持續計數。舉起手腕時會看到計時器處於正確的經過時間位置。音訊提示(若計時器在完成時會播放)會在正確的牆上時間觸發,而不是舉起手腕的時間。

放下手腕的情境正是 Return 第一版 Watch 建置上架時帶有的 bug,由單例重構修復。修復方式就是上述的單例模式;bug 的成因是一個 WatchSessionManager 實例被一個會在導覽 push 時釋放的 SwiftUI view 持有。技術上,工作階段在系統端仍在執行,但 delegate 已被釋放;下一次 session-start 呼叫無聲地變成了無作用,因為管理器的 session 屬性是設定在一個現在已死的物件上。實機測試在數秒內就會浮現失敗。模擬器測試永遠不會。

Delegate Callback 實際告訴您什麼

WKExtendedRuntimeSessionInvalidationReason 列舉了工作階段結束的各種方式:6

原因 何時發生
none 工作階段被 App 呼叫 invalidate() 明確 invalidate
sessionInProgress 同類型的工作階段已在執行
expired 達到系統設定的時間上限
resignedFrontmost 工作階段執行期間另一款 App 成為前景
suppressedBySystem 系統壓制了工作階段(低電量、溫度壓力)
error 發生無法復原的錯誤;請檢查 error 參數

對產品設計而言重要的原因:

expired 表示達到系統設定的時間上限。 Apple 文件記載 mindfulness 工作階段上限為 1 小時;3 Return 最長的冥想時長為 60 分鐘,正是文件記載的上限。一段 90 分鐘的冥想無法在單一 mindfulness 工作階段內完成:計時器會在中途的 1 小時時點死亡。產品決策是將可用時長限制在執行階段模型文件所能交付的範圍內,而不是賭系統的容忍度。

resignedFrontmost 表示使用者開啟了另一款 Watch App 而您的工作階段輸了。 Watch 使用者擅長滑到另一款 App 然後忘記。產品決策是要嘛 pause-on-resign(保留狀態,使用者可回來),要嘛 end-on-resign(工作階段結束,使用者收到「您提早停止」訊號)。Return 選擇 pause-on-resign,讓使用者可在冥想中途接電話後再回來。

suppressedBySystem 是「手錶過熱」的禮貌說法。 處於溫度壓力或低電量的 watchOS 裝置可能會撤回延伸執行階段工作階段,即使 App 並未誤用。工作階段管理器必須優雅地處理這種情況:清除參考、提示非阻塞性警告,且不要進入會嘗試重啟系統剛拒絕的工作階段的狀態。

willExpire callback 會在工作階段即將過期時觸發;Apple 範例將其定位為「在工作階段結束前完成並清理任何任務」的時刻。3 此 callback 是 App 可寫入最終狀態快照、播放結束音訊提示或呈現「工作階段即將結束」UI 的所在。Return 目前僅記錄此 callback 的 log;更豐富的清理(HealthKit 紀錄寫入、音訊淡出)發生於計時器的重設與完成路徑,並列入 willExpire 視窗的我會做不同設計清單。

我會做不同設計的部分

如果 Return 從零開始,有兩件事我會這麼做。

對於任何因 HealthKit 整合而提升價值的工作階段,使用 HKWorkoutSession 冥想計時器位於 mindfulnessworkout-processing 之間的邊界。Mindfulness 在 v1 是正確選擇,因為資料模型較簡單,且使用者預期是「這是冥想,不是健身」。HKWorkoutSession 帶有更細緻的 HealthKit 整合(工作階段開始、結束、區段、事件),並提供更豐富的 LiveWorkoutBuilder 介面以累積資料。這是架構判斷,並非 Apple 文件保證:對於價值取決於詳細工作階段遙測的 App 而言,workout-session 路徑能處理 WKExtendedRuntimeSession 無法處理的結構。

從第一天起就加入工作階段狀態的可觀測介面。 Return 第一版將工作階段事件記錄至 console。第二版加入了裝置上的工作階段狀態可見性以利除錯。第三版會公開一個開發者模式切換,在出問題時把工作階段原因歷程顯示給使用者,而不是把工作階段 invalidation 視為黑箱。watchOS 執行階段是不透明的;除錯介面必須加以彌補。

WKExtendedRuntimeSession 何時是錯誤答案

工作階段類型不適用的三種情況:

需要區段標記、心率串流或活動卡路里追蹤的健身。 直接使用 HKWorkoutSession 搭配 HKLiveWorkoutBuilder。Workout API 是 Apple 為實際健身(以及步行冥想或激烈活動)所記載的路徑;WKExtendedRuntimeSession 是為非健身工作階段(如正念或鬧鐘)所記載的路徑。冥想 App 不需要健身;Couch-to-5K App 才需要。

需要 Now Playing 介面的音訊播放。 使用為播放設定的 AVAudioSession,搭配 watchOS 音訊工作階段授權;Now Playing 整合加上系統播放介面正是音訊 App 想要的,而音訊路徑與 WKExtendedRuntimeSession 完全分離。WKExtendedRuntimeSession 不會給您 Now Playing 或系統音訊路由。

使用者無感的長時間資料同步。 使用 WKApplicationRefreshBackgroundTask 處理由系統排程的週期性更新視窗。使用者並不在 App 內;App 不需要持續執行;它只需要短暫地醒來並更新。背景任務與延伸執行階段工作階段這兩種模型滿足的是非常不同的需求。

此模式對於上架 watchOS 11+ 的 App 意義為何

三項要點。

  1. Watch 執行階段模型是 opt-in。挑一個工作階段類型,並在其規則內活動。 試圖在 watchOS 上做「一般背景工作」的 App 會輸。挑選 mindfulnessworkout-processingself-carephysical-therapyalarmunderwater-depth,並圍繞您所選工作階段類型隨附的執行時間預算來設計使用者體驗。

  2. 工作階段 delegate 必須存活於 App 範圍。SwiftUI 的 view 生命週期不會保護長壽的有狀態物件。@main App 層級綁定的 static let shared 單例,是能夠在導覽 push、view 替換以及 SwiftUI 正常釋放行為下存活的最小模式。

  3. 在實機上測試。模擬器不會強制執行放下手腕的執行階段模型。 Watch App 在模擬器中無法測試的 bug,正是它上架交給使用者的 bug。

請將本文與我先前針對同類 App 的撰文一起閱讀:跨平台的 SwiftUI 上架(Return 上架於 iPhone、iPad、Watch、Mac 與 Apple TV);Live Activities 狀態機(同款計時器的 iOS 端介面);HealthKit 模式(Watch 的 mindfulness 工作階段在使用者 Health 資料中的落點)。完整系列收錄於 Apple Ecosystem Series hub。若想了解更廣泛的 iOS 結合 AI agent 情境,請參閱 iOS Agent Development guide

FAQ

什麼是 watchOS 延伸執行階段工作階段?

watchOS 延伸執行階段工作階段(WKExtendedRuntimeSession)是 Watch App 用來在使用者放下手腕後持續執行的 API。工作階段必須透過 Info.plist 中的 WKBackgroundModes 宣告類型(mindfulness、workout-processing、alarm 等)。沒有作用中的延伸工作階段時,watchOS 會在放下手腕後不久暫停 App。

為什麼我的 watchOS 計時器在使用者放下手腕時就停止計數?

除非有支援類型的作用中 WKExtendedRuntimeSession 在執行,Watch App 會在放下手腕後不久被暫停。沒有啟動此類工作階段的計時器管理器,會看到背景執行時間被切斷,計時器狀態會凍結在放下手腕的瞬間,直到使用者再次舉起手腕。

WKExtendedRuntimeSessionHKWorkoutSession 有什麼差別?

WKExtendedRuntimeSession 是針對 mindfulness、alarm 或 self-care 等非健身工作階段的通用延伸執行階段 API。HKWorkoutSession 是針對實際健身的 API;它與 HealthKit 整合、支援區段標記,且是步行冥想或激烈活動的文件記載路徑。沒有健身級遙測的正念 App 使用前者;健身 App 則使用後者。

系統可以撤回我的延伸執行階段工作階段嗎?

可以。WKExtendedRuntimeSessionInvalidationReason 包含 expired(達到系統時間上限)、resignedFrontmost(另一款 Watch App 成為前景)以及 suppressedBySystem(低電量或溫度壓力)。工作階段管理器必須妥善處理每一種情況:參考被清除、計時器狀態做出適當反應,且下一次工作階段啟動呼叫能正確運作。

在 SwiftUI watchOS App 中,工作階段管理器應放在哪裡?

放在 App 範圍,作為從 @main App struct 綁定的單例。SwiftUI 的 view 範圍狀態(@State@StateObject)會在導覽 push、view 替換或 App 進入背景時被釋放。一個由 view 擁有、在工作階段中途被釋放的 session delegate,會造成工作階段參考外洩,並阻止後續工作階段乾淨啟動。

References


  1. 作者的 Return,一款 SwiftUI 冥想計時器,於 2026 年 4 月 21 日上架 App Store,可於 iPhone、iPad、Mac、Apple Watch 與 Apple TV 使用。Watch App 採用 WKExtendedRuntimeSession 搭配 mindfulness 背景模式作為週期計時器的執行階段。 

  2. Apple Developer,“About the background execution sequence”。iOS 端背景執行時間的便利機制(音訊工作階段、位置、BGTaskScheduler)以及它們與 watchOS 的差異。 

  3. Apple Developer,“WKExtendedRuntimeSession”。工作階段類型、生命週期、delegate callback、執行階段限制以及 WKBackgroundModes Info.plist 鍵。 

  4. Apple Developer,“Information Property List: WKBackgroundModes”。所支援的工作階段類型字串:workout-processingmindfulnessself-carephysical-therapyalarmunderwater-depth。 

  5. Apple Developer,“Building a watchOS app” 以及 WatchKit 測試指引。實機執行階段行為在 watchOS 模擬器上無法重現;模擬器不會強制執行放下手腕後的暫停。 

  6. Apple Developer,“WKExtendedRuntimeSessionInvalidationReason”。列舉案例:nonesessionInProgressexpiredresignedFrontmostsuppressedBySystemerror。 

相關文章

iOS 26 上的 HealthKit + SwiftUI:授權、樣本類型,以及兩款上架 App 的跨平台模式

來自 Water(飲水追蹤、HKQuantitySample)與 Return(正念冥想、HKCategorySample)的真實生產級模式。權限體驗、async 包裝、watchOS 變體,以及務必避開的陷阱。

5 分鐘閱讀

SwiftUI 的構成元素

SwiftUI 是建構在值型別 View 樹之上的 result-builder DSL。一旦看清底層基礎,AnyView、Group 與 ViewBuilder 便不再神祕。

5 分鐘閱讀

清理層才是真正的 AI 代理市場

Charlie Labs 從建構代理轉向清理代理留下的爛攤子。AI 代理市場正從生成轉向證明。清理才是耐久的那一層。

2 分鐘閱讀