← すべての記事

watchOSのランタイムは契約であり、バックグラウンドタスクではない

ReturnのWatchアプリは、ユーザーが手首を下ろしてもカウントを続ける必要があるマルチサイクルの瞑想タイマーを実行しています。1 この制約に耐えるパターンが、WKExtendedRuntimeSessionとアプリスコープのグローバルなdelegateの組み合わせです。これ以外のものはすべて、watchがスリープした瞬間に死にます。

watchOSは画面が小さいだけのiOSではありません。ランタイムモデルが異なります。iOSはアプリに豊富なフォアグラウンドの予算と、オーディオセッション、位置情報の更新、BGTaskScheduler、その他いくつかのアフォーダンスを通じて、減少していくとはいえ実用的なバックグラウンドランタイムを提供します。2 watchOSがフォアグラウンドアプリに与えるのは、手首を下ろしてから秒単位で測られる予算であり、その後はシステムとランタイム契約を結んでいない限りアプリは停止されます。「ちょっとバックグラウンドで何かをしている」というアフォーダンスは存在しません。「ワークアウト、マインドフルネスセッション、スマートアラーム、ナビゲーションルート、ヘルスモニタリングタスクを実行している」かどうか、それ以外は何もありません。3

ReturnのWatchターゲットはマインドフルネスタイマーです。セッション契約はWKBackgroundModes: mindfulness。ランタイムのAPIはWKExtendedRuntimeSessionです。Watchアプリを手首を下ろすと壊れる状態から25分間の瞑想を耐え抜く状態へと変えたパターンが、本記事で説明する内容です。

TL;DR

  • watchOSにはiOS型のバックグラウンドはありません。フォアグラウンドのランタイムは手首を下ろすとすぐに終了し、登録されたセッションタイプのみが実行を継続します。
  • WKExtendedRuntimeSessionがランタイムのAPIです。Appleがサポートするセッションタイプは4種類あります。self-caremindfulnessphysical-therapyalarmです。瞑想タイマーの場合、セッションタイプはmindfulnessであり、Info.plistWKBackgroundModesで宣言します。
  • セッションマネージャーはビュースコープではなくアプリスコープに置く必要があります。SwiftUIのビューライフサイクルは、ナビゲーション時にビュー所有のオブジェクトを解放します。解放されたセッションdelegateは、セッション自体がまだ動いていても、死んだセッションです。
  • WKExtendedRuntimeSessionDelegateのコールバックが契約です。didStartwillExpiredidInvalidateWithの3つです。expireコールバックはシステムが強制的に無効化する前に発火します。Appleの「Using extended runtime sessions」のサンプルコードでは、これを「セッションが終了する前にタスクを完了し、クリーンアップする」場所として位置付けています。3
  • アクティブなextendedセッションがない状態で手首を下ろすと、タイマーは一時停止します。アクティブなextendedセッションがある状態で手首を下ろすと、タイマーは継続します。このセッションが「製品として出荷できる」と「2回目の使用で壊れる」の違いです。

watchOSがiOS方式では解決しないバックグラウンド問題

iOSアプリは画面オフでアプリを動かし続ける必要がある場合、いくつかのバックグラウンドアフォーダンスを利用します。2

  • .playbackカテゴリのAVAudioSessionは、音楽再生中にオーディオアプリを生かし続けます。
  • CLLocationManagerのバックグラウンド更新は、青いバーとともにナビゲーションアプリを生かし続けます。
  • BGTaskSchedulerは、システムが独自のクロックでスケジュールする短期メンテナンス作業をキューに入れます。
  • フォアグラウンドUI拡張機能(Live Activity、CallKit、PushKit)は、アプリプロセスをシステム制御のレンダリングサーフェスにブリッジします。

これらはwatchOSでは想定したようには役立ちません。Watchアプリには同じバックグラウンドタスクスケジューラはありません。タイマーを無音でカウントし続けるバックグラウンド化されたAVAudioSession.playbackモードもありません。「ユーザーが手首を下ろした後も実行を続けたい」ための構造的なプリミティブはひとつだけで、それがWKExtendedRuntimeSessionと宣言済みのセッションタイプです。3

AppleがWKExtendedRuntimeSessionでサポートするセッションタイプは、意図的に絞り込まれています。3

  • self-care(短時間のウェルビーイング活動、フロントモストランタイム、10分制限)
  • mindfulness(無音瞑想、フロントモストランタイム、1時間制限)
  • physical-therapy(ストレッチや可動域訓練、バックグラウンドランタイム、1時間制限)
  • alarm(スマートアラーム、バックグラウンドランタイム、30分制限、start(at:)を介して最大36時間先までスケジュール可能)

