「バグは直したのに、まだ残っている」— 外から見ればそういう話だった。オーナーからの苦情はシンプルに聞こえた。「アプリを開いてもデータが更新されない — 天気もほかも、手で引っ張り直さないと更新されない」。症状は一つ、文は一つ、期待される修正も一つ。
MeteoHealth(プロジェクトページ)は App Store で公開中のアプリで、こういう苦情は実際、たいてい間違った場所にある一行で片付く。原因を見つけ、修正し、テストは緑になった — そして苦情は消えた。十二日間だけ。
8 月 22 日、苦情はほぼ一字一句同じ形で戻ってきた。今度は「更新されない」という症状の裏に、独立した四つの原因が見つかり、さらに最初の修正自体が生んだ五つ目まであった。以下は一つのバグの話ではなく、修正が持ちこたえないときに何をすべきかの話だ。
原因 1:.task は思ったより長生きする#
最初の発見は、後から見ればほとんど自明だった。「今日」画面は .task でデータを読み込んでいたが、TabView の中の .task はプロセスの生存期間中に一度しか実行されない。タブはマウントされたままで、タブの切り替えもバックグラウンドからの復帰も、それを再起動しない。
画面側で scenePhase を聞いている者は誰もいなかった — アプリレベルでは通知とディープリンクの処理だけに使われていた。コード内のコメントがそれを率直に記録している。
// 発端:「アプリを開いてもデータが更新されない — 手で引っ張り直さないと
// いけない」。原因は、読み込みが `.task` にぶら下がっていたこと。`TabView`
// の中の `.task` はプロセスの生存期間中に一度しか実行されない。タブは
// マウントされたままで、タブの切り替えもバックグラウンドからの復帰も
// それを再起動しない。8 月 10 日の修正(952975c)は、「今日」「予報」「日記」「分析」の四つのタブに、scenePhase が .active に遷移したときに反応する .refreshOnForeground モディファイアを追加した。これで「バックグラウンドに行って戻ってきた」は閉じた。閉じなかったのは「一度もバックグラウンドに行かずにタブを切り替えて戻ってきた」— 同じ .task は沈黙し続けた。
原因のこの半分は 8 月 22 日になって初めて、同じ症状の別項目として浮上した。「タブに戻っても何も更新されなかった:ゲートはバックグラウンドに行った後にしか作動しなかった」。一つのメカニズム、二つのトリガー、二段階で閉じられた。
原因 2:裏口のないキャッシュ#
二つ目の原因は同じ最初の修正の中にあった。WeatherService はバイパスのない硬い十分キャッシュを持っていた。画面が誠実に読み込みを再起動しても、updateWeatherData() はキャッシュから同じ値を黙って返す — 手で引き起こした pull-to-refresh は何も変えなかった。画面は再描画され、スピナーは回り、データは昨日のまま — ユーザーにとってこれは「まったく動かない」と区別がつかない。
/// - Parameter force: 十分キャッシュをバイパスする。人間が更新を求める場所
/// (pull-to-refresh)やアプリのバックグラウンドからの復帰時に立てる。
/// さもないと、キャッシュ窓内の「引っ張り直し」は同じ値を黙って再代入し、
/// データは新鮮でないのに新鮮に見えた。
func updateWeatherData(force: Bool = false) async {
...
let lifetime = force ? Self.forcedCacheLifetime : Self.cacheLifetime
if let entry = cache.object(forKey: cacheKey as NSString),
Date().timeIntervalSince(entry.value.timestamp) < lifetime {force パラメータはキャッシュを丸ごと無効にはしない — 有効期間が十分から一分に縮まるだけだ。API キーの共有制限にリクエストの束を撃ち込まないためである。inFlightUpdate による並行呼び出しの coalescing はそのまま残った — そしてそれが重要だったと判明する。まさにそれが原因 3 を引き起こした。
原因 3:群衆に溶けた force#
8 月 22 日までに上の二つの原因は閉じられていたのに、苦情は戻ってきた。アプリ起動時、二つの場所が同時に更新を開始する。MainTabView.task と TodayViewModel.loadData — どちらも force なしで。その同じ窓の中で人が画面を手で引っ張ると、その強制リクエストは inFlightUpdate を通じてすでに進行中の通常リクエストにただ合流し、同じキャッシュ済みデータを受け取った。pull-to-refresh のジェスチャーは物理的には作動した — そして何一つしなかった。
/// すでに一つ進行中のときに、新しい更新リクエストをどう扱うか。
///
/// 起動時には `MainTabView.task` と `TodayViewModel.loadData` が同時に更新を
/// 開始し、どちらも `force` なし。その窓の中で人が画面を引っ張ると、その
/// 強制リクエストは他人の通常リクエストに流れ込み、そちらは十分キャッシュ
/// からデータを返し、ジェスチャーは何もしなかった。
enum WeatherUpdateCoalescer {
enum Decision: Equatable {
case start
case join
case joinThenForce
}
static func decide(incomingForce: Bool, inFlightForce: Bool?) -> Decision {
guard let inFlightForce else { return .start }
return incomingForce && !inFlightForce ? .joinThenForce : .join
}
}coalescing 自体は正しい発想だった — それがなければ並行呼び出しは重複したネットワークリクエストを量産していただろう。誤りは、リクエストの強さを区別しなかったこと。弱いパスと強いパスが一つの弱い結果に潰れていた。
原因 4:天気は引っ越しを待っていた#
四つ目の原因は最初の三つとは別の場所、位置情報の解決に住んでいた。GPS の測位が遅いと、resolveLocation() は八秒のタイムアウトにぶつかり手ぶらで抜ける — そして再試行はなかった。didUpdateLocations が天気を再リクエストするのは 5 km を超える移動のときだけだからだ。人がどこにも引っ越していなければ(そしてたいてい引っ越していない — 朝、同じ場所でアプリを開いただけだ)、更新は黙って永久に消えた。画面は手で引っ張り直すまで昨日のスナップショットのまま — そのジェスチャー自体が原因 3 に食われていた。
/// 測位の遅いコールドスタートは八秒のタイムアウトにぶつかり手ぶらで抜けて
/// いた。試行そのものは二度と繰り返されなかった — `didUpdateLocations` が
/// 天気を再リクエストするのは 5 km を超える移動のときだけ。二回が上限:
/// それでも測位がなければ、問題は時間ではない。
private func scheduleLocationRetryIfNeeded(force: Bool) {
guard authorizationStatus != .denied, authorizationStatus != .restricted else { return }
guard locationRetryCount < Self.maxLocationRetries else { return }
locationRetryCount += 1
Task { [weak self] in
try? await Task.sleep(nanoseconds: UInt64(Self.locationRetryDelay * 1_000_000_000))
await self?.updateWeatherData(force: force)
}
}加えて、システムの位置情報キャッシュへのフォールバック(location ?? currentLocation ?? locationManager.location)— 新鮮な測位がなくても、最後の既知位置でたいてい天気には十分だ。
四つの原因 — すべて閉じた。残っていたのは五つ目の層で、この四つの勘定には入っていなかった。持ち込んだのが最初の修正そのものだったからだ。
原因 5:自分自身を追い越した修正#
最初の修正(952975c)は、それ自体が別のバグを持ち込んだ。翌日、シミュレータでの実地確認で見つかったものだ。ゲート RefreshOnForegroundGate はスロットリングを「最後の更新時刻」で数えていた — そのため、コールドスタート直後に .active になったシーンが、一回目のすぐ後を追って二回目の画面読み込みを仕掛けていた。ログではこう見えた。01:15:23 と 01:15:26 — アプリを開いたばかりなのに、三秒差で二回連続の読み込み。
ルールは苦情そのものの言い回しに沿って書き直された。「いつ更新したか」ではなく、「アプリがどれだけバックグラウンドにいたか」だ。
mutating func shouldRefresh(on phase: ScenePhase, now: Date = Date()) -> Bool {
switch phase {
case .background:
backgroundedAt = now
return false
case .active:
guard let wentAway = backgroundedAt else { return false }
backgroundedAt = nil
return now.timeIntervalSince(wentAway) >= minimumBackgroundTime
default:
return false
}
}書き直しの副作用は嬉しいものだった。ついでに二つの誤作動が両方消えた — コールドスタート(backgroundedAt は単に nil で、更新するものがない)と、アプリの上に重なるシステムダイアログ(.background ではなく .inactive を返す)。同じログで検証済み:01:19:50 コールドスタート — 読み込み一回。バックグラウンドに 32 秒。01:20:26 復帰 — 二回目。きっかり二回、重複なし。
純粋な型としてのゲート#
この変更で気に入っているのは、修正の事実そのものではない。「今すぐ更新するか否か」の判断が最初から View の外の独立した型 RefreshOnForegroundGate に切り出され、ロジックの内部に SwiftUI への依存が一つもないことだ。だからこそ、スロットリングも .inactive vs .background も繰り返しの重複も、シミュレータで目視するのではなくユニットテストで検証できた。
.inactive と .background の区別は API の偶発的な細部ではなく、苦情の直接の帰結だ。システムの許可ダイアログやコントロールセンターがアプリの上に重なると .inactive になるが、そこでデータが古くなる時間は客観的にない。そういうダイアログのたびに更新するのは、理由なくネットワークを叩くことを意味する。
30 秒のしきい値は二か所に住んでいる — ゲート自身と、複製されて、シミュレータ上で「最小化 — 待つ — 戻す」の実サイクルを回す UI テスト(XCUIDevice.shared.press(.home) → しきい値より長い Thread.sleep → app.activate())の中だ。複製はコメントに記録されている。
/// `RefreshOnForegroundGate` のしきい値。UI テストはアプリを `@testable import`
/// できない(別プロセスとして起動される)ため、値を複製している。ずれたら
/// テストが早く戻りすぎるようになって赤くなる — 嘘はつかない。
private enum RefreshOnForegroundThreshold {
static let seconds: TimeInterval = 30
}二つのプロセスに複製された定数にとって最悪のシナリオは、静かなずれだ。ここではずれはテストを壊す — 気づかれずに通過することはない。
このすべてを取り巻くプロセスの誠実さにも触れておく価値がある。コミット 952975c にははっきり書かれている。「完全には検証していない:シミュレータで『最小化 — 戻す』の対話的サイクルを回すことができなかった」。そして pull-to-refresh 後のスクロール回帰についての 22b8068 には:「シミュレータでは元の回帰を再現できなかった — 巻き戻したコードでもテストは緑だった — ため、修正の確認は実機でしかできない」。検証済みの境界を記録することは、修正そのものと同じ規律の一部だ。バグが閉じたことを証明できないテストは、証明ではなく、将来の破損への見張り役と正直に呼ばれる。
この物語の結末#
バグについて「直したのに残っている」と言われるとき、それが「修正が効かなかった」を意味することはほとんどない。原因が一つ以上あり、それらをつなぐのはロジックではなく症状だけだ、という意味だ。ここでは独立した層が四つあった — SwiftUI のライフサイクル(TabView の中の .task)、バイパスのないサービスのキャッシュ、並行リクエストの coalescing、距離ベースの位置情報トリガー — そして各層が他をマスクしていた。.task を直しても、pull-to-refresh はキャッシュのせいで沈黙したまま。キャッシュを直しても、起動時に coalescing がそれを食う。coalescing を直しても、位置情報の修正なしでは GPS が動くまで画面は更新されない。さらに別枠で、最初の修正自体が持ち込んだバグ — ビルドログではなく、ようやくシミュレータで実地に確かめる手が回ったときに初めて見つかった。
実践的な結論は「テストをもっと書け」より具体的だ。ロジックを View から純粋な型に切り出す決断は元を取った — まさにそれがスロットリングをスクリーンショットなしで検証可能にした。そしてもう一つ:症状がユーザーの一文で記述されているからといって、原因が一つとは限らない — チケットを閉じる前に、各層を単独で確かめる価値がある。


