RunWayホームへMaintenance Log

整備記録

開発中に実際に直面した問題と原因追跡、解決までの過程を
整備ログの形式でまとめました。すべてが解決したわけではなく、
構造的な限界は限界として正直に残しています。

19
Resolved
2
Known Limitation
  1. FINDING

    問題の認識

    ミラーリング中でもiPhoneとWatchがそれぞれ独自にLocationServiceとRunningCenterを動かしていた。Watchから開始しても、iPhoneが新たにGPSロックを取得する間に遅延が発生していた。

  2. ACTION

    方向転換

    既存のstartOriginを位置追跡の要否を判断する基準にまで拡張。主導端末のみGPSをオンにして計算結果を相手に送信し、ミラーリング端末は受信後に表示するだけとした。

  3. DISCOVERY

    双方向の実装

    sendFlightData()がiOS側にしか存在せず、iPhone → Watch方向にしか送れなかった。Watch主導のミラーリングでは、Watchが計算したデータをiPhoneに送る経路そのものが存在しないという隠れた盲点だった。

  4. RESULT

    補完

    elapsedTimeまで3秒スロットルに乗せると誤差が積み重なるため、FlightDataとは分離して1秒ごとに専用メッセージ(sendElapsedTime())で同期。iPhone単独 / Watch単独 / 双方向ミラーリングの4つのシナリオがstartOrigin一つで整理された。

  1. FINDING

    発見

    スタックトレースを追跡した結果、session(_:activationDidCompleteWith:error:)内でクラッシュが発生していた。

  2. ROOT CAUSE

    原因

    クラス自体はnonisolatedで宣言していたが、extension内のdelegateメソッド自体に明示しないと、Xcode 26のSWIFT_DEFAULT_ACTOR_ISOLATION = MainActorのデフォルト設定により再び@MainActorと推論されてしまう。

  3. FINDING

    再発

    1次修正後もdidReceiveUserInfo内で同じパターンのクラッシュが実機でのみ再現した。

  4. ACTION

    全数点検

    +iOS、+watchOS extensionのWCSessionDelegateコールバックを全て点検し、漏れていたnonisolatedをすべて追加した。

  1. APPROACH

    1次実装

    Timer.publish().connect()で開始タイミングを直接制御。一時停止の実装を見据えてautoconnect()の代わりに選択した。

  2. FINDING

    再起動の失敗

    停止後に再開してもタイマーが動かない。connect()は一回限りの接続で、一度cancel()したPublisherは再接続できない。

  3. ACTION

    autoconnectへの転換

    start()が呼ばれるたびにautoconnect() + sinkで毎回新しく購読。Set<AnyCancellable>に保存し、stop()時にremoveAll()で整理した。

  4. RESULT

    重複購読の防止

    start()を連続で押すと購読が積み重なり、秒数が2、3ずつ飛ぶ問題も発見。start()の実行時点で既存の購読を先に整理するよう補完し、最終的に解決した。

  1. FINDING

    発見

    InstrumentsのSwift Concurrencyプロファイラーで確認したところ、位置情報の更新のたびに新しいAsyncStreamと新しいTaskが繰り返し生成されていた。

  2. ACTION

    プロパティへの昇格

    continuationをActorのプロパティに昇格。ストリームは一度だけ開いておき、processLocation()内部で保存されたcontinuationから直接yield()するように変更した。

  3. FINDING

    Sendableの衝突

    onTerminationでcontinuation = nilを直接呼び出すと「Actor-isolated property can not be mutated from a Sendable closure」エラー。任意のスレッドで実行される@Sendableクロージャのため、Actor保護されたプロパティに直接アクセスできない。

  4. RESULT

    init → taskの分離

    Actor-isolatedなメソッド(clearContinuation())を作り、Task { await ... }で包んで解決。その後startStream()として分離し、Viewの.taskから呼び出すよう再整理した。

  1. FINDING

    問題の認識

    正常なフロー(ボタンクリック)だけが状態整理を処理しており、異常な離脱(タブバーのクリック、Watchのクラウン操作)は全くカバーされていないことを実機テストで発見した。

  2. FINDING

    判断基準

    isRunningフラグで判断しようとしたが、ミラーリング中のiPhoneはstart()を呼ばないため、セッションが生きていてもfalseになる場合があった。HealthKitService.shared.session != nilを基準に置き換えた。

  3. ACTION

    フラグパターン

    ボタンアクションでdidNavigateToTouchdownのようなフラグを先にtrueに設定し、.onDisappearはそのフラグが立っていないことを「異常離脱」と解釈するパターンを5つのViewに同様に適用した。

  4. RESULT

    例外処理

    FlightSummaryViewはLogbook閲覧時にも使われるViewのため、selectedFlight == nil条件で区別。Watchは WatchSummaryViewのみ例外的に無条件で整理するようにした。

  1. FINDING

    1次試行

    startDate基準で5秒経過したらゾンビとみなす方式を試みたが検証不可 - デバッガーで強制終了した直後に接続が切れ、ログを確認できなかった。

  2. DISCOVERY

    原因の特定

    os_log / Console.appに切り替えてprivacyマスキングの問題を解決した後、ようやく本当の原因を特定した。retrieveRemoteSessionが再起動時に既に生きているセッションを再検知する構造だった。

  3. ACTION

    フラグベースの判別

    UserDefaultsフラグ(wasZombieSuspected)による判別は正確だったが、.end()呼び出し時にWatchセッションまで連鎖終了する副作用が発生した。

  4. FINDING

    代替案の模索

    appLaunchTime比較方式へ転換。ゾンビと判断しても無視すると新しいミラーリングがブロックされ、end()を呼ぶと先の副作用が再発するというジレンマだった。

  5. RESULT

    構造的限界の確定

    HKWorkoutSessionはhealthd(システムデーモン)レベルのリソースであり、アプリコードでは完全な制御が不可能だと結論。関連コードを全てロールバックし、ロギングインフラのみ維持した。