ワークアウトアプリはHKWorkoutSessionと別個のworkout-processingバックグラウンドモードを使用します。このパスは実際のワークアウト用にドキュメント化されており、WKExtendedRuntimeSessionのタイプではありません。4 underwater-depthバックグラウンドモードは、ダイビングおよび水深トラッキングアプリ向けに、ダイブセッションのAPIパスを介してサポートされます。これもWKExtendedRuntimeSession経由ではありません。アプリはworkout-processingを1つのextended-runtimeセッションタイプと組み合わせることはできますが、アプリごとに複数のextended-runtimeタイプを選ぶことはできません。4

これらのカテゴリのいずれにも当てはまらないアプリは、手首を下ろした後の実行にWKExtendedRuntimeSessionを使用できません。オーディオアプリは別のコードパスでAVAudioSession.Category.playbackオーディオセッションカテゴリとNow Playing統合を利用します。ナビゲーションアプリはCLLocationManagerのバックグラウンド更新を使用します。Watchは汎用コンピュータではなく、ランタイムモデルがバッテリー制約を強制するデバイスなのです。

瞑想タイマーはmindfulnessに当てはまります。契約は次のとおりです。Info.plistでバックグラウンドモードを宣言し、WKExtendedRuntimeSessionを要求し、delegateコールバックを処理し、タイマー終了時にセッションを終了します。Appleはマインドフルネスセッションの制限を1時間とドキュメント化しており、サーマルやバッテリーの圧力下ではシステムの裁量で短縮される可能性があるとしています。3

Returnで採用しているパターン

パターンはInfo.plistの宣言から始まります。4

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

このモード宣言があってこそ、セッションタイプが有効になります。これがないと、WKExtendedRuntimeSession().start()の呼び出しはサイレントに失敗し、バックグラウンドモードのないWatchアプリと同じく、手首を下ろした瞬間にアプリは停止します。

セッションマネージャー自体はアプリスコープに置く必要があります。SwiftUIのビューライフサイクルは、長期間生存するステートフルオブジェクトには不向きです。@StateObject@Stateはそれらを所有するビューにスコープされており、ビューを置き換えるナビゲーションプッシュは状態もろとも捨てます。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
    }
}

プロトコル準拠以外に、3つの構造的な詳細が重要です。

static let sharedインスタンスは、静的ストレージを通じてWatchアプリのプロセスライフタイム全体で保持されます。ARCはこれを解放しません。 Appレベルのバインディングが買うのは、追加の保持ではなく、安定した観測ポイントです。これが防ぐバグパターン。セッション中にポップされる一時的なビューだけが保持するセッションマネージャーで、ビューは死んでもstatic let sharedは生き残るが、その副作用として@StateObjectでラップされたマネージャーが観測サイクルを失い、正しく再描画しなくなる、というものです。シングルトンとAppレベルでの@Observableアクセサを組み合わせて、UIが正規のインスタンスを観測し続けるようにします。

sessionプロパティが重複セッションに対する保護です。「やり直し」ボタンのあるタイマーは、複数のパスからstartSession()を呼び出す可能性があります。guard session == nilチェックがそのロックです。2つの並行するextendedセッションは予測不能な動作を引き起こします。2つ目が成功して1つ目が孤立することもあれば、開始呼び出しがサイレントに失敗することもあります。シングルセッション不変条件がこのクラス全体を防ぎます。

delegateコールバックはログを取りますが、めったに動作しません。 didStartコールバックはセッションごとに1回発火し、観測性のための便利なフックです。willExpireコールバックはシステムが強制的に無効化する前に発火し、Appleのサンプルではアプリが「セッションが終了する前にタスクを完了し、クリーンアップする」ことを期待している場所です。didInvalidateWithコールバックは、次のstartSession()呼び出しが機能するようにセッション参照をクリアする場所です。出荷したパターンは「コールバックが直接作業を行う」ではなく「コールバックは状態を更新し、状態マシンが作業を行う」です。

タイマーマネージャーは、タイマーが実際にカウントしているかどうかが変わるすべての遷移時にセッションマネージャーを呼び出します。

@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の下でシステムが付与するランタイム予算を主張し続ける必要はありません。一時停止からの再開で新しいセッションを再取得します。プロダクトのコストは、手首を下ろした状態で一時停止したセッションは手首を上げるだけでは再開できないことです。ユーザーは再開のためにアプリをフォアグラウンドに戻す必要があります。プロダクトの利点は、一時停止時のバッテリー消費がゼロになり、システムが古くなったセッションを認識しなくなることです。

