SysTecevo

Android SavedStateHandleとは?画面回転・プロセス終了時の状態復元とJava実装方法

初回掲載日:
最終更新日:

入力状態をViewModelとSavedStateHandleへ保存し、プロセス終了後に画面へ復元する流れの図解

SavedStateHandleは、ViewModelで扱う小さなUI状態を保存済み状態へつなぎ、画面の再生成やシステムによるプロセス終了後の復元を助ける仕組みです。画面回転だけならViewModelで足りることもありますが、プロセス終了まで考えるならSavedStateHandleを検討します。

先に結論
  • 画面回転・ウィンドウサイズ変更の間だけ保持したい複雑な状態は、まずViewModelを使う
  • 検索文字列、選択中ID、フィルターなど小さな値をプロセス終了後も戻したいなら、ViewModel+SavedStateHandleを使う
  • 大きなリスト、Bitmap、ユーザーの永続データはSavedStateHandleへ詰め込まず、Roomなどへ保存する
  • force-stop、タスク削除、アプリ終了を、システム起因のプロセス終了と同じ「復元されるケース」と考えない

SavedStateHandleとは?

SavedStateHandleは、ViewModelへ渡されるキーと値の入れ物です。Android公式では、ActivityやFragmentのsaved instance stateと連携し、システムにプロセスを終了された後でも、同じタスクへ戻る際に値を取り出せる仕組みとして説明されています。

例えば、検索画面のquery、選択中の商品ID、フィルター条件のような「画面を戻したときに復元したい小さな状態」を保存します。DBやバックアップの代わりではなく、画面を再構成するための最小限の情報を預ける場所です。

UI状態がViewModelとSavedStateHandleを経由して保存され、ActivityとFragmentの再生成後に復元される流れ
SavedStateHandleには、状態そのものよりも画面を再構成できる小さな値を保存します。

ViewModel・SavedStateHandle・Roomの使い分け

「状態が消えた」ときは、まずどの範囲まで残したいデータかを分けます。画面回転だけならViewModel、システム起因のプロセス終了も考えるならSavedStateHandle、アプリを閉じても残す業務データならRoomなどの永続ストレージが基本です。

方法主な保存場所構成変更システム起因のプロセス終了向いているデータ
ViewModelメモリ保持しやすい保持されない画面で使う複雑な状態や取得済みデータ
SavedStateHandlesaved instance stateと連携復元できる復元を補助できる検索文字列、ID、選択状態など小さな値
onSaveInstanceStateActivity/Fragmentの状態復元できる復元を補助できるUIロジックに属する小さなView状態
Room端末ストレージ再読込できる再読込できる履歴、メモ、商品情報など永続データ
ViewModel、SavedStateHandle、onSaveInstanceState、Roomの保存範囲と復元条件の比較図
4つの方法は競合するものではなく、データの寿命と役割に応じて組み合わせます。

画面回転とプロセス終了で何が違うのか

画面回転では、通常はActivityやFragmentのインスタンスが破棄され、新しいインスタンスが作られます。ViewModelは同じスコープに再接続されるため、メモリ上の状態を保ちやすいのが特徴です。

一方、アプリがバックグラウンドにいる間にシステムがプロセスを終了すると、ViewModelのインスタンスは失われます。このときSavedStateHandleやonSaveInstanceState()に保存された状態が、再生成時の手がかりになります。ただし、保存できるのは保存処理が行われた時点の値であり、バックグラウンド中に変更した値が次の保存機会なしで必ず残るわけではありません。

ユーザーがタスクを完全に終了した場合、設定画面から強制停止した場合、最近使ったアプリ一覧からタスクを削除した場合、再起動した場合などは、システム起因の一時的なプロセス終了と同じ復元を期待できません。保存状態はタスクスタックに結びつくためです。

JavaでSavedStateHandle付きViewModelを作る