FINAL VERDICT

HKWorkoutSessionはhealthd(システムデーモン)レベルのリソースであり、アプリコードでは完全な制御が不可能だと結論。os_log/Console.appベースで原因を正確に特定したが、副作用なしでの解決が不可能だったためv1.0ではknown limitationとして明記した。

  1. FINDING

    症状の移動

    iPhone主導のミラーリングで出ていた症状(Watch表示が更新されない、終了が同期しない)が、コード変更なしの再ビルドだけで消えた。代わりにWatch主導のミラーリングで、位置情報が全く取得できないという新しい症状が現れた。

  2. ROOT CAUSE

    非同期の代入と同期チェックの衝突

    updatePhase(.cruise)内のstartOrigin = .localという代入がTask {}の中にあり非同期で実行される。すぐ次の行のstart()はこの値を一度だけ同期的にチェックするため、代入が完了する前にチェックが走ると、そのランニング中ずっとGPSが起動しない。

  3. DISCOVERY

    iPhoneが無事だった理由

    同じ呼び出し順序なのにiPhoneは問題なかった。TakeoffViewにPre-flight Checkを追加した際にprepareTracking()を先に呼ぶようにしていたため、start()が実行される時点ですでにGPSが動いており、レース自体が発生しなかった。Watchにはこの事前ステップがなく、そのまま影響を受けていた。

  4. ACTION

    レース自体を排除

    WatchにもiPhoneと同じprepareTracking()/stopTracking()パターンを導入。GPS起動ロジックをstart()から完全に分離し、start()がstartOriginを一切参照しない構造に変更した。

  5. RESULT

    プラットフォーム構造の統一

    didStartFlightフラグで、ROTATEによる正常な開始とカウントダウン中の離脱を区別。iOSとWatchが同じアーキテクチャになり、以降両プラットフォームのコードを並べて見ても混乱しにくくなった。

  1. FINDING

    画面と保存値の不一致

    PFDのリアルタイムペース計算にはすでにisFiniteガードがあり、画面には計算不能時も安全に--'--"と表示されていた。しかしSwiftDataに保存するsaveRunningData()には同じガードが抜けていた。

  2. ROOT CAUSE

    infの伝播

    距離が0だとDoubleはクラッシュせずinfを返す。このinfがそのまま保存され、月平均を求めるreduce演算にinfが一つ混ざるだけで合計全体がinfに汚染され、その月の平均が丸ごと壊れていた。

  3. ACTION

    保存・集計の二重防御

    保存時にisFiniteガードを追加し、計算不能な値は0として保存。集計時にもisFiniteフィルタをもう一段かけた。同じ計算式を持つWatch側のWatchPFDView.swiftにも同様のガードを適用した。

  4. FINDING

    後続の問題

    ランニング開始直後にすぐ終了すると、ペースが異常に跳ね上がる別の問題を発見。infではなく、時間に対して距離が極端に小さいことで生じた非現実的な有限値だったため、isFiniteガードでは検出できなかった。

  5. RESULT

    閾値の導入とデータ整理

    最小有効距離(50m)と現実的なペース上限(30 min/km)を追加。すでに汚染されていた14件のレコードは、使い捨てのクリーンアップスクリプトで削除した。「画面に見えなければ問題ない」ではなく、保存される値自体の安全性を確認すべきだという教訓を得た。

  1. FINDING

    パターンの確認

    ミラーリングの4つの組み合わせ(アプリ主導/Watch主導 × アプリ終了/Watch終了)をコードで全て再検証した結果、これまでの問題の半分以上が「Watch主導 + iPhoneがそれをリアルタイムで追従する」という組み合わせでのみ発生していた。

  2. DISCOVERY

    GPS交錯の正体

    WatchPFDViewには「.onDisappearで状態を整理する」というドキュメントコメントがあったが、実際のコードには存在しなかった。デジタルクラウンで離脱してもGPS追跡が停止せず、止まらないまま残った以前の追跡が次のランと重なっていた。

  3. DISCOVERY

    Dynamic Islandも同じ根本原因

    Live Activityを更新するupdateCruise()はiPhone自身のGPSストリームからしか呼ばれておらず、Watchから送られるflightData受信ロジックには接続されていなかった。Watch主導の場合、Live Activityは開始画面から一歩も動けない構造だった。

  4. ACTION

    妥協点

    完全な双方向ミラーリングの代わりに、アプリ主導(+ Watchセンサーデータのミラーリング)のみを維持し、Watch主導ではミラーリングを一切試みないようstartOriginで分岐させた。終了時は既存の「Watch単独ラン」経路のまま記録だけiPhoneのLogbookに渡す。

