整備記録
開発中に実際に直面した問題と原因追跡、解決までの過程を
整備ログの形式でまとめました。すべてが解決したわけではなく、
構造的な限界は限界として正直に残しています。
- FINDING
問題の認識
ミラーリング中でもiPhoneとWatchがそれぞれ独自にLocationServiceとRunningCenterを動かしていた。Watchから開始しても、iPhoneが新たにGPSロックを取得する間に遅延が発生していた。
- ACTION
方向転換
既存のstartOriginを位置追跡の要否を判断する基準にまで拡張。主導端末のみGPSをオンにして計算結果を相手に送信し、ミラーリング端末は受信後に表示するだけとした。
- DISCOVERY
双方向の実装
sendFlightData()がiOS側にしか存在せず、iPhone → Watch方向にしか送れなかった。Watch主導のミラーリングでは、Watchが計算したデータをiPhoneに送る経路そのものが存在しないという隠れた盲点だった。
- RESULT
補完
elapsedTimeまで3秒スロットルに乗せると誤差が積み重なるため、FlightDataとは分離して1秒ごとに専用メッセージ(sendElapsedTime())で同期。iPhone単独 / Watch単独 / 双方向ミラーリングの4つのシナリオがstartOrigin一つで整理された。
- FINDING
発見
スタックトレースを追跡した結果、session(_:activationDidCompleteWith:error:)内でクラッシュが発生していた。
- ROOT CAUSE
原因
クラス自体はnonisolatedで宣言していたが、extension内のdelegateメソッド自体に明示しないと、Xcode 26のSWIFT_DEFAULT_ACTOR_ISOLATION = MainActorのデフォルト設定により再び@MainActorと推論されてしまう。
- FINDING
再発
1次修正後もdidReceiveUserInfo内で同じパターンのクラッシュが実機でのみ再現した。
- ACTION
全数点検
+iOS、+watchOS extensionのWCSessionDelegateコールバックを全て点検し、漏れていたnonisolatedをすべて追加した。
- APPROACH
1次実装
Timer.publish().connect()で開始タイミングを直接制御。一時停止の実装を見据えてautoconnect()の代わりに選択した。
- FINDING
再起動の失敗
停止後に再開してもタイマーが動かない。connect()は一回限りの接続で、一度cancel()したPublisherは再接続できない。
- ACTION
autoconnectへの転換
start()が呼ばれるたびにautoconnect() + sinkで毎回新しく購読。Set<AnyCancellable>に保存し、stop()時にremoveAll()で整理した。
- RESULT
重複購読の防止
start()を連続で押すと購読が積み重なり、秒数が2、3ずつ飛ぶ問題も発見。start()の実行時点で既存の購読を先に整理するよう補完し、最終的に解決した。
- FINDING
発見
InstrumentsのSwift Concurrencyプロファイラーで確認したところ、位置情報の更新のたびに新しいAsyncStreamと新しいTaskが繰り返し生成されていた。
- ACTION
プロパティへの昇格
continuationをActorのプロパティに昇格。ストリームは一度だけ開いておき、processLocation()内部で保存されたcontinuationから直接yield()するように変更した。
- FINDING
Sendableの衝突
onTerminationでcontinuation = nilを直接呼び出すと「Actor-isolated property can not be mutated from a Sendable closure」エラー。任意のスレッドで実行される@Sendableクロージャのため、Actor保護されたプロパティに直接アクセスできない。
- RESULT
init → taskの分離
Actor-isolatedなメソッド(clearContinuation())を作り、Task { await ... }で包んで解決。その後startStream()として分離し、Viewの.taskから呼び出すよう再整理した。
- FINDING
問題の認識
正常なフロー(ボタンクリック)だけが状態整理を処理しており、異常な離脱(タブバーのクリック、Watchのクラウン操作)は全くカバーされていないことを実機テストで発見した。
- FINDING
判断基準
isRunningフラグで判断しようとしたが、ミラーリング中のiPhoneはstart()を呼ばないため、セッションが生きていてもfalseになる場合があった。HealthKitService.shared.session != nilを基準に置き換えた。
- ACTION
フラグパターン
ボタンアクションでdidNavigateToTouchdownのようなフラグを先にtrueに設定し、.onDisappearはそのフラグが立っていないことを「異常離脱」と解釈するパターンを5つのViewに同様に適用した。
- RESULT
例外処理
FlightSummaryViewはLogbook閲覧時にも使われるViewのため、selectedFlight == nil条件で区別。Watchは WatchSummaryViewのみ例外的に無条件で整理するようにした。
- FINDING
1次試行
startDate基準で5秒経過したらゾンビとみなす方式を試みたが検証不可 - デバッガーで強制終了した直後に接続が切れ、ログを確認できなかった。
- DISCOVERY
原因の特定
os_log / Console.appに切り替えてprivacyマスキングの問題を解決した後、ようやく本当の原因を特定した。retrieveRemoteSessionが再起動時に既に生きているセッションを再検知する構造だった。
- ACTION
フラグベースの判別
UserDefaultsフラグ(wasZombieSuspected)による判別は正確だったが、.end()呼び出し時にWatchセッションまで連鎖終了する副作用が発生した。
- FINDING
代替案の模索
appLaunchTime比較方式へ転換。ゾンビと判断しても無視すると新しいミラーリングがブロックされ、end()を呼ぶと先の副作用が再発するというジレンマだった。
- RESULT
構造的限界の確定
HKWorkoutSessionはhealthd(システムデーモン)レベルのリソースであり、アプリコードでは完全な制御が不可能だと結論。関連コードを全てロールバックし、ロギングインフラのみ維持した。
HKWorkoutSessionはhealthd(システムデーモン)レベルのリソースであり、アプリコードでは完全な制御が不可能だと結論。os_log/Console.appベースで原因を正確に特定したが、副作用なしでの解決が不可能だったためv1.0ではknown limitationとして明記した。
- FINDING
症状の移動
iPhone主導のミラーリングで出ていた症状(Watch表示が更新されない、終了が同期しない)が、コード変更なしの再ビルドだけで消えた。代わりにWatch主導のミラーリングで、位置情報が全く取得できないという新しい症状が現れた。
- ROOT CAUSE
非同期の代入と同期チェックの衝突
updatePhase(.cruise)内のstartOrigin = .localという代入がTask {}の中にあり非同期で実行される。すぐ次の行のstart()はこの値を一度だけ同期的にチェックするため、代入が完了する前にチェックが走ると、そのランニング中ずっとGPSが起動しない。
- DISCOVERY
iPhoneが無事だった理由
同じ呼び出し順序なのにiPhoneは問題なかった。TakeoffViewにPre-flight Checkを追加した際にprepareTracking()を先に呼ぶようにしていたため、start()が実行される時点ですでにGPSが動いており、レース自体が発生しなかった。Watchにはこの事前ステップがなく、そのまま影響を受けていた。
- ACTION
レース自体を排除
WatchにもiPhoneと同じprepareTracking()/stopTracking()パターンを導入。GPS起動ロジックをstart()から完全に分離し、start()がstartOriginを一切参照しない構造に変更した。
- RESULT
プラットフォーム構造の統一
didStartFlightフラグで、ROTATEによる正常な開始とカウントダウン中の離脱を区別。iOSとWatchが同じアーキテクチャになり、以降両プラットフォームのコードを並べて見ても混乱しにくくなった。
- FINDING
画面と保存値の不一致
PFDのリアルタイムペース計算にはすでにisFiniteガードがあり、画面には計算不能時も安全に--'--"と表示されていた。しかしSwiftDataに保存するsaveRunningData()には同じガードが抜けていた。
- ROOT CAUSE
infの伝播
距離が0だとDoubleはクラッシュせずinfを返す。このinfがそのまま保存され、月平均を求めるreduce演算にinfが一つ混ざるだけで合計全体がinfに汚染され、その月の平均が丸ごと壊れていた。
- ACTION
保存・集計の二重防御
保存時にisFiniteガードを追加し、計算不能な値は0として保存。集計時にもisFiniteフィルタをもう一段かけた。同じ計算式を持つWatch側のWatchPFDView.swiftにも同様のガードを適用した。
- FINDING
後続の問題
ランニング開始直後にすぐ終了すると、ペースが異常に跳ね上がる別の問題を発見。infではなく、時間に対して距離が極端に小さいことで生じた非現実的な有限値だったため、isFiniteガードでは検出できなかった。
- RESULT
閾値の導入とデータ整理
最小有効距離(50m)と現実的なペース上限(30 min/km)を追加。すでに汚染されていた14件のレコードは、使い捨てのクリーンアップスクリプトで削除した。「画面に見えなければ問題ない」ではなく、保存される値自体の安全性を確認すべきだという教訓を得た。
- FINDING
パターンの確認
ミラーリングの4つの組み合わせ(アプリ主導/Watch主導 × アプリ終了/Watch終了)をコードで全て再検証した結果、これまでの問題の半分以上が「Watch主導 + iPhoneがそれをリアルタイムで追従する」という組み合わせでのみ発生していた。
- DISCOVERY
GPS交錯の正体
WatchPFDViewには「.onDisappearで状態を整理する」というドキュメントコメントがあったが、実際のコードには存在しなかった。デジタルクラウンで離脱してもGPS追跡が停止せず、止まらないまま残った以前の追跡が次のランと重なっていた。
- DISCOVERY
Dynamic Islandも同じ根本原因
Live Activityを更新するupdateCruise()はiPhone自身のGPSストリームからしか呼ばれておらず、Watchから送られるflightData受信ロジックには接続されていなかった。Watch主導の場合、Live Activityは開始画面から一歩も動けない構造だった。
- ACTION
妥協点
完全な双方向ミラーリングの代わりに、アプリ主導(+ Watchセンサーデータのミラーリング)のみを維持し、Watch主導ではミラーリングを一切試みないようstartOriginで分岐させた。終了時は既存の「Watch単独ラン」経路のまま記録だけiPhoneのLogbookに渡す。
Watchで走っている間はたいてい画面を見ないという現実を受け入れた。Watch主導のランにiPhoneのリアルタイム同期は必須ではないと判断し、完全な双方向ミラーリングを追い求める代わりに実際の使われ方に合わせてスコープを意図的に狭めた。01番(ミラーリングアーキテクチャ再設計)で組み上げた4つのシナリオのうち一つを、今回撤回した形になる。
- FINDING
実機での症状
ミラーリング中にWatchでランを終了したが、iPhoneはまだ生きていた。その状態でWatchでランを再開すると、WatchとiPhoneがそれぞれ位置情報を集め続ける状態が重なり、Watchのランをさらに終了するとログに値が3回記録された。
- ROOT CAUSE
「同じラン」だと確認する共通の識別子がなかった
両方の端末が自分の側で終わったランをそれぞれ保存するだけで、お互いに「これは同じランだ」と確認する方法がなかった。終了信号が少し遅れて届くだけで、新しいランと以前のセッションが混ざってしまっていた。
- ACTION
共有セッションIDの導入
ランを開始する端末でUUIDを発行しrunSessionIDとして保持し、flightDataと最終保存メッセージに毎回乗せて送る。保存時にはこの値をそのままSwiftDataFlight.idとして使い、同じidがすでに保存されていれば再度保存しない。
- ACTION
受信側にもガードを追加
Watchが自分だけで新しいランを開始したら(startOrigin == .local)、iPhoneから届く古いflightDataを無視するようガードをかけた。逆方向も同様に処理した。
- DISCOVERY
iPhone側のnativeミラーリング受信も整理
この調査中に、HealthKitService+iOS.swiftのretrieveRemoteSession()が心拍数の伝達にもApple Healthへの保存にも使われていないことを確認した。むしろ残しておくと保存ガードが壊れたり、PFDViewがナビゲーションに重複して積まれる危険しかなかったため、登録を止めた。
09番でWatch主導ミラーリング自体はなくしたはずだったが、iPhoneがWatchのセッションを受け取り返すコードはまだ残っていた。開始側は今や完全にiPhone一つだけで判断され、終了側はセッションIDで同じランかどうかを確認する形に整理された。
- FINDING
実機での症状
iPhoneがstartWatchAppでWatchアプリを直接起動してミラーリングする場合、Watchでランを終了してもiPhone側のランが全く終わらなかった。
- ROOT CAUSE
10番で止めたコードに密かに依存していた
WatchViewModelは自分の終了をiPhoneに知らせるかどうかをrunningMode == .mirroredで判断していたが、runningModeはstartMirroringToCompanionDevice()が成功したときだけ.mirroredに変わる。10番でretrieveRemoteSession()(このハンドシェイクを受け取るリスナー)を止めたため、iPhone側に受け取ってくれるものがなくなり、この呼び出しが失敗してrunningModeがずっと.standaloneのままになり、sendStopSignal()自体が呼ばれなくなっていた。
- ACTION
判定基準の入れ替え
判定基準をrunningModeの代わりにstartOrigin != .localに変えた。startOriginはAppDelegate.handle()でこのハンドシェイクの成功可否と関係なく必ず先にセットされ、stopWorkout()もこの値に触れないため、終了時点までそのまま残っている。
10番でretrieveRemoteSession()を止めたことによる副作用だった。コードを消すときは、それに密かに寄りかかっていた別のロジック(今回は全く別の関数の判定基準)まで一緒に点検する必要があると改めて感じた。
- FINDING
実機での症状
音楽を聴きながら走ってみると、kmスプリットの案内自体が全く聞こえなかった。SpeechAnnouncerServiceはAVAudioSessionを.duckOthersオプションで設定し、案内中だけ音楽を下げるようにしているが、このアクティブ化コードはランニング開始時に一度だけ呼ばれていて、途中で音楽アプリがセッションを奪って中断されると、その後は復旧しなかった。
- ACTION
再アクティブ化で一次修正、そして逆の問題
speak()の直前に毎回セッションを再アクティブ化するよう修正したところ、今度は音楽の音量がランニング中ずっと下がったまま戻らなくなった。原因はstart()に残っていたアクティブ化呼び出しだった。ダッキングはspeak()ではなくセッションがアクティブな間ずっとかかるものなので、この呼び出しを削除し、AVSpeechSynthesizerDelegateのdidFinish/didCancelで発話終了と同時に非アクティブ化するようにした。
- DISCOVERY
デバイス間でオーディオセッションは分離されている
Watch単独ランニング中にiPhoneで音楽を聴いていると、TTSは全く聞こえなかった。AirPodsは両方のデバイスにBluetoothでペアリングされていても、実際のオーディオストリームは一方のデバイスしか持たない。AVAudioSessionはデバイスごとに完全に独立したインスタンスのため、Watch側の設定がiPhoneのオーディオに影響する方法がない。
- FINDING
Nike Run Clubも同じ壁にぶつかっている
コミュニティを調べてみると、Nike Run Clubも全く同じ症状を抱えていて、提示されている解決策も「音楽もWatch自体で再生する」ことだけだった。自分たちのアプリだけのバグではなく、Appleエコシステム全体の構造的な限界だった。
オンデバイスのダッキングのバグは2つとも直したが、デバイス間のオーディオセッション分離という根本的な限界はコードでは解決できなかった。代わりにアプリのお知らせ画面に「音楽もApple Watchで再生してほしい」という案内を追加し、ユーザーの行動で回避した。すべての問題がコードで解決するわけではなく、時には正直に案内することも答えになる。