OS のマイナーアップデートでバックグラウンド処理が静かに死んだ話
| 開発記録 | 位置情報
タグ: #iOS #バックグラウンド処理 #Swift #Flutter #個人開発
iOS のマイナーアップデート後、アプリ終了後のバックグラウンド位置記録が静かに動かなくなりました。プラグインの抽象を捨ててネイティブの Significant Location Change + CLVisit に降り、「アプリが死んでいる間に届くイベント」をバッファする設計で復旧するまでの記録です。
ある日、夜の記録だけが消えた
位置情報を常時記録するアプリで、あるユーザー環境の記録に穴が空くようになりました。特徴的なのは就寝中の自宅滞在がまるごと抜けること。日中は正常なのに、アプリが長時間バックグラウンドに置かれた後の記録だけがない。
調査の結果、iOS のマイナーアップデート以降、バックグラウンド更新タスクの起動が当てにならなくなっていたことが分かりました。さらに、使っていた Flutter プラグイン経由の位置取得は、アプリが OS に kill された後に自動再起動してくれない経路だった。つまりアプリが一度死ぬと、ユーザーが次に開くまで何も記録されません。
クラッシュではないので通知も来ない。「静かに死ぬ」障害が一番怖い、を体験しました。
プラグインの抽象を捨てて、OS の低レイヤーへ
対策は、Flutter プラグインへの依存をやめて Swift でネイティブ実装に降りることでした。iOS には、アプリが kill されていても OS がアプリを起こしてくれる位置情報 API が2つあります。
- Significant Location Change (SLC): 大きな位置変化(セル基地局レベル)で発火し、アプリを cold start させる
- CLVisit: 「ある場所に滞在した / 離れた」を OS が判定して届けてくれる
この2つを直接扱う Singleton マネージャを実装し、SLC でアプリを起こして記録を再開する構成にしました。消費電力も GPS 常時監視よりずっと軽い。なお、これらは OS によって終了された場合の再起動を想定した仕組みで、ユーザーが明示的にアプリを終了した場合の挙動は OS バージョンの影響を受けます。過信は禁物です。
「アプリが死んでいる間に届くイベント」をどう受けるか
ネイティブ化して終わり、ではありませんでした。cold start 時のイベントは Flutter エンジンが起動する前にネイティブ側へ届きます。Dart 側のリスナーはまだ存在しない。素朴に実装するとイベントを取りこぼします。
そこでネイティブ側に リングバッファ(直近 50 件)を持たせ、Dart 側のリスナーが登録された瞬間に溜まっていたイベントを flush する設計にしました。また SLC は短時間に連続発火することがあるため 100m / 60 秒でデバウンス。CLVisit は到着と出発が別々に届いたり重複したりする Apple 仕様があるので、確定した方だけを単独のイベントとして流します。
Dart 側はイベントを sealed class で型分けし、「アプリが死んでいた間に何が起きたか」を復元してから記録を継続します。直近の記録が 4 時間以内ならそこに繋ぎ、古ければ登録済みの場所情報から新しい滞在を開始する、というフォールバックも用意しました。
テストをどう書くか
この種の機能の厄介さは再現の難しさです。「アプリを kill して数百 m 移動する」を CI では実行できません。そこでネイティブとの境界(メソッドチャネル / イベントチャネル)をモックし、cold start 時のイベント順序・バッファの flush・デバウンスの境界値をユニットテストで 15 件固めました。実機では確かめられない組み合わせほどテストに落とす。逆に実機でしか分からない「OS が本当に起こしてくれるか」は、数日間の実機ログで検証する。テストと実機観測の役割分担をはっきりさせたことが、結果的に一番の時短でした。
教訓
クロスプラットフォームの抽象化は快適ですが、OS の挙動変化に対する保険にはなりません。むしろ抽象の層が「なぜ動かないか」を隠します。バックグラウンド処理のような OS の胸三寸で生殺与奪が決まる領域は、最初からプラットフォーム API を直接扱う覚悟をしておいた方がいい。そして「アプリは頻繁に kill される」を前提に、死んでいた間のイベントをどう受け取るかまで設計して、初めてバックグラウンド対応と呼べるのだと学びました。