FINAL VERDICT

Watchで走っている間はたいてい画面を見ないという現実を受け入れた。Watch主導のランにiPhoneのリアルタイム同期は必須ではないと判断し、完全な双方向ミラーリングを追い求める代わりに実際の使われ方に合わせてスコープを意図的に狭めた。01番(ミラーリングアーキテクチャ再設計)で組み上げた4つのシナリオのうち一つを、今回撤回した形になる。

  1. FINDING

    実機での症状

    ミラーリング中にWatchでランを終了したが、iPhoneはまだ生きていた。その状態でWatchでランを再開すると、WatchとiPhoneがそれぞれ位置情報を集め続ける状態が重なり、Watchのランをさらに終了するとログに値が3回記録された。

  2. ROOT CAUSE

    「同じラン」だと確認する共通の識別子がなかった

    両方の端末が自分の側で終わったランをそれぞれ保存するだけで、お互いに「これは同じランだ」と確認する方法がなかった。終了信号が少し遅れて届くだけで、新しいランと以前のセッションが混ざってしまっていた。

  3. ACTION

    共有セッションIDの導入

    ランを開始する端末でUUIDを発行しrunSessionIDとして保持し、flightDataと最終保存メッセージに毎回乗せて送る。保存時にはこの値をそのままSwiftDataFlight.idとして使い、同じidがすでに保存されていれば再度保存しない。

  4. ACTION

    受信側にもガードを追加

    Watchが自分だけで新しいランを開始したら(startOrigin == .local)、iPhoneから届く古いflightDataを無視するようガードをかけた。逆方向も同様に処理した。

  5. DISCOVERY

    iPhone側のnativeミラーリング受信も整理

    この調査中に、HealthKitService+iOS.swiftのretrieveRemoteSession()が心拍数の伝達にもApple Healthへの保存にも使われていないことを確認した。むしろ残しておくと保存ガードが壊れたり、PFDViewがナビゲーションに重複して積まれる危険しかなかったため、登録を止めた。

FINAL VERDICT

