Apple Watch↔iPhone同期の設計と実装
PlayTimeにApple Watch対応を追加した際、iPhone↔Watch間のデータ同期で試行錯誤した。WatchConnectivity1フレームワークには複数の転送APIがあり、どれをどう使うかで設計が大きく変わる。
この記事では、実際にPlayTime開発で採用した同期設計と、そこに至るまでの知見をまとめる。
WatchConnectivity基礎
WCSessionのactivate
WCSession2のactivate()は、iPhone・Watch両方で呼ぶ必要がある。ただしタイミングは独立でよい。iPhoneが先にactivateして数時間後にWatchがactivateしても問題ない。
// AppDelegate or App init
func setupWatchConnectivity() {
guard WCSession.isSupported() else { return }
let session = WCSession.default
session.delegate = self
session.activate()
}
BLE3ペアリングはOS層で維持されている。アプリ層のactivate()はこのOS接続の上にセッションを確立するだけなので、呼ぶタイミングに神経質になる必要はない。
💡 activate()はAppDelegateのdidFinishLaunchingWithOptionsで呼ぶのが定番。SwiftUIの場合はApp構造体のinit()でもよい。重要なのはアプリ起動直後に呼ぶこと。
到達可能状態の検知
相手デバイスが通信可能かどうかはsessionReachabilityDidChangeで検知できる。
func sessionReachabilityDidChange(_ session: WCSession) {
if session.isReachable {
// 相手が到達可能 — sendMessageが使える状態
}
}
ただし、後述する設計ではリアルタイム通信(sendMessage)を使わないため、到達可能状態に依存しない。
データ転送API比較
WatchConnectivityには3つの主要な転送APIがある。
| API | 特性 | 保持 | オフライン耐性 |
|---|---|---|---|
updateApplicationContext | 最新1件のみ上書き | 最新値のみ | あり(次回起動時に届く) |
transferUserInfo | キュー型、順序保証 | OS永続化 | あり(キューで確実配信) |
sendMessage | リアルタイム | なし | なし(到達可能時のみ) |
updateApplicationContext
辞書([String: Any])を送信し、受信側に最新の状態を反映する。複数回呼ぶと最新1件で上書きされる。
// iPhone → Watch: 全データを送信
try WCSession.default.updateApplicationContext([
"activities": encodedActivities,
"settings": encodedSettings,
"lastSyncDate": Date().timeIntervalSince1970
])
この辞書の正体は、OSが1つだけ保持するシングルトン的な[String: Any]だ。呼ぶたびに辞書全体が丸ごと置換される(マージではない)。アプリが終了→再起動しても永続しており、session.receivedApplicationContextでいつでも最新値を読める。値はplist互換型4(String, Int, Double, Data, Array, Dictionary等)に限定される。
Watch側がアプリ非アクティブでも、次回起動時に最新のcontextが届く。iPhone→Watchの状態全量同期に最適。
この辞書を「iPhoneが知っている情報のうちWatchに共有すべきもの」の全量スナップショットとして設計する。クエストリスト(questUUID, title, storyTitle, limitTime)・設定値など、Watchが表示・判断に使う情報を1つの辞書にまとめて全量送る。Watchは受信したら丸ごと採用すればよい。差分計算やマージロジックが不要なので、バグの余地が少ない。部分更新による不整合が原理的に起きないのが、updateApplicationContextをiPhone→Watch状態同期の唯一のチャネルとして使う理由だ。
ただし、applicationContextはWatchの全状態の単一ソースではない。Watch側にはUserDefaultsで管理するローカル状態(進行中アクティビティ、ACK待ちのpendingリスト等)が存在する。applicationContext = 「iPhone→Watchの同期チャネル」、UserDefaults = 「Watch側でのみ発生するローカル状態」。両者は役割が異なる。
transferUserInfo
辞書をキューに追加して送信する。OSがキューを管理し、順序を保証して配信する。
// Watch → iPhone: アクティビティ完了を通知
WCSession.default.transferUserInfo([
"type": "activity_completed",
"activityId": activity.id.uuidString,
"startTime": activity.startTime.timeIntervalSince1970,
"duration": activity.duration
])
sendMessageと違い、相手が到達不能でもキューに蓄積される。Watch→iPhoneのイベント送信に最適。
さらに、iPhoneアプリが完全に終了している状態でも、WatchからtransferUserInfoで送信するとiPhoneアプリがバックグラウンドで自動起動し、WCSessionDelegateのコールバックが呼ばれて非同期処理が実行される。ユーザーがiPhoneアプリを開いていなくてもデータは届く。これは実機で確認済みの挙動だ。
sendMessage
リアルタイム通信。replyHandlerでレスポンスを受け取れるが、相手が到達可能(isReachable == true)でないと使えない。
// リアルタイム通信(到達可能時のみ)
WCSession.default.sendMessage(["ping": true]) { reply in
// レスポンス受信
} errorHandler: { error in
// 到達不能等のエラー
}
アクティビティ同期のようにリアルタイム性が不要な用途なら、transferUserInfoで十分。PlayTimeではアクティビティ同期にsendMessageを使っていない。ただし、クエスト開始/停止のリアルタイム通知にはsendMessageを使う場合がある(将来のショートカットフック用に残している)。
リアクティブプログラミングとの対応
updateApplicationContextとtransferUserInfoの違いは、リアクティブプログラミングのストリーム型に対応させると理解しやすい。
updateApplicationContext — 最新値のみ保持(状態ストリーム):
| 特性 | updateApplicationContext | Kotlin5 | Combine6 |
|---|---|---|---|
| 最新値のみ保持 | ○ | StateFlow / Channel(CONFLATED) | CurrentValueSubject7 / @Published |
| 中間値は消える | ○ | ○ | ○(遅いsubscriberは取りこぼす) |
| 新subscriber即配信 | ○(Watch起動時) | ○(collect開始時) | ○(subscribe時) |
transferUserInfo — 全件キュー(イベントストリーム):
| 特性 | transferUserInfo | Kotlin | Combine |
|---|---|---|---|
| 全件キュー | ○ | Channel(UNLIMITED) | PassthroughSubject8 + buffer |
| 1つも落とさない | ○ | ○ | ○(buffer付き) |
| 順序保証 | ○ | ○(FIFO) | ○ |
updateApplicationContextは「今の状態」を伝える。transferUserInfoは「起きたイベント」を1件ずつ確実に届ける。この使い分けは、状態(State)とイベント(Event)の区別そのものだ。
同期設計パターン: transferUserInfo一本化
PlayTimeの同期設計はシンプルだ。
- iPhone → Watch:
updateApplicationContextで全量送信 - Watch → iPhone:
transferUserInfoでイベント送信
アクティビティ同期にはsendMessageは使わない。リアルタイム性が不要なら、APIの分岐を増やす意味がない。
⚡ transferUserInfoのキューはOS永続化されている。アプリが終了しても、デバイスが再起動しても消えない。唯一消えるケースはアプリのアンインストールまたはWatchのペアリング解除。
なぜsendMessageを使わないか
| 観点 | sendMessage | transferUserInfo |
|---|---|---|
| 到達不能時 | エラー(要フォールバック) | キューに蓄積(自動配信) |
| アプリ終了時 | 失われる | OSが保持 |
| 実装の複雑さ | 到達可能判定+フォールバック必要 | 呼ぶだけ |
| リアルタイム性 | あり | 数秒〜数分の遅延 |
ゲームのアクティビティ記録が数秒遅れて同期されても問題ない。であれば、フォールバック分岐のないシンプルな設計の方が安定する。なお、クエストの開始/停止のようにリアルタイム性が求められる通知にはsendMessageを使う余地がある。用途によってAPIを使い分けるのが正解だ。
ACK9(確認応答)機構
transferUserInfoにはreplyHandlerやerrorHandlerがない。送信側は「相手が受信・処理したか」を知る手段がない。
解決策: transferUserInfoでACKを個別送信
ACKもまたtransferUserInfoで送る。iPhone→Watchへ、処理済みアクティビティIDを1件ずつ個別に送信する。
// iPhone側: アクティビティ処理後、ACKをtransferUserInfoで個別送信
func processReceivedActivity(_ info: [String: Any]) {
guard let activityId = info["activityId"] as? String else { return }
// 1. アクティビティを保存
saveActivity(info)
// 2. ACKをtransferUserInfoで個別送信
WCSession.default.transferUserInfo([
"type": "ack",
"activityId": activityId
])
}
iPhone側でprocessedUUIDsのようなリストを保持する必要はない。保存が完了したら即座にACKを送るだけ。
// Watch側: ACKを受信したらpendingから削除
func session(_ session: WCSession,
didReceiveUserInfo userInfo: [String: Any]) {
guard let type = userInfo["type"] as? String else { return }
if type == "ack", let activityId = userInfo["activityId"] as? String {
// 該当IDがなければ何もしない(冪等)
pendingActivities.removeAll { $0.id == activityId }
}
}
Watch側は冪等に処理する。該当IDがpendingリストになければ何もしない。重複ACKが来ても安全だ。
updateApplicationContextはクエスト同期専用として役割を分離する。ACK情報を混ぜないことで、contextの構造がシンプルになる。
フローをまとめると:
- Watch → iPhone:
transferUserInfoでアクティビティ送信 - iPhone: 受信・処理・保存
- iPhone → Watch:
transferUserInfoで処理済みIDをACK送信 - Watch: ACKを受信し、pendingリストから削除(該当IDがなければ何もしない)
整合性保証
この同期設計では、データの管理場所と責任が3つに分かれている。まずその全体像を整理する。
データ管理の全体像
| 管理場所 | 内容 | 管理主体 |
|---|---|---|
applicationContext([String: Any]辞書) | iPhoneからWatchに共有すべき情報の全量スナップショット(クエストリスト、設定値等) | OS永続化。アプリはupdateApplicationContextで丸ごと置換するだけ |
transferUserInfoキュー | Watch→iPhoneのイベント(アクティビティ完了)、iPhone→WatchのACK9 | OS永続化。アプリはキューに追加するだけで、配信タイミングはOS任せ |
| UserDefaults(Watch側) | Watchローカル状態 — pendingリスト(ACK待ちアクティビティID)、UIキャッシュ等 | アプリが直接読み書き |
| Realm(iPhone側) | アクティビティの永続データ。重複排除の照合先 | アプリが直接読み書き |
重要なのは、OS管理の部分とアプリ実装の部分の境界だ。
OS管理(アプリから制御不可能):
transferUserInfoキューの永続化・配信タイミング・順序保証applicationContext辞書の保持・配信- BLEペアリングの維持
アプリ実装(制御可能):
- 重複排除(dedup)の判定ロジック
- ACKの送信・受信処理
- pendingリストの管理(UserDefaults読み書き)
- Realmへのデータ保存
OS管理の部分は堅牢で信頼できる。アプリが壊れても、OSが保持しているキューやcontextは消えない。整合性の問題が起きるとすれば、アプリ実装の部分だ。
iPhone → Watch(applicationContext全量上書き)
applicationContextはOSが1つだけ保持する[String: Any]辞書であり、updateApplicationContextを呼ぶたびに辞書全体が丸ごと置換される。差分同期ではないので、部分的な不整合は原理的に起きない。
Watch側が古いcontextを保持したまま新しいcontextの受信に失敗するケースはあるが、次回のupdateApplicationContextで上書きされるので自動修復される。これはOS管理の配信保証による。
Watch → iPhone(ACKベース)
transferUserInfoで送信したアクティビティは、ACKを受信するまでWatch側のpendingリスト(UserDefaultsに保存)に残る。
- ACK受信 → pendingから削除(正常完了)
- ACK未受信 → pendingに残り続ける(次回起動時に再送可能)
iPhoneが処理に成功すれば即座にACKがtransferUserInfoで返る。ACKもOSのキューに乗るため、配信は保証されている。
重複排除(dedup)
Watchの再起動やネットワーク不安定で、同じアクティビティが複数回送信される可能性がある。iPhone側でquestId+開始時刻の組み合わせで既存のRealmデータと照合し、重複を排除する。
func saveActivity(_ info: [String: Any]) {
let questId = info["questId"] as! String
let startTime = info["startTime"] as! TimeInterval
// Realmの既存データと照合して重複チェック(1秒以内のtolerance)
let isDuplicate = realm.objects(Activity.self).contains {
$0.questId == questId && abs($0.startTime - startTime) < 1.0
}
guard !isDuplicate else { return }
// Realmに保存
// ...
}
questId+startTimeで照合し、startTimeは1秒のtoleranceを持たせる。iPhone↔Watch間の時刻同期にわずかな誤差が生じる可能性があるため、完全一致ではなくabs(差) < 1.0で比較する。同じクエストのほぼ同時刻のアクティビティは論理的に同一なので、Watchが何度再送しても二重登録されない。これはアプリ実装の判定ロジックであり、Realmというアプリが管理するデータストアに対して照合する。
自動修復
Watchアプリが再起動した場合、特別なリカバリ処理は不要だ。通常フローの繰り返しで自然に整合性が回復する。
activate()で最新のapplicationContext([String: Any]辞書)をOSから受信 → Watchの表示データが最新化- UserDefaultsのpendingリストに残っているアクティビティを
transferUserInfoで再送 → OSがキューに追加 - iPhone側がRealmと照合して重複排除しつつ処理 → ACKを
transferUserInfoで返す - Watch側がACKを受信し、pendingリストから削除
OS管理の部分(キュー永続化、context保持)が土台を支え、アプリ実装の部分(dedup、ACK処理、pending管理)がその上で正しく動く。この二層構造が、明示的なリカバリ処理なしに整合性を維持できる理由だ。
UserDefaultsの注意点
iPhone↔Watch間でUserDefaults10は共有されない。
// ❌ これはiPhone↔Watch間では共有されない
UserDefaults(suiteName: "group.com.example.app")
App Groups11によるUserDefaults共有は、同一デバイス内のアプリとExtension間でのみ有効。iPhone上のアプリとWidget Extension間では共有できるが、iPhoneとWatch間では共有できない。
⚠️ Watch側のUserDefaultsはローカル一時保存専用と割り切ること。永続データはiPhone側に持ち、WatchConnectivityで同期するのが正解。
Watch側でUserDefaultsを使う場面は:
- pendingリスト(ACK待ちのアクティビティID)の一時保存
- 最後に受信したcontextのキャッシュ
- UIの一時的な状態保持
いずれも「iPhoneから再取得すれば復旧可能」なデータに限定する。
WCSessionDelegateのライフサイクル
プロセスが生きている場合
session(_:didReceiveUserInfo:)やsession(_:didReceiveApplicationContext:)は、アプリのプロセスが生きている間に呼ばれる。
extension WatchSessionManager: WCSessionDelegate {
func session(_ session: WCSession,
didReceiveUserInfo userInfo: [String: Any]) {
// transferUserInfoで送られたデータを受信
processReceivedData(userInfo)
}
func session(_ session: WCSession,
didReceiveApplicationContext context: [String: Any]) {
// updateApplicationContextで送られたデータを受信
updateLocalState(context)
}
}
アプリ完全終了時
アプリが完全に終了している場合、キューに溜まったデータはOSが保持する。次回アプリ起動→activate()完了後に、保留分が順次配信される。
// アプリ起動時の流れ
func application(_ application: UIApplication,
didFinishLaunchingWithOptions: ...) {
WCSession.default.delegate = self
WCSession.default.activate()
// → activate完了後、保留中のtransferUserInfo/applicationContextが配信される
}
データの欠損を心配する必要はない。OSのキューが確実に保持・配信してくれる。
まとめ
| 方向 | API | 用途 |
|---|---|---|
| iPhone → Watch | updateApplicationContext | 全量同期(最新状態の反映) |
| Watch → iPhone | transferUserInfo | イベント送信(アクティビティ完了等) |
| ACK | transferUserInfoで個別送信 | 処理済みIDの通知 |
設計のポイント:
- アクティビティ同期に
sendMessageは使わない。リアルタイム通知(クエスト開始/停止等)には使う余地あり transferUserInfoのキューはOS永続化。アプリ終了でも消えない- ACKも
transferUserInfoで個別送信。updateApplicationContextはクエスト同期専用 - OS管理(キュー永続化・context保持)とアプリ実装(dedup・ACK・pending管理)の二層構造で、明示的なリカバリ処理なしに整合性を維持
WatchConnectivityは使い方さえ間違えなければ堅牢な仕組み。APIの選択を間違えると(sendMessageに依存する等)、到達不能時のフォールバックで実装が複雑化する。最初にAPIの特性を理解して設計するのが重要だった。
Footnotes
-
WatchConnectivity — Apple Watch↔iPhone間の通信を提供するフレームワーク。データ転送、メッセージング、ファイル転送等のAPIを含む。 ↩
-
WCSession — WatchConnectivityの中核クラス。シングルトン(
WCSession.default)で使用し、activate()でセッションを開始する。 ↩ -
BLE (Bluetooth Low Energy) — Apple WatchとiPhone間の物理的な通信に使われる低電力Bluetooth規格。ペアリングはOS層で管理される。 ↩
-
plist互換型 — Property List形式でシリアライズ可能な型。
String,Int,Double,Bool,Data,Date,Array,Dictionary等。カスタムクラスは直接送信できない。 ↩ -
Kotlin Flow / Channel — Kotlinの非同期ストリームAPI。
StateFlowは最新値を保持する状態ストリーム、Channelはプロデューサー・コンシューマー間のキュー。 ↩ -
Combine — Appleのリアクティブプログラミングフレームワーク。Publisher/Subscriberパターンで非同期イベントを処理する。 ↩
-
CurrentValueSubject — Combineの
Subjectで、最新値を保持し新しいsubscriber に即座に配信する。@Publishedプロパティラッパーも内部で同様の仕組みを使う。 ↩ -
PassthroughSubject — Combineの
Subjectで、値を保持せずsubscriberにそのまま流す。bufferオペレータを付けるとキュー型になる。 ↩ -
ACK (Acknowledgement) — ネットワークプロトコル(TCP等)で使われる概念。受信側が送信側に「正しく受け取って処理した」と返す確認応答。WatchConnectivityにはACK機構が組み込まれていないため、アプリ層で実装する。 ↩ ↩2
-
UserDefaults — iOS/watchOSの軽量キーバリューストア。デバイスローカルの永続化に使用。iPhone↔Watch間では共有されない。 ↩
-
App Groups — 同一デバイス内のアプリとExtension間でデータを共有する仕組み。iPhone↔Watch間には効かない。 ↩