以下は、検索文字列を保存する最小例です。Android DevelopersのViews向け公式例と同じく、ViewModelのコンストラクタでSavedStateHandleを受け取ります。Fragment 1.2.0以降、またはActivity 1.1.0以降の標準Factoryを使える構成が前提です。実際のプロジェクトでは、採用しているAndroidXの依存関係を確認してください。

import androidx.lifecycle.LiveData;
import androidx.lifecycle.SavedStateHandle;
import androidx.lifecycle.ViewModel;

public class SearchViewModel extends ViewModel {
    private static final String KEY_QUERY = "search_query";
    private final SavedStateHandle savedStateHandle;

    public SearchViewModel(SavedStateHandle savedStateHandle) {
        this.savedStateHandle = savedStateHandle;
    }

    public String getQuery() {
        String value = savedStateHandle.get(KEY_QUERY);
        return value != null ? value : "";
    }

    public void setQuery(String query) {
        savedStateHandle.set(KEY_QUERY, query);
    }

    public LiveData<String> query() {
        return savedStateHandle.getLiveData(KEY_QUERY, "");
    }

    public void clearQuery() {
        savedStateHandle.remove(KEY_QUERY);
    }
}

get()がnullを返す初回を考え、初期値を決めてから画面へ反映します。contains()でキーの存在を確認し、remove()でフィルター解除時の状態を消すこともできます。

FragmentからViewModelを取得する

Fragmentでは、標準のViewModelProviderから取得します。onViewCreated()のたびに初期値を無条件で書き込むと、復元された値を上書きすることがあるため、初期値の設定と表示処理を分けます。

import android.os.Bundle;
import android.text.Editable;
import android.text.TextWatcher;
import android.view.View;
import android.widget.EditText;

import androidx.annotation.NonNull;
import androidx.fragment.app.Fragment;
import androidx.lifecycle.ViewModelProvider;

public class SearchFragment extends Fragment {
    private SearchViewModel viewModel;

    @Override
    public void onViewCreated(@NonNull View view, Bundle savedInstanceState) {
        super.onViewCreated(view, savedInstanceState);

        viewModel = new ViewModelProvider(this)
                .get(SearchViewModel.class);

        EditText searchBox = view.findViewById(R.id.search_box);
        searchBox.setText(viewModel.getQuery());

        viewModel.query().observe(getViewLifecycleOwner(), query -> {
            if (!query.equals(searchBox.getText().toString())) {
                searchBox.setText(query);
            }
        });

        searchBox.addTextChangedListener(new TextWatcher() {
            @Override public void beforeTextChanged(CharSequence s, int start,
                    int count, int after) { }
            @Override public void onTextChanged(CharSequence s, int start,
                    int before, int count) {
                viewModel.setQuery(s.toString());
            }
            @Override public void afterTextChanged(Editable s) { }
        });
    }
}

実際の画面では、TextWatcherとLiveDataの相互更新でカーソル位置や入力イベントが不自然にならないよう、既存値と異なる場合だけUIを書き換えるなどの調整が必要です。上のコードは仕組みを示す最小例であり、実機検証済みコードとして扱わないでください。

getLiveData()を使うときの注意

getLiveData()は、キーの値をLiveDataとして監視できます。検索条件が変わったらRepositoryの検索を切り替えるような処理と相性がよく、公式ドキュメントでもSavedStateHandleからLiveDataを取り出す例が示されています。

ただしLiveDataやStateFlowを使っていても保存の仕組み自体が別になるわけではありません。Activityが停止する前にプロセスが終了した場合、最後の変更が復元対象へ反映されない可能性があります。重要なデータを確実に残す目的なら、Roomなどの永続ストレージを使います。

FragmentとNavigationでのスコープ

new ViewModelProvider(this)は、そのFragmentを所有者とするViewModelです。画面を離れたときに破棄される状態ならFragmentスコープが自然ですが、複数Fragmentで共有する検索条件ならActivityスコープやNavigationグラフスコープを検討します。