09番でWatch主導ミラーリング自体はなくしたはずだったが、iPhoneがWatchのセッションを受け取り返すコードはまだ残っていた。開始側は今や完全にiPhone一つだけで判断され、終了側はセッションIDで同じランかどうかを確認する形に整理された。

  1. FINDING

    実機での症状

    iPhoneがstartWatchAppでWatchアプリを直接起動してミラーリングする場合、Watchでランを終了してもiPhone側のランが全く終わらなかった。

  2. ROOT CAUSE

    10番で止めたコードに密かに依存していた

    WatchViewModelは自分の終了をiPhoneに知らせるかどうかをrunningMode == .mirroredで判断していたが、runningModeはstartMirroringToCompanionDevice()が成功したときだけ.mirroredに変わる。10番でretrieveRemoteSession()(このハンドシェイクを受け取るリスナー)を止めたため、iPhone側に受け取ってくれるものがなくなり、この呼び出しが失敗してrunningModeがずっと.standaloneのままになり、sendStopSignal()自体が呼ばれなくなっていた。

  3. ACTION

    判定基準の入れ替え

    判定基準をrunningModeの代わりにstartOrigin != .localに変えた。startOriginはAppDelegate.handle()でこのハンドシェイクの成功可否と関係なく必ず先にセットされ、stopWorkout()もこの値に触れないため、終了時点までそのまま残っている。

FINAL VERDICT

10番でretrieveRemoteSession()を止めたことによる副作用だった。コードを消すときは、それに密かに寄りかかっていた別のロジック(今回は全く別の関数の判定基準)まで一緒に点検する必要があると改めて感じた。

  1. FINDING

    ホットフィックス分離中に発見

    まだマージされていない心拍数ブランチをv1.0向けの単独ホットフィックスとして急いで切り出す途中で発見した。RunningCenter.reset()は画面遷移だけでなくstart()の中でも防御的に呼ばれていたが、この呼び出しがmodeAData(ミッション目標)まで毎回消していた。

  2. ROOT CAUSE

    1つの関数に2つの役割が混ざっていた

    start()内の防御的なreset()は、elapsedTime/flightDataが前の値を引きずる問題を防ぐためのものであり、modeADataを消す意図はなかった。しかし同じ関数の中に一緒に入っていたため、画面遷移のたびにも、再開時の防御でも、毎回目標データまで一緒に消えていた。

  3. ACTION

    責務を分離

    目標データを消す役割をclearModeAData()として切り出した。reset()はmodeADataに触れなくなり、RunViewModel/WatchViewModelのresetState()だけがclearModeAData()を明示的に呼ぶようにした。

FINAL VERDICT

「画面遷移用のクリーンアップ」と「再開時の防御」という別々の目的が1つの関数に同居していると、片方だけを意図した変更がもう片方まで巻き込んでしまう。関数単位で責務を分けるだけで解決した。

  1. FINDING

    テスト方法の死角

    心拍数ベースのGPWSのためにSwiftDataAlertにtarget/heartRateフィールドを追加したが、毎回削除して再インストールするテストしかしておらず、マイグレーション経路自体を一度も通っていなかった。

  2. ROOT CAUSE

    initのデフォルト値はマイグレーションから見えない

    新しいフィールドのデフォルト値をinit()のパラメータにしか入れていなかった。SwiftDataの自動軽量マイグレーションは、既存レコードに新しいフィールドの値を埋める際にプロパティ宣言部のデフォルト値を参照するが、initパラメータのデフォルト値はそこからは見えない。

  3. ACTION

    宣言部に直接デフォルト値を明記

    プロパティ宣言部に直接デフォルト値を入れた。調査の過程で、これをやってもクラッシュするケースがあることも確認した。Appleのフォーラムではこれを「意図された動作」としている。完全に安全にするには、VersionedSchemaでフィールドを一旦オプショナルにしてから必須値に変える多段階マイグレーションが必要。

FINAL VERDICT

