← 所有文章

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 的回呼即為契約:didStartwillExpiredidInvalidateWith。expire 回呼會在系統強制使會話失效之前觸發;Apple 將其描述為應用程式「結束並清理」的時機。
  • 沒有作用中擴充會話時,手腕放下會暫停計時器;有作用中擴充會話時,手腕放下計時器會繼續。會話的存在與否,正是「成功上架的產品」與「第二次使用就壞掉」的差別。

watchOS 並未以 iOS 方式解決的背景問題

當 iOS 應用程式需要在螢幕關閉時繼續執行時,會借助多種背景機制:2

  • 採用 .playback 類別的 AVAudioSession,可在播放音樂時讓音訊應用程式持續運作。
  • CLLocationManager 的背景更新,配合藍色狀態列,可讓導航應用程式持續運作。
  • BGTaskScheduler 將短期維護工作排入佇列,由系統依自身排程執行。
  • 前景 UI 擴充功能(Live Activity、CallKit、PushKit)將應用程式行程橋接至系統控制的呈現介面。

上述機制在 watchOS 上並不會以您預期的方式發揮作用。Watch 應用程式沒有相同的背景任務排程器;沒有可在靜音時讓計時器持續計數的背景 AVAudioSession.playback 模式。對於「我希望使用者放下手腕後仍能繼續執行」這個需求,watchOS 只有一個結構性原語,那就是搭配宣告會話類型的 WKExtendedRuntimeSession3

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 回呼(willExpiredidInvalidateWith)會抵達一個已被釋放的物件,這代表清理工作永遠不會發生,下一次 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

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

沒有作用中擴充執行時會話時,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 冥想計時器處於 mindfulnessworkout-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+ 上架應用程式的意義

三項要點。

  1. Watch 執行時模型是選擇加入的。挑一個會話類型,並在其規則內生活。 嘗試在 watchOS 上做「通用背景工作」的應用程式必敗。請從 mindfulnessworkout-processingself-carephysical-therapyalarmunderwater-depth 中挑選,並依您所選會話類型附帶的執行時預算來設計使用者體驗。

  2. 會話 delegate 必須存活於應用程式層級。SwiftUI 的檢視生命週期無法保護長期存活的有狀態物件。@main App 層級繫結的 static let shared 單例,是能在導航 push、檢視取代與 SwiftUI 一般釋放行為下存活的最簡模式。

  3. 在實機上測試。模擬器不會強制執行抬腕放下的執行時模型。 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 應用程式會在手腕放下後不久被暫停。沒有啟動此類會話的計時器管理器,背景執行時間會被切斷,計時器狀態會凍結於手腕放下的那一刻,直到使用者再次抬腕為止。

WKExtendedRuntimeSessionHKWorkoutSession 有何差異?

WKExtendedRuntimeSession 是用於非健身會話(如正念、鬧鐘或自我照護)的通用擴充執行時 API。HKWorkoutSession 是用於實際健身的 API;它整合 HealthKit、支援區段標記,是步行冥想或劇烈活動的官方記載路徑。沒有健身等級遙測需求的正念應用程式使用前者;健身應用程式使用後者。

系統可以撤銷我的擴充執行時會話嗎?

可以。WKExtendedRuntimeSessionInvalidationReason 包含 expired(達到系統時間上限)、resignedFrontmost(另一個 Watch 應用程式成為前景)與 suppressedBySystem(低電量或熱量壓力)。會話管理器必須妥善處理每一種:清除參照、計時器狀態做出適當反應,下一次會話啟動呼叫也能正常運作。

在 SwiftUI 的 watchOS 應用程式中,會話管理器應該存活於哪裡?

存活於應用程式層級,作為從 @main App 結構繫結的單例。SwiftUI 檢視層級的狀態(@State@StateObject)會在導航 push、檢視取代或應用程式進入背景時被釋放。在會話進行中被釋放、由檢視擁有的會話 delegate,會導致會話參照外洩,並使後續會話無法乾淨啟動。

參考資料


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

  2. Apple Developer,「About the background execution sequence」。iOS 端的背景執行時機制(音訊會話、位置、BGTaskScheduler),以及它們與 watchOS 的差異。 

  3. Apple Developer,「WKExtendedRuntimeSession」。會話類型、生命週期、delegate 回呼、執行時上限,以及 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。 

相關文章

HealthKit + SwiftUI on iOS 26: Authorization, Sample Types, and Cross-Platform Patterns

Real production patterns from Water (water tracking, HKQuantitySample) and Return (mindful sessions, HKCategorySample). …

17 分鐘閱讀

What SwiftUI Is Made Of

SwiftUI is a result-builder DSL on top of a value-typed View tree. Once the substrate is visible, AnyView, Group, and Vi…

17 分鐘閱讀

The Cleanup Layer Is the Real AI Agent Market

Charlie Labs pivoted from building agents to cleaning up after them. The AI agent market is moving from generation to pr…

15 分鐘閱讀