スコープを間違えると、復元処理が動いていても別のViewModelを取得して「状態が消えた」ように見えます。保存先の問題を疑う前に、同じViewModelインスタンスを参照しているか、FragmentのviewLifecycleOwnerとViewModelの所有者を確認してください。

SavedStateHandleに保存してよいデータ、避けるデータ

保存候補判断理由
検索文字列向いている小さく、画面を復元する情報だから
選択中のID向いている再表示時にRoomやRepositoryから再取得できるから
フィルター条件向いているUIを再構成するための軽量な値だから
Bitmap・大量リスト避けるBundle化や復元の負荷、容量制限の影響を受けるから
給与・履歴・メモRoom等へアプリを閉じても残すべき永続データだから
認証トークン・暗号鍵別設計SavedStateHandleへ無条件に保存せず、安全な保管方式を選ぶから

保存できる型はBundleとして扱える単純な型が基本です。ParcelableやSerializableの利用可否も含め、データ量と復元コストを確認します。「保存できる」ことと「保存すべき」ことは別です。

onSaveInstanceState()との違い

onSaveInstanceState()はActivityやFragmentのUIロジック側で状態を保存するAPIです。SavedStateHandleはViewModel側のビジネスロジック寄りの状態を保存するための窓口です。どちらもsaved instance stateの仕組みと連携するため、優劣ではなく状態の所有者で使い分けます。

  • Viewの選択やスクロールなど、UIそのものの状態:onSaveInstanceState()を検討
  • 検索条件や選択IDなど、ViewModelが使う状態:SavedStateHandleを検討
  • アプリの本体データや履歴:Roomなどへ保存

SavedStateHandleを使ったからRecyclerViewのスクロール位置まで自動復元される、とは限りません。Viewの状態復元やLayoutManagerの扱いは別途確認します。

状態が復元されないときの確認順

SavedStateHandleで状態が消えたときに回転、プロセス終了、保存、上書き、データサイズを確認する診断フロー
状態が消えたら、保存APIだけでなくライフサイクルと初期化処理を順に確認します。
  1. 何が起きたかを分類する:画面回転、Activity再生成、バックグラウンド復帰、タスク削除、force-stopを分ける。
  2. ViewModelのスコープを確認する:Fragmentごと、Activity共有、Navigationグラフのどれかを確認する。
  3. 値を書いているかを確認する:キー名の typo、set()の呼び出し漏れ、初期値だけを読んでいないかを見る。
  4. 復元値を上書きしていないか確認する:onViewCreated()で毎回空文字や初期フィルターを書き込まない。
  5. 保存タイミングを確認する:Activityが停止する前に書き込まれた値か、バックグラウンド中の変更ではないかを見る。
  6. 型とサイズを確認する:Bundleで扱えない型、大きすぎるデータ、Bitmapを入れていないか確認する。
  7. 永続データと混同していないか確認する:アプリ再起動後も必要ならRoomなどへ設計を移す。

画面回転・プロセス終了をテストする

保存機能は「アプリを一度起動できた」だけでは確認できません。テスト端末またはエミュレーターで、次のケースを分けて実施します。ここでは公式手順を整理しており、この記事の作成環境で実機テストを実行したという意味ではありません。

テストA:画面回転

  1. 検索文字列やフィルターを入力する。
  2. 画面を回転してActivity/Fragmentを再生成する。
  3. ViewModelの値と画面表示が一致するか確認する。

テストB:バックグラウンド復帰

  1. 入力状態を作る。
  2. Home操作でバックグラウンドへ移る。
  3. アプリへ戻り、状態が保持されるか確認する。

テストC:システムによるプロセス終了

入力後にアプリをバックグラウンドへ移し、保存済み状態が作られる機会を与えます。そのうえで対象アプリがバックグラウンドにあることを確認し、開発用端末でプロセス終了を再現してから元のタスクへ戻します。ADBを使う場合は、対象パッケージを自分のテストアプリへ置き換えてください。