削除して再インストールするテストではマイグレーション経路自体を通らない。スキーマを変更するたびに、少なくとも一度は既存バージョンからのアップデート経路で確認する必要があると学んだ。

  1. FINDING

    バグ1 - 2回目のバックアップから静かに失敗

    バックアップボタンを押すとCloudKit Dashboardにレコードまでは正しくできたが、2回目に押すと「0件バックアップしました」と表示された。

  2. ROOT CAUSE

    変更タグの比較失敗が静かに埋もれていた

    modifyRecords(saving:deleting:)のデフォルトの保存ポリシー(.ifServerRecordUnchanged)はサーバー上のレコードと変更タグを比較するが、新しく作ったローカルレコードにはその情報がなく、競合として扱われていた。しかもこの失敗はエラーを投げず、レコードごとの結果の中にだけ含まれていたため、表面上は何の問題もないように見えた。

  3. ACTION

    常に上書きする方式へ変更

    savePolicy: .changedKeysに変更し、常にローカルの状態で無条件に上書きするようにした。

  4. FINDING

    バグ2 - Dashboardで照会できない

    バックアップはできるのに、CloudKit Dashboardで条件なしの全件照会をすると「Field 'recordName' is not marked queryable」というエラーが出た。

  5. ROOT CAUSE

    recordNameにはデフォルトでインデックスがない

    CKQueryで照会する際、条件や並び替えに使うフィールドごとにQueryableインデックスが必要だが、recordNameにはデフォルトでインデックスがない。条件なしの照会(NSPredicate(value: true))が内部的にrecordNameを参照しようとして引っかかっていた。

  6. RESULT

    クエリ条件の変更とインデックス追加

    クエリをdateフィールドの条件基準に変更してrecordNameの参照自体を回避し、CloudKit DashboardのSchema > IndexesでrecordNameにQUERYABLEインデックスも直接追加した。

FINAL VERDICT

保存の失敗がエラーを投げずに結果値の中だけに静かに含まれるAPIだったため、「成功したように見える失敗」を捕まえるには戻り値自体を確認する習慣が必要だった。CloudKit Dashboardとアプリのコードが互いに異なるクエリを作るという点も見落としやすいポイントだった。

  1. FINDING

    実機での症状

    音楽を聴きながら走ってみると、kmスプリットの案内自体が全く聞こえなかった。SpeechAnnouncerServiceはAVAudioSessionを.duckOthersオプションで設定し、案内中だけ音楽を下げるようにしているが、このアクティブ化コードはランニング開始時に一度だけ呼ばれていて、途中で音楽アプリがセッションを奪って中断されると、その後は復旧しなかった。

  2. ACTION

    再アクティブ化で一次修正、そして逆の問題

    speak()の直前に毎回セッションを再アクティブ化するよう修正したところ、今度は音楽の音量がランニング中ずっと下がったまま戻らなくなった。原因はstart()に残っていたアクティブ化呼び出しだった。ダッキングはspeak()ではなくセッションがアクティブな間ずっとかかるものなので、この呼び出しを削除し、AVSpeechSynthesizerDelegateのdidFinish/didCancelで発話終了と同時に非アクティブ化するようにした。

  3. DISCOVERY

    デバイス間でオーディオセッションは分離されている

    Watch単独ランニング中にiPhoneで音楽を聴いていると、TTSは全く聞こえなかった。AirPodsは両方のデバイスにBluetoothでペアリングされていても、実際のオーディオストリームは一方のデバイスしか持たない。AVAudioSessionはデバイスごとに完全に独立したインスタンスのため、Watch側の設定がiPhoneのオーディオに影響する方法がない。

  4. ACTION

    1.4.1 - イヤホンを持つ端末が読み上げる

    ミラーリング側はコードで解決できた。最初は設定で読み上げる端末を選ばせていたが、AirPodsはラン中も2台の間を移動するため、ユーザー自身にも分からないことを尋ねている状態だった。現在はiPhoneがcurrentRouteで自身の出力を直接確認し、イヤホンが自分側にあれば自分で読み、そうでなければWatchに渡す。受け渡しに失敗すれば自分で読む。当初はBluetoothポートだけを見ていて有線イヤホン(.headphones)を丸ごと取りこぼしたため、種類を数えるのではなく「自分のスピーカーではないか」で判定するよう反転させた。

  5. FINDING

    Nike Run Clubも同じ壁にぶつかっている

    コミュニティを調べてみると、Nike Run Clubも全く同じ症状を抱えていて、提示されている解決策も「音楽もWatch自体で再生する」ことだけだった。自分たちのアプリだけのバグではなく、Appleエコシステム全体の構造的な限界だった。

