Under The Hood
4つの設計判断
並行処理、状態管理、端末間の同期、GPWS判定ロジック - RunWayを
作る上で最も悩んだ4つのポイントです。
CoreLocation
RunningCenter (actor · serial queue)
AsyncStream<FlightData>
HealthKit + WatchConnectivity
send / receiveViewModel (@Observable) → SwiftUI
なぜこの設計にしたのか
“GPSの更新が連続的に発生し、そのたびにペース・距離・GPWS判定を再計算する必要があるため、この計算ロジックをRunningCenter Actorに隔離して状態の整合性を保証しました。心拍数とケイデンスはWatchConnectivityが受け取り、判定ロジックを持たずスレッドセーフに値を保持するだけのHealthCenter Actorに渡され、RunningCenterが必要なときにそこから値を取得して@Observable ViewModelへ渡します。”
SwiftUIUI
@MainActor + @Observable状態管理
RunningCenter (Actor)並行処理
HealthCenter (Actor)心拍数/ケイデンスのスレッドセーフな保管
AsyncStreamセンサー → ViewModel
FlightPhase enum状態機械
WatchConnectivityiPhone ↔ Watch
SwiftData保存
CloudKit (CKRecord)手動バックアップ/復元 · Private Database
Douglas-Peucker経路の対角線長に比例した誤差でGPS座標を圧縮
MapKit + MapPolylineルート可視化
HealthKit心拍数・ケイデンス・ワークアウトセッション
WidgetKit + ActivityKitDynamic Island
WeatherKitリアルタイム天気
AVSpeechSynthesizerkmスプリット・カウントダウンの音声案内
Combine位置情報・アラート・セッション状態のPublisher + Timer
System Flow
全体システムマップ
GPS・心拍・高度センサーから画面、保存、iPhone↔Watch同期まで、実際のアプリを
構成する17個のコンポーネントとその間を行き来するデータを一枚にまとめました。
ボックスをクリックすると右側に実際のコードが表示されます
RunningCenter
RunWay/Shared/Actor/RunningCenter.swift
すべてのランニング計算の中心。GPS/心拍/高度を受け取り、距離・ペース・GPWS・スプリットを計算してFlightDataストリームへ送り出す。
1actor RunningCenter {2 private(set) var totalDistance: Double = 03 private(set) var gpwsStatus: GPWSState = .normal4 private(set) var isStationary: Bool = false56 func processLocation(_ location: CLLocation) async {7 if location.speedAccuracy >= 0 {8 }9 continuation?.yield(flightData)10 }11}担当機能
- ▸累積距離の計算、二重指数移動平均(Double EMA)によるペーススムージング
- ▸raw速度ベースの停止判定(isStationary)、speedAccuracyによる信頼度チェック
- ▸GPWS(SINK RATE/OVERSPEED/MINIMUMS)判定、停止中はnormalへリセット
- ▸km単位のスプリット記録、相対高度のスムージングと外れ値フィルタリング
なぜこの設計にしたのか
iOSとWatchはそれぞれ独立にGPS・心拍・高度を受け取るため、RunningCenterも端末ごとに別インスタンスとして動く。停止判定はGPS信頼度(speedAccuracy)が低いサンプルを除外しながら確定するが、当初はこのチェックが停止判定にしか掛かっておらず、ペース計算用の速度スムージングには掛かっていなかった。OVERSPEEDに反応して急減速する瞬間はまさに信頼度が負になりやすく、停止確定はできないままスムージング値だけが下がり続けてペースが際限なく膨らみ、アプリがクラッシュする実害につながった。スムージング更新を同じ信頼度チェックの中に移すことで1.3.1で解決した。