← すべての記事

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

ジャンル: shipped-code。本記事は、Returnが本番環境で採用しているwatchOSランタイムパターンを記録したものです。ReturnはApp Storeで公開されているSwiftUI製の瞑想タイマーで、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です。セッションはタイプを宣言する必要があり、瞑想タイマーの場合はInfo.plistWKBackgroundModes: mindfulnessによって暗黙的に指定されます。
  • セッションマネージャーはビュースコープではなく、アプリスコープに置く必要があります。SwiftUIのビューライフサイクルはナビゲーションでビュー所有のオブジェクトを解放してしまい、解放されたセッションdelegateは死んだセッションです。たとえセッション自体は動いていても、です。
  • WKExtendedRuntimeSessionDelegateのコールバックが契約そのものです:didStartwillExpiredidInvalidateWith。expireコールバックはシステムが強制無効化する前に発火し、Appleはこれを「終了とクリーンアップを行うウィンドウ」と説明しています。
  • アクティブな拡張セッションがない状態での手首ドロップはタイマーを停止させます。アクティブな拡張セッションがある状態での手首ドロップはタイマーを継続させます。このセッションこそが「出荷可能な製品」と「2回目の使用で壊れるもの」の分かれ目です。

watchOSがiOS流に解決しないバックグラウンド問題

iOSアプリは、画面オフでもアプリを動かし続けたいときに、いくつかのバックグラウンド機構に手を伸ばします:2

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

これらはどれも、想像するような形ではwatchOSでは役に立ちません。Watchアプリには同じバックグラウンドタスクスケジューラはありません。無音中もタイマーをカウントし続けるバックグラウンドAVAudioSession.playbackモードもありません。「ユーザーが手首を下ろしても動かし続けたい」という用途に対する構造的プリミティブは1つだけ、宣言されたセッションタイプを伴うWKExtendedRuntimeSessionです。3

AppleがWKBackgroundModesを通じてサポートするセッションタイプは、意図的に絞られています:4

  • workout-processing(実際のワークアウトにはHKWorkoutSessionを併用)
  • mindfulness(瞑想タイマーや呼吸エクササイズ向け)
  • self-care(ガイド付きルーチン向け)
  • physical-therapy(理学療法セッションアプリ向け)
  • alarm(時刻ベースの起床アラーム向け)
  • underwater-depth(ダイビングや水深トラッキングアプリ向け)

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

瞑想タイマーはmindfulnessに当てはまります。契約は次の通りです:Info.plistでバックグラウンドモードを宣言し、WKExtendedRuntimeSessionをリクエストし、delegateコールバックを処理し、タイマー終了時にセッションを終了する。システムはセッション1回あたりおおよそ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に加えて、@main Appレベルで@State private var sessionManager = WatchSessionManager.sharedとしてバインドすることで、Watchアプリプロセスのライフタイム全体にわたってマネージャーを生かし続けます。 SwiftUIはビューが保持しているだけではシングルトンを保持しません。上記のバインディングこそが、ランタイムに参照を保持するよう伝えるものです。Appレベルのバインディングがないと、どのビューもマネージャーを保持していない瞬間にARCがそれを破棄してしまう可能性があります。

sessionプロパティが重複セッションへの防衛策です。 「最初からやり直す」ボタンを持つタイマーは、複数の経路からstartSession()を呼ぶ可能性があります。guard session == nilのチェックがロックの役割を果たします。2つの拡張セッションが同時に走ると予測不能な挙動になります:2つ目が成功して1つ目がオーファンになる場合もあれば、startの呼び出しが静かに失敗する場合もあります。シングルセッション不変条件はこのクラスの問題をすべて防ぎます。

delegateコールバックはログを取るだけで、ほとんど動作しません。 didStartコールバックはセッションごとに1回発火し、オブザーバビリティに有用なフックです。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でパッチを当てたバグでした。修正は上記のシングルトンパターンです。バグは、WatchSessionManagerのインスタンスをSwiftUIのビューが保持しており、ナビゲーションプッシュで解放されてしまったことでした。セッションは技術的にはシステム側で動き続けていましたが、delegateは解放されていました。次のセッション開始呼び出しは静かにno-opになりました。なぜならマネージャーのsessionプロパティが、すでに死んだオブジェクト上にセットされていたからです。実機テストはこの失敗を数秒で表面化させます。シミュレータテストは決して表面化させません。

delegateコールバックが実際に教えてくれること

WKExtendedRuntimeSessionInvalidationReasonは、セッションが終わる経路を列挙しています:6

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

製品設計上、特に重要な理由はこれらです:

expiredは、ユーザーが要求したフルセッションを得たことを意味します。 セッションは自然な終わりまで走りました。Returnの最長瞑想時間は60分で、これはmindfulnessセッションが通常許可される範囲のぎりぎりです。90分の瞑想だと日常的にexpiredに当たり、タイマーがセッション中に死ぬでしょう。製品上の判断は、利用可能な時間をランタイムモデルが実際に提供できる範囲に制限することです。

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

suppressedBySystemは、「watchが熱い」の丁寧版です。 熱の圧迫やバッテリー残量低下下のwatchOSデバイスは、アプリの誤用がなくても拡張ランタイムセッションを取り消すことがあります。セッションマネージャーは、このケースをグレースフルに処理する必要があります:参照をクリアし、ノンブロッキングな警告を表示し、システムが拒否したばかりのセッションを再起動しようとする状態に入らないこと。

willExpireコールバックはセッションがまもなく期限切れになるときに発火し、ドキュメントではアプリが「終了とクリーンアップ」を行う瞬間とされています。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のオーディオセッションエンタイトルメントとともにplayback用に設定されたAVAudioSessionを使ってください。Now Playing統合とシステム再生面の組み合わせがオーディオアプリの求めるものであり、オーディオパスはWKExtendedRuntimeSessionとは完全に分離しています。WKExtendedRuntimeSessionはNow Playingやシステムオーディオルーティングを提供しません。

ユーザーが認識しない長時間のデータ同期。 システムがスケジュールする定期的な更新ウィンドウにはWKApplicationRefreshBackgroundTaskを使ってください。ユーザーはアプリにいません。アプリは動き続ける必要はなく、短時間目覚めて更新する必要があるだけです。バックグラウンドタスクと拡張ランタイムセッションの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ハブにあります。AIエージェントを使ったiOS開発のより広い文脈については、iOS Agent Developmentガイドを参照してください。

FAQ

watchOSの拡張ランタイムセッションとは何ですか?

watchOSの拡張ランタイムセッション(WKExtendedRuntimeSession)は、ユーザーが手首を下ろした後もWatchアプリを動かし続けるために使うAPIです。セッションはInfo.plistWKBackgroundModesを通じてタイプ(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)は、ナビゲーションプッシュ、ビュー置き換え、アプリのバックグラウンド化で解放されます。セッション中に解放されるビュー所有のセッションdelegateは、セッション参照のリークを引き起こし、その後のセッションがクリーンに開始するのを妨げます。

References


  1. 著者のReturnは、2026年4月21日にApp Storeで公開されたSwiftUI製の瞑想タイマーで、iPhone、iPad、Mac、Apple Watch、Apple TVで利用可能です。Watchアプリはサイクルタイマーのランタイムにmindfulnessバックグラウンドモードを伴うWKExtendedRuntimeSessionを使用しています。 

  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 分で読める