FINAL VERDICT

オンデバイスのダッキングのバグは2つとも直し、1.4.1ではミラーリング中にどちらの端末が読み上げるかまでコードで解決した。残っているのは、Watch単独ランニング中にiPhoneで音楽を再生している場合だ。オーディオストリームの所有者を決めるのはシステムであり、WatchアプリがiPhoneのセッションに触れる方法はない。代わりにアプリのお知らせ画面に「音楽もApple Watchで再生してほしい」という案内を追加し、ユーザーの行動で回避した。すべての問題がコードで解決するわけではなく、時には正直に案内することも答えになる。

  1. FINDING

    すでに残っていたTODO

    最小値100mから回し続けると中途半端な値で止まることを発見した。コードにはすでに「// 修正が必要」というTODOが残っていた。

  2. ROOT CAUSE

    開始点がステップの倍数になっていなかった

    Crownのバインディングは開始値0.1(100m)から0.5ずつ足していく構造だった。100mは0.5の倍数ではないため、それ以降のすべての値が開始点の分だけずれ続けていた。

  3. ACTION

    値の代わりにインデックスへ変更

    Crownの値を距離そのものではなく、あらかじめ用意したdistanceSteps配列のインデックスに変更した。100mは配列の先頭に例外として置き、以降は0.5km刻みのグリッドにハーフ/フルマラソン(21.1km/42.2km)だけを間に挟んだ。プリセットボタン(3K/5K/10K/HALF/FULL)も値の代わりにインデックスを探して設定するよう修正した。

FINAL VERDICT

開始点とステップ幅が互いの倍数になっていないと、そのずれが積み重なり続けるという、よくあるが見落としやすい算術バグだった。

  1. FINDING

    リリース直後に再現

    1.3リリース直後、OVERSPEED警告に反応して減速すると正常にSINK RATEに切り替わったが、再び加速してもNORMALに戻らなかった。偏差の数値は上がり続けて300秒に達し、最終的にWatchアプリが強制終了した。

  2. ROOT CAUSE

    停止判定とペース計算が別々のものを見ていた

    GPS信頼度(speedAccuracy)が負のサンプルは停止判定(isStationary)では確実にスキップされるが、ペース計算用の速度スムージングはこのチェックの外で毎回無条件に更新されていた。OVERSPEEDに反応して急減速する区間はまさに信頼度が負になりやすく、停止確定はできないままスムージング値だけが低い方へ引っ張られ続けた。

  3. DISCOVERY

    心拍モードで裏付け

    心拍ベースのGPWSはこのスムージング計算自体を経由せず、リアルタイムの心拍をそのまま使う。同じ条件でも問題は起きなかった。原因がペース計算の経路だけにあることを裏付ける手がかりになった。

  4. ACTION

    修正

    スムージング更新を停止判定と同じspeedAccuracyチェックの中に移した。信頼できない速度値なら、停止判定もペース計算も両方それまでの状態を保つようにした。

  5. FINDING

    検証中に見つかった二つ目のバグ

    この修正を確認している最中、別件として、停止して休んでいる間もSINK RATE/OVERSPEEDの振動が鳴り止まない問題が見つかった。停止中はGPWSを凍結する既存のロジックが、停止直前がたまたま警告状態だった場合その状態のまま固まり、警告画面と繰り返し振動を鳴らし続けていた。

  6. RESULT

    凍結ではなく初期化に

    停止中は凍結する代わりにnormalへ戻すよう変更した。心拍低下による新規誤警告も引き続き防げるうえ、停止直前に警告が出ていても停止した瞬間に消えるようになった。1.3.1としてリリース。

FINAL VERDICT