手首を下ろすことがテストである

シミュレータでのwatchOSテストは丁寧なフィクションです。シミュレータは、実際のApple Watchのようには手首を下ろした際のランタイムモデルを強制しません。シミュレータは、シミュレータウィンドウにフォーカスがある限りアプリをフォアグラウンドに保ちます。シミュレータでのextended runtime sessionは、セッションがない場合と見分けがつきません。フォアグラウンドアプリはどちらの場合でも実行を続けるからです。

実際のテストは実機のApple Watchで行います。5

  1. タイマーを起動する。
  2. 手首を下ろす(またはサイドボタンを押して画面をロックする)。
  3. 30秒待つ。
  4. 手首を上げて戻す。

アクティブなextended runtime sessionがないと、Watchアプリは停止し、タイマー状態は手首を下ろした瞬間で凍結し、その凍結状態から再開します。ユーザーが目を閉じている5分間の瞑想では、このバグはタイマーが目を閉じていた時間分だけずれるまで見えません。

アクティブなextended runtime sessionがあれば、タイマーはカウントを続けます。手首を上げると、タイマーが正しい経過時刻にあることが分かります。オーディオキュー(タイマーが完了時に再生する場合)は、手首を上げた時刻ではなく、正しい実時間に発火します。

手首を下ろすシナリオは、Returnの最初のWatchビルドが出荷したバグであり、シングルトンのリファクタリングで修正されました。修正は上記のシングルトンパターンです。バグは、ナビゲーションプッシュで解放されたSwiftUIビューが保持していたWatchSessionManagerインスタンスでした。セッションは技術的にはシステム側で動いていましたが、delegateは解放されていました。次のセッション開始呼び出しは、マネージャーのsessionプロパティが既に死んだオブジェクトに設定されていたため、サイレントにno-opになっていました。実機テストではこの失敗が数秒で表面化します。シミュレータテストでは決して表面化しません。

delegateコールバックが実際に伝えること

WKExtendedRuntimeSessionInvalidationReasonは、セッションが終了する方法を列挙しています。6

理由 発生するタイミング
none アプリがinvalidate()を呼び出して明示的に無効化された
sessionInProgress 同じタイプのセッションが既に実行中である
expired システムが課す時間制限に達した
resignedFrontmost セッション中に別のアプリがフロントモストになった
suppressedBySystem システムがセッションを抑制した(低電力、サーマル圧力)
error 回復不可能なエラーが発生した。errorパラメータを確認する

プロダクト設計に関係する理由は次のとおりです。

expiredはシステムが課す時間制限に達したことを意味します。 Appleはマインドフルネスセッションの制限を1時間とドキュメント化しています。3 Returnの最長瞑想時間は60分であり、これがドキュメント化された上限です。90分の瞑想は1つのマインドフルネスセッションでは完了できません。タイマーは1時間の時点でセッション中に停止するでしょう。プロダクトの判断は、システムの寛容さに賭けるのではなく、ランタイムモデルがドキュメント化された範囲内に利用可能な時間をキャップすることです。

resignedFrontmostは、ユーザーが別のWatchアプリを開いてセッションが負けたことを意味します。 Watchユーザーは別のアプリにスワイプしてそのまま忘れることが上手です。プロダクトの判断は、resign時に一時停止する(状態を保持し、ユーザーが戻ってこられる)か、resign時に終了する(セッション終了、ユーザーは「早く止めた」というシグナルを受け取る)かのどちらかです。Returnは、ユーザーが瞑想中に電話に出て戻ってこられるよう、resign時に一時停止を選択しています。

suppressedBySystemは、「watchが熱い」の丁寧なバージョンです。 サーマル圧力や低バッテリー下のwatchOSデバイスは、アプリの誤用がなくても、extended runtime sessionを取り消す可能性があります。セッションマネージャーは、このケースを優雅に処理する必要があります。参照をクリアし、ブロックしない警告を表示し、システムが拒否したばかりのセッションを再起動しようとする状態に入らないようにします。

willExpireコールバックは、セッションが間もなく失効する際に発火します。Appleのサンプルでは、これを「セッションが終了する前にタスクを完了し、クリーンアップする」瞬間として位置付けています。3 このコールバックは、アプリが最終状態のスナップショットを書き込んだり、終了オーディオキューを再生したり、「セッション終了間近」UIを表示したりできる場所です。今のReturnはコールバックをログに記録するだけです。より豊富なクリーンアップ(HealthKitログエントリー、オーディオフェードアウト)はタイマーのリセットおよび完了パスで行われており、willExpireウィンドウ向けのもし作り直すならリストに入っています。

