プロセス死と状態復元
バックグラウンド放置から戻ると画面が初期化される不具合を根絶できる。プロセス死の判定と、Bundle/SavedStateHandle・iOS状態復元によるUIとナビゲーションスタックの再構築を原理から押さえられる。
- OSはメモリ確保のためバックグラウンドのプロセスを予告なく殺すため、アプリは自分のプロセスがいつでも消える前提で、少量のUI状態をシステム側に退避しておく必要がある。
- Androidのsaved instance stateはBundleをBinder経由でsystem_serverへ保存する。SavedStateHandleがViewModelへ橋渡しし、プロセス終了後も同じキーで状態を戻す。
- iOSのState RestorationとSwiftUIの@SceneStorageは、ナビゲーションスタックと各画面の識別子つき状態をエンコードし、新規プロセスとして再起動した際にUI階層を再構築する。
プロセス死は例外ではなく通常運転である
モバイルOSにとって、バックグラウンドのアプリを終了させることはエラー処理ではなく日常的なメモリ管理です。ユーザーが他アプリに切り替えた瞬間から、あなたのプロセスは「回収可能なメモリの塊」として扱われ、フォアグラウンドのアプリがメモリを必要とすればいつでも殺されます。このときプロセス空間ごと消えるため、ヒープ上のオブジェクト、シングルトン、静的フィールドはすべて失われます。それでもユーザーが戻ってきたとき、あたかも継続していたかのように同じ画面・同じ入力内容を見せる——この一貫した体験を成立させる仕組みが状態復元です。ライフサイクルのコールバックがいつ呼ばれるかは /mobile-development/android-activity-lifecycle/ や /mobile-development/ios-app-lifecycle-states/ で扱いました。本稿はその一段下、「消えた状態をどう物理的に退避し、どう再構築するか」に踏み込みます。
横にスクロール
二つの「状態が消える」を分離する
まず対策を誤らないために、状態が失われる二つの原因を切り分けます。両者は見た目が似ていても、生き残る保存先がまったく異なります。
| 原因 | 何が起きるか | 生き残るもの |
|---|---|---|
| Configuration change | 画面回転などでActivityやビューが破棄・再生成される。プロセスは生きたまま | AndroidのViewModel(同一プロセス内の参照保持)、iOSのSceneオブジェクト |
| プロセス死 | OSがメモリ確保のためプロセスごと終了。ヒープは全消滅 | システム側に退避した少量の直列化データのみ |
Configuration changeはプロセスが生きているため、AndroidではViewModelが同一インスタンスとして残り対処できます。しかしプロセス死ではViewModelもろとも消えるため、頼れるのは「事前にシステムプロセスへ書き出しておいた直列化済みのバイト列」だけです。この差を意識せずに ViewModel だけで状態を持つと、回転には強いのにバックグラウンド長時間放置には弱いアプリになります。
Androidのsaved instance stateはどこへ保存されるのか
AndroidのonSaveInstanceState(Bundle)に書き込んだデータは、アプリのプロセス内には留まりません。BundleはParcel化(直列化)され、Binder IPCを通じて別プロセスであるsystem_server(ActivityManagerService)に渡されて保持されます。だからこそ、アプリのプロセスが殺されても内容が生き残るのです。復元時は逆の経路で、新規プロセスのonCreate(savedInstanceState)とonRestoreInstanceStateに同じBundleが届きます。
この「別プロセスへIPCで運ぶ」という実装が、そのまま制約を生みます。
Binderトランザクションのバッファは1プロセスあたり約1MBを共有します。BundleにBitmapや巨大なリストを詰め込むと、保存時にこの上限を超えてTransactionTooLargeExceptionでクラッシュします。saved instance stateは「スクロール位置・選択中のタブ・入力途中のテキスト」といった数十KB規模のUI状態専用と考え、取得済みの本体データはDBやディスクキャッシュに置き、Bundleにはそれを引き当てるキーだけを入れるのが原則です。
Viewにはandroid:idが振られていればonSaveInstanceStateが自動で走り、EditTextのテキストやRecyclerViewのスクロール位置は標準で保存・復元されます。逆に言えば、IDのないViewや動的生成したViewの状態は自動保存の対象外になり、明示的な退避が必要です。
SavedStateHandleがプロセス死とViewModelを橋渡しする
ViewModelはconfiguration changeには強い一方、プロセス死には無力でした。この穴を埋めるのがSavedStateHandleです。これはViewModelのコンストラクタに注入されるキー・バリューストアで、内部的にはSavedStateRegistryという仕組みを通じて、ActivityのonSaveInstanceStateが保存するBundleの一部として同じ経路で退避されます。
| 観点 | onSaveInstanceState(View層) | SavedStateHandle(ViewModel層) |
|---|---|---|
| 書く場所 | Activity/Fragmentのコールバック内 | ViewModel内。ロジックと同じ層で完結 |
| 生き残る範囲 | configuration change・プロセス死の両方 | configuration change・プロセス死の両方(同じBundle経路) |
| 典型的な用途 | View固有のUI状態(スクロール位置など) | 画面の論理状態(検索クエリ、選択中ID、フォーム下書き) |
| 観測 | 手動でget/put | getStateFlow/getLiveDataで値の変化を購読できる |
class SearchViewModel(
private val state: SavedStateHandle
) : ViewModel() {
// プロセス死をまたいでも "query" キーで復元される
val query: StateFlow<String> =
state.getStateFlow("query", "")
fun onQueryChanged(text: String) {
state["query"] = text // Bundleに退避される値を更新
}
}
ここで保存できる値はBundleに載る型(プリミティブ、String、Parcelableなど)に限られます。これは制約ではなく、「プロセス死をまたぐ状態は直列化可能でなければならない」という物理的要請の表れです。関数の途中結果や進行中のコルーチンは直列化できないため、これらはプロセス死をまたいで保持できず、復元後に再実行する設計にします。宣言的UIであるJetpack Composeでも、rememberSaveableが内部で同じSavedStateRegistryに接続され、コンポーズ関数のローカル状態をプロセス死から守ります(/mobile-development/jetpack-compose-declarative-ui/ 参照)。
iOSの状態復元:識別子つきスナップショットの符号化
iOSも思想は同じで、プロセス死に備えてUI階層のスナップショットをディスクへ書き出しておきます。UIKitのState Restorationは、アプリがバックグラウンドへ入る際にビューコントローラ階層を走査し、restorationIdentifierが設定されたものだけをエンコード対象にします。ナビゲーションスタックの構成(どの画面が何階層積まれているか)と、各コントローラがencodeRestorableStateで書き出したアプリ固有データが、一つのアーカイブとして保存されます。
再起動時はこのアーカイブを即座に画面へ貼り付けるのではなく、二段構えで復元します。まずストーリーボードやコードで通常どおりビュー階層を生成し、次に保存済みスナップショットをdecodeRestorableStateで流し込みます。この順序により、「最後の画面のスクリーンショットを見せるだけ」ではなく、スクロール位置や入力途中のテキストまで含めて論理的に再構築できます。
restorationIdentifierベースの復元はUIKit専用で単一アプリ内に閉じています。近年はより汎用的なNSUserActivityが併用され、こちらは同一状態をHandoff(別デバイスでの継続)やSpotlight検索、Universal Linksとも共有できます。単純な画面復元だけならレガシー方式、状態を端末やコンテキストをまたいで再利用したいならNSUserActivity、という棲み分けが実務の指針です。
SwiftUIでは@SceneStorageプロパティラッパーがこの仕組みを簡潔に包みます。@Stateがプロセス死で消えるのに対し、@SceneStorageに置いた値はシーン単位で永続化され、再起動後も復元されます。状態プロパティラッパーの所有・生存の違いは /mobile-development/swiftui-state-data-flow/ に詳しく、@SceneStorageはそこに「プロセス死耐性」という軸を加えたものと位置づけられます。
低メモリ時の終了は「クラッシュ」ではない
プロセス死の直接の引き金は多くがメモリ逼迫です。AndroidのLow Memory Killer(LMK、現在はlmkdデーモン)は、プロセスに割り当てられたoom_adjスコアを見て、フォアグラウンドから遠いプロセスほど優先的に殺します。iOSも同様に、Suspended中でメモリ使用量の大きいプロセスから回収します。重要なのは、この終了が正常なOS運用であって、クラッシュログには残らない点です。したがってデバッガで動かしているだけでは再現せず、検証には意図的なメモリ圧迫やDeveloper Optionの「アクティビティを保持しない」といった手段が要ります。
「回転では消えないのにバックグラウンド放置後に戻ると初期化される」という症状は、configuration change対策(ViewModel)だけを実装しプロセス死対策(SavedStateHandle/onSaveInstanceState、iOSなら@SceneStorage/State Restoration)を欠いた典型例です。原因レイヤーを取り違えると効かない修正を重ねます。もう一つの頻出は、saved instance stateに大きなオブジェクトを載せてTransactionTooLargeExceptionを招くパターン。退避してよいのは直列化可能な少量のUI状態だけ、本体は再取得可能な場所に置く、という切り分けが答えになります。
まとめ
プロセス死はモバイルOSの通常運転であり、アプリはヒープ上の状態がいつでも消える前提で設計する必要があります。Androidはsaved instance stateのBundleをBinder経由でsystem_serverに退避し、SavedStateHandleがその経路をViewModel層まで引き込むことで、論理状態をプロセス死からも守ります。iOSはrestorationIdentifierつきのUI階層とナビゲーションスタックをアーカイブし、再起動時に二段構えで再構築します。両プラットフォームに共通するのは、「プロセスをまたぐ状態は直列化可能な少量に絞り、本体は再取得可能にする」という原則です。configuration changeとプロセス死を明確に分離し、それぞれに正しい保存先を割り当てることが、中断に強いアプリの土台になります。
モバイル開発の記事ガイド
プロセス死と状態復元を実務で読む
TL;DRは入口です。実際に選ぶ・使う段階では、何を解決するか、何と比較するか、導入後にどこで詰まるかまで見る必要があります。
解決すること
Android
比較で見る軸
難易度: advanced / カテゴリ: モバイル開発 / タグ数: 6
導入後に効く点
Androidのsaved instance stateはBundleをBinder経由でsystem_serverへ保存する。SavedStateHandleがViewModelへ橋渡しし、プロセス終了後も同じキーで状態を戻す。
先に潰すリスク
用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。
- 難易度
- advanced
- カテゴリ
- モバイル開発
- タグ数
- 6
判断チェックリスト
- 自社の用途が「Android / iOS」に近いか確認する。
- 強みである「OSはメモリ確保のためバックグラウンドのプロセスを予告なく殺すため、アプリは自分のプロセスがいつでも消える前提で、少量のUI状態をシステム側に退避しておく必要がある。」が本当に評価軸になるか確認する。
- 注意点の「用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。」を運用で吸収できるか確認する。
- 公開値や仕様値は、対象プラン・対象機種・対象リージョンまで確認する。
- 既存システム、ID、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
- 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。