このバグは一度に生まれたものではなく、v1.3準備中に行った二つの変更が重なって生まれたものだった。まず停止判定の仕組みをraw速度ベースに作り直し、その後まったく別の理由(心拍低下による誤警告防止)で停止中に凍結するロジックを追加した。それぞれはその時点では妥当な理由による変更だったが、停止判定側のルールが後から追加した凍結ロジックの前提を崩しうることには気づいていなかった。片方のコードだけを見ていては見つからない、二つの変更の関係の中にだけ現れるバグだった。

  1. FINDING

    コメントが事実ではなかった

    開始時刻が秒まで同じ記録が二件あり、ソースはどちらもRunWayでデバイスだけが違った。コードのコメントには「Apple Healthへの保存はWatchのbuilderだけで行われる」とあったが事実ではなかった。iPhoneもセッションにbuilderを付けて収集を始めており、終了時に締めずに置くとシステムがそれまでのデータでワークアウトを確定して保存する。

  2. ACTION

    片方を止めた

    ミラーリング中はiPhoneのbuilderを破棄するようにした。無条件に破棄するとWatchなしで走るときに保存する側がいなくなるため、ミラーリング時だけに限定した。

  3. DISCOVERY

    終了の主体で結果が分かれた

    実機で確認すると、Watchで終了すると正常だがアプリで終了するとヘルスケアに何も残らなかった。Watch画面から終了する経路はHealthKitのデリゲートが締めてくれるが、iPhoneからの終了信号で入る経路はそのデリゲートを通らない。iPhoneは自分の分を破棄し、Watchも締めないため保存する側が一つもなかった。

  4. RESULT

    先にルールを決めた

    「センサーを持つ機器が保存する」と決めたことで、直すべき箇所が二つに絞られた。Watchは遠隔終了信号でも締めるようにし、iPhoneはミラーリング判定の順序を直した。finishWatchWorkout()はセッションを先に終了する順序にも変えた。遠隔終了の経路はセッションがまだ動いた状態で入ってくるためだ。

FINAL VERDICT

最初は保存する側を一つに減らす問題だと考えた。実際には誰が保存するのかを決める問題だった。それを決めずに片方を塞いだため、塞いだ経路では誰も保存せず、塞がなかった経路では両方が保存した。重複を消すことと主体を決めることは別の作業だった。

  1. FINDING

    他人が遭遇した症状

    1.3.1利用者からの報告で、自分の端末では再現しなかった。ホームに戻すコードを全て探すと経路は一つしかなく、利用者は何も押していないのに誰かが代わりにランニングを終了させていた。

  2. ACTION

    外れた仮説が二つ

    まず前回のランニングの終了信号がキューに残って遅れて配達されたと考えた。再現手順を作って依頼したが再現せず、後で見ると手順が仮説そのものを避けていた。次にスプラッシュ画面が1.5秒間NavigationStackを塞ぐ構造を見つけて確信した。しかし追加で聞いた「完全終了は正常、バックグラウンドのときだけ問題」が予測と正反対だった。

  3. DISCOVERY

    サマリーを見ていなかった

    三つ目に聞いた言葉が答えだった。Watchでランニングを終えると着陸画面が出るが、サマリーを押さずに手首を下ろすとバックグラウンド移行ではonDisappearが呼ばれず後片付けが走らない。次に新しいランニングが始まるとその画面の上にPFDが押し入り、覆われた着陸画面のonDisappearがそこで初めて呼ばれ、始まったばかりのランニングを片付けてしまう。

  4. RESULT

    覆われたのか離れたのかを区別

    後片付けに「ワークアウトセッションが動いていれば触らない」条件を付けた。正常に離れる場合はセッションが既に終わっているためそのまま通る。加えて新しいランニングはナビゲーション経路を置き換え、前の画面の上に積まないようにした。外れた仮説のために入れたコードは全て元に戻した。

FINAL VERDICT