もし作り直すなら

Returnをゼロから始めるなら2つです。

HealthKit統合で価値が増すセッションにはHKWorkoutSessionを使う。 瞑想タイマーはmindfulnessworkout-processingの境界に位置します。v1ではマインドフルネスが正しい選択でした。データモデルがよりシンプルで、ユーザーの期待は「これは瞑想であり、ワークアウトではない」だからです。HKWorkoutSessionはより細かいHealthKit統合(セッション開始、セッション終了、セグメント、イベント)を備え、データを蓄積するための豊富なLiveWorkoutBuilderインターフェースを提供します。Appleがドキュメント化した保証ではなく、アーキテクチャ上の判断としては、詳細なセッションテレメトリに価値が依存するアプリでは、ワークアウトセッションのルートがWKExtendedRuntimeSessionにはない構造を扱ってくれます。

初日からセッション状態の観測性サーフェスを追加する。 Returnの最初のバージョンはセッションイベントをコンソールにログ記録していました。2番目のバージョンはデバッグ用のオンデバイスのセッション状態可視化を追加しました。3番目のバージョンでは、何かがおかしくなったときにセッション理由の履歴をユーザーに表示する開発者モードのトグルを公開し、セッション無効化をブラックボックスとして扱わないようにします。watchOSのランタイムは不透明なので、デバッグサーフェスがそれを補う必要があります。

WKExtendedRuntimeSessionが間違った答えとなる場合

セッションタイプが合わない3つのケースです。

セグメントマーカー、心拍数ストリーム、アクティブカロリートラッキングを必要とするワークアウト。 HKWorkoutSessionHKLiveWorkoutBuilderとともに直接使用します。WorkoutのAPIは、実際のワークアウト(およびウォーキング瞑想や激しい活動)向けにAppleがドキュメント化したパスです。WKExtendedRuntimeSessionは、マインドフルネスやアラームのような非ワークアウトセッション向けにドキュメント化されたパスです。瞑想アプリにはワークアウトは必要ありません。Couch-to-5Kアプリには必要です。

Now Playingサーフェスを必要とするオーディオ再生。 watchOSのオーディオセッションエンタイトルメントとともに再生用に設定されたAVAudioSessionを使用します。Now Playing統合とシステム再生サーフェスは、オーディオアプリが求めるものであり、オーディオパスはWKExtendedRuntimeSessionとは完全に別個のものです。WKExtendedRuntimeSessionは、Now Playingやシステムオーディオルーティングを提供しません。

ユーザーが意識しない長期実行のデータ同期。 システムがスケジュールする定期的な更新ウィンドウにはWKApplicationRefreshBackgroundTaskを使用します。ユーザーはアプリ内にいません。アプリは実行を続ける必要はありません。短時間目を覚ましてリフレッシュする必要があります。バックグラウンドタスクとextended-runtime-sessionの2つのモデルは、非常に異なるニーズに対応します。

このパターンがwatchOS 11+で出荷するアプリにとって意味するもの

3つのポイントです。

  1. Watchのランタイムモデルはオプトインです。セッションタイプを選び、そのルールの中で生きてください。 watchOSで「汎用的なバックグラウンド作業」をしようとするアプリは負けます。mindfulnessworkout-processingself-carephysical-therapyalarmunderwater-depthのいずれかを選び、選択したセッションタイプに付随するランタイム予算を中心にユーザー体験を設計してください。

  2. セッションdelegateはアプリスコープに置く必要があります。SwiftUIのビューライフサイクルは、長期間生存するステートフルオブジェクトを保護しません。 @main Appレベルでバインドされたstatic let sharedシングルトンが、ナビゲーションプッシュ、ビュー置換、SwiftUIの通常の解放動作に耐える最小のパターンです。

  3. 実機でテストしてください。シミュレータは手首を下ろしたランタイムモデルを強制しません。 Watchアプリがシミュレータでテストできないバグは、ユーザーに出荷されるバグです。

この記事は、同じ系列のアプリに関する以前の記事と組み合わせてご覧ください。クロスプラットフォームのSwiftUI出荷(ReturnはiPhone、iPad、Watch、Mac、Apple TVで出荷)。Live Activitiesステートマシン(同じタイマーのiOS側のサーフェス)。HealthKitパターン(Watchのマインドフルネスセッションがユーザーのヘルスデータに着地する場所)。フルセットはApple Ecosystem Series hubにあります。AIエージェントを使ったiOSの広範な文脈については、iOS Agent Developmentガイドをご覧ください。