# 例:バックグラウンドのテストアプリに対するプロセス終了の再現
adb shell am kill com.example.testapp

# ログを確認する場合
adb logcat

am killで期待どおりの状態が再現できるかは端末や実行状態に依存します。対象アプリをフォアグラウンドにしたまま実行しないこと、実データを使わないことが重要です。

テストD:force-stopとタスク削除

adb shell am force-stopや設定画面からの強制停止は、システムが一時的にプロセスを終了したケースと同じではありません。最近使ったアプリ一覧からのタスク削除も、保存状態が復元されないユーザー操作の例です。復元されないことを、直ちにSavedStateHandleの不具合と判断しないでください。

「Don't keep activities」はActivityの破棄・再生成を試す設定であり、プロセス終了の完全な代替テストではありません。

よくある間違い

  • ViewModelに入れたから、プロセス終了後も必ず残ると思う
  • SavedStateHandleへDBの全件リストやBitmapを保存する
  • 復元処理より先に初期値を毎回セットしている
  • FragmentスコープとActivityスコープを混同する
  • force-stop後に復元されないことをSavedStateHandleのバグと決めつける
  • 画面状態とユーザーデータを同じ保存先で扱う

FAQ

Q. SavedStateHandleはViewModelと同じですか?

違います。ViewModelは画面に必要な状態やロジックを保持するオブジェクトで、SavedStateHandleはそのViewModelが小さな状態をsaved instance stateへ保存・復元するための仕組みです。

Q. 画面回転だけならSavedStateHandleは必須ですか?

必須とは限りません。ViewModelだけで足りる状態もあります。プロセス終了後にも検索条件などを戻したい場合にSavedStateHandleを追加で検討します。

Q. SavedStateHandleにRoomのEntityを保存できますか?

保存可能な型やサイズの制約があるため、Entityや大量データをそのまま保存先にする設計は避けます。必要ならIDだけ保存し、復元後にRoomから再取得します。

Q. アプリを強制停止しても復元できますか?

システム起因のプロセス終了と強制停止は別です。SavedStateHandleはタスクスタックに結びつく保存状態であり、強制停止やタスク削除後の復元を保証する仕組みではありません。

Q. 入力内容が消えるときの最初の確認箇所は?

まず、画面回転なのかプロセス終了なのかを分類し、次にViewModelのスコープ、キー名、保存タイミング、初期値による上書きを確認します。

公開前チェックリスト

  • 保存したい値が一時的なUI状態か、永続データかを分けた
  • ViewModelのスコープが画面設計と一致している
  • SavedStateHandleへ小さな値だけを保存している
  • 初回だけ初期値を使い、復元値を上書きしていない
  • get()のnullとcontains()を考慮した
  • getLiveData()の監視をViewのライフサイクルに紐付けた
  • 画面回転、バックグラウンド復帰、プロセス終了を分けて確認した
  • force-stop・タスク削除を復元保証のテストと混同していない
  • RoomやAuto Backupが必要なデータをSavedStateHandleに置いていない

まとめ

SavedStateHandleは、ViewModelの利便性とsaved instance stateの復元性をつなぐためのAPIです。画面回転だけならViewModel、システム起因のプロセス終了後にも戻したい小さな状態ならSavedStateHandle、アプリの本体データならRoomなど、データの寿命で保存先を選びます。

特に重要なのは、復元処理の実装だけでなく、初期値による上書き、Fragmentのスコープ、保存タイミング、データ量、force-stopとの違いまで確認することです。状態が消える不具合は、保存APIを追加する前に「どの終了パターンで消えたのか」を切り分けると解決しやすくなります。

参考にしたAndroid Developers公式資料

Roomで保存するデータと画面を一時的に復元するデータは役割が異なります。DBスキーマ変更時のデータ保持はRoom Migrationの記事、バックアップや機種変更後の復元はAndroid Auto Backupの記事も参照してください。