onDisappearは「利用者が離れた」と「別の画面が上に覆った」を区別できない。ただ、より大きな学びは別にある。外れた二つの仮説はコードだけを見て立てたもので、当たった一つは報告者が「サマリーを見ていない」と言ってくれた後に出てきた。再現しないと聞いても、仮説ではなく手順を疑わなかった。他人が遭遇したバグは、コードをいくら読んでも条件を埋められない。条件はその人だけが知っている。

  1. FINDING

    同じ関数なのに一つの場合だけ失敗

    アプリで終了しても、Watchを切って走っても、Watch単独で走っても保存された。Watchで終了した場合だけ失敗した。つまり保存関数自体は正常ということだ。しかし保存を飛ばす箇所が四つとも何も言わずに戻るため、どこで抜けたのか分からなかった。

  2. ACTION

    順序を出力した

    四箇所それぞれに理由を付け、その順序をまとめて一度に表示するようにした。外で確認する問題なので画面にも出した。十行が出てきて、二箇所がおかしかった。重複処理を防ぐ値が二度とも無効で、同じランニングなのに距離が0mと264mで違っていた。

  3. DISCOVERY

    ビューモデルが二つあった

    App構造体の@State初期値はSwiftUIが構造体を作り直すたびに新しく生成され、最初の一つだけが使われる。捨てられたインスタンスもinit()で既に購読を張っており、終わらないストリームをselfを掴んだまま待つTaskのせいで解放されなかった。重複防止の値はインスタンスごとに別々なので効かず、先に動く幽霊が共有状態を空にして本物の保存がガードに引っかかった。

  4. RESULT

    繰り返しごとに確認

    二つのTaskを弱参照にし、繰り返しごとに生きているか確認するようにした。捨てられたインスタンスが正常に解放され、解放されれば購読も切れる。1.3.1のコミットに戻して同じ試験をしても同様に失敗したため、今回の作業が生んだ問題ではないことも確認した。

FINAL VERDICT

購読を経由して保存する経路が一つだけだったため、残り三つは正常に見えた。幽霊がいても表に出ない場所だったのだ。そして幽霊は終了信号だけでなくinit()で張った購読を全て受け取っていた。保存されないことが最も目立っただけだ。@Stateの初期値が複数回作られること自体は知っていたが、それが購読と結び付くと捨てられたコピーが働き続けるところまでは思い至らなかった。

  1. FINDING

    条件が掴めなかった

    間欠的で、やり直すと正常だった。長いランでのみ発生したが、40分で起きて114分では起きないこともあり、長さでも線が引けなかった。より厄介だったのはiPhone側に何も残らないことだ。信号が届かなければ何も起きていない状態になるため、Watchが送らなかったのか、途中で消えたのか、届いて捨てられたのかを区別できない。

  2. ACTION

    まず読む手段を作った

    ボタンから信号までの各地点に一行ずつ残すようにした。ところがWatchシミュレータはアプリが書いたログを保存領域に残さない。6時間分を探してもアプリの行はゼロで、同じ時間帯のシステムアプリのログは正常に残っていた。症状はシミュレータで出るのにログは実機でしか読めない、再現する場所と読める場所がずれた状態だった。同じ行をアプリのフォルダ内のファイルにも書くことで回避した。

  3. DISCOVERY

    まず正常時を測った

    失敗した記録を見ても「本来どこまで記録されるのか」が分からなければ意味がない。そこで成功した終了を最後まで記録した。ボタンからiPhoneがホームに戻るまで10秒、そのうち6秒がWatchのHealthKit保存という一箇所だった。基準ができると、失敗した記録で欠けている行がすぐ分かった。

  4. ROOT CAUSE

    保存が戻ってこなかった

    再現した日の記録は「保存開始」で途切れていた。システムログを見ると収集終了は3.5秒で完了し、その後の保存段階から出てこない。9分待っても解消せず、その間iPhoneは実際より9分10秒長い記録を積み上げていた。終了信号はその保存が終わってから発行される位置にあった。

  5. ACTION

    順序を入れ替えた

    発行を保存の前に移した。iPhoneが自分のランニングを終えるのに、WatchのHealthKit保存が終わる必要はない。保存をなくしたのではなく信号の後ろに回しただけなので、保存はそのまま走り、iPhoneだけがそこから外れる。

  6. RESULT

    10秒から3秒へ

    同じ長さのランでボタンからホームまで3.2秒になった。6時間41分のランでは保存に26秒かかったがiPhoneは4.2秒で終了した。保存が終わる24秒前だ。同時に入れた安全装置は、Watchのデータが10秒途切れると待機中であることを表示し、15秒でここで終了するか確認する。

FINAL VERDICT

変えたのは順序であって保存ではない。Watchの保存がなぜ一部のランでだけ終わらないのかは、まだ分かっていない。長さでもデータ量でもなかった。6時間41分のランの方がはるかに多く書き込んで完了し、止まった方はむしろデータベース書き込みが速かった。ただしその答えがなくても直せる問題だった。iPhoneにはそもそもWatchの保存を待つ理由がなかったのだ。原因を最後まで突き止めることと、ユーザーが体験する問題をなくすことは、いつも同じ作業とは限らない。