FAQ

watchOSのextended runtime sessionとは何ですか?

watchOSのextended runtime session(WKExtendedRuntimeSession)は、ユーザーが手首を下ろした後もWatchアプリが実行を続けるためのAPIです。セッションは、Info.plistWKBackgroundModesを通じてタイプ(mindfulness、workout-processing、alarmなど)を宣言する必要があります。アクティブなextendedセッションがないと、watchOSは手首を下ろした直後にアプリを停止します。

なぜユーザーが手首を下ろすとwatchOSのタイマーがカウントを止めるのですか?

サポートされているタイプのアクティブなWKExtendedRuntimeSessionが実行されていない限り、Watchアプリは手首を下ろすと間もなく停止します。そのようなセッションを開始しないタイマーマネージャーは、バックグラウンドランタイムが切断され、ユーザーが再び手首を上げるまでタイマー状態が手首を下ろした瞬間で凍結します。

WKExtendedRuntimeSessionHKWorkoutSessionの違いは何ですか?

WKExtendedRuntimeSessionは、マインドフルネス、アラーム、セルフケアのような非ワークアウトセッション向けの汎用extended runtimeのAPIです。HKWorkoutSessionは実際のワークアウト向けのAPIであり、HealthKitと統合し、セグメントマーカーをサポートし、ウォーキング瞑想や激しい活動向けにドキュメント化されたパスです。ワークアウトレベルのテレメトリを必要としないマインドフルネスアプリは前者を、ワークアウトアプリは後者を使用します。

システムが私のextended runtime sessionを取り消すことはありますか?

はい。WKExtendedRuntimeSessionInvalidationReasonには、expired(システム時間制限に達した)、resignedFrontmost(別のWatchアプリがフロントモストになった)、suppressedBySystem(低電力またはサーマル圧力)が含まれます。セッションマネージャーはそれぞれをクリーンに処理する必要があります。参照がクリアされ、タイマー状態が適切に反応し、次のセッション開始呼び出しが正しく機能するようにします。

SwiftUIのwatchOSアプリでセッションマネージャーはどこに置くべきですか?

アプリスコープに、@main App構造体からバインドされたシングルトンとして置きます。SwiftUIのビュースコープの状態(@State@StateObject)は、ナビゲーションプッシュ、ビュー置換、アプリのバックグラウンド化で解放されます。セッション中に解放されるビュー所有のセッションdelegateは、セッション参照のリークを引き起こし、後続のセッションがクリーンに開始するのを妨げます。

References


  1. 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. Watch app uses WKExtendedRuntimeSession with mindfulness background mode for cycle-timer runtime. 

  2. Apple Developer, “About the background execution sequence”. iOS-side background runtime affordances (audio sessions, location, BGTaskScheduler) and how they differ from watchOS. 

  3. Apple Developer, “WKExtendedRuntimeSession”. Session types, lifecycle, delegate callbacks, runtime limits, and the WKBackgroundModes Info.plist key. 

  4. Apple Developer, “Information Property List: WKBackgroundModes”. Supported session-type strings: workout-processing, mindfulness, self-care, physical-therapy, alarm, underwater-depth

  5. Apple Developer, “Building a watchOS app” and the WatchKit testing guidance. Real-device runtime behavior is not reproducible in the watchOS simulator; the simulator does not enforce wrist-drop suspension. 

  6. Apple Developer, “WKExtendedRuntimeSessionInvalidationReason”. Enumeration cases: none, sessionInProgress, expired, resignedFrontmost, suppressedBySystem, error

関連記事

iOS 26のHealthKit + SwiftUI:認可、サンプルタイプ、そして2つの実アプリから得たクロスプラットフォームパターン

Water(水分摂取トラッキング、HKQuantitySample)とReturn(マインドフルセッション、HKCategorySample)から得た本番運用パターン。許可UX、async ラッパー、watchOSバリアント、そして避けるべ…

4 分で読める

SwiftUIを構成するもの

SwiftUIは、値型のViewツリーの上に構築されたresult builder DSLです。基盤が見えれば、AnyView、Group、ViewBuilderはもう謎ではなくなります。

3 分で読める

クリーンアップレイヤーこそが本当のAIエージェント市場である

Charlie Labsはエージェント構築から、エージェントの後始末をする側へとピボットしました。AIエージェント市場は生成から証明へと移行しています。クリーンアップこそが永続的なレイヤーなのです。

2 分で読める