AndroidアプリにおけるUIの状態保存と復元について調べてみた

こんにちは。
2020年新卒でAndroidエンジニアの宮本です。
マネーフォワード クラウド確定申告アプリの開発を担当しています。

マネーフォワードでは、全社のAndroidエンジニアが集い、月一で社内勉強会を開催しています。
(最近はオンラインにて開催)

この勉強会では、毎回Android開発に関するテーマを1つ決めて深堀り、発表・ディスカッションしています。
本記事では、2021年1月の勉強会で私が発表した『AndroidアプリにおけるUIの状態保存と復元』について紹介します。

 

はじめに

Androidアプリにおいて、ユーザーが端末の戻るボタンを押してActivityを閉じたり、「最近の画面」でスワイプしてアプリを終了するなどしてActivityの破棄が行われると、ユーザーが行った操作などによって変化したUIの状態は初期化され、再度ActivityやFragmentを開いた際は変更前のクリーンな状態から開始されます。

▼ 「最近の画面」のスクリーンショット(公式ドキュメント 最近の画面 より引用)

そのため、多くのアプリでは画面表示に必要なデータをAPIから都度取得したり、データベースに保存できるようにしていることが多いでしょう。

一方で、AndroidのシステムによってActivityが破棄されてUIの状態が初期化されることもあります。
そのため、AndroidフレームワークではUIの状態が保持されることを保証するために、必要なデータを保存・復元するための仕組みが用意されています。

今回はどのようなときにシステムによってUIの状態が初期化されるのか、UIの状態を保持・復元するべきケースやその方法について解説します。

 

システムによってUIの状態は初期化されることがある

はじめに、システムがどのようなときにActivityを破棄してUIの状態を初期化するのかについて紹介します。

画面回転などによる構成変更が起きたとき

ユーザーはアプリの構成変更が行われた場合でもUIの状態が変わらないことを期待しています。
構成変更というのは例えば画面回転やマルチウィンドウモードへの切り替えのことです。

しかし、このような構成変更が起きるとシステムはActivityを破棄してUIの状態を初期化してしまいます。

構成変更のトリガーに何があるかについてはリファレンスに定数の一覧が載っています。

▼ 構成変更が起きるトリガーの一覧(configCahngesのリファレンス より引用)

リファレンスには構成変更が起きた際にActivityを破棄する理由として以下のように書かれています。

Note that all of these configuration changes can impact the resource values seen by the application,
so you will generally need to re-retrieve all resources (including view layouts, drawables, etc) to correctly handle any configuration change.

(意訳)
これらの構成変更はすべて、アプリケーションが参照するリソースの値に影響を与える可能性があります。
そのため、一般的には、構成変更を正しく処理するために、ビューレイアウトや画像などを含むすべてのリソースを再取得する必要があります。

たとえAndroidManifest.xml内でアプリ内の全ての画面を縦画面固定にしていたとしても、他の様々な要因で構成変更が起きる可能性があり、そういった際にUIの状態が初期化される場合があることを認識しておくとよいでしょう。

アプリのプロセスがシステムによって強制終了されたとき

ユーザーは「最近の画面」等を通して一時的に別のアプリに切り替えた後、元のアプリに再度戻ってきたときもUIの状態が変わらないことを期待しています。
しかし、システムはユーザーがアプリから離れてActivityが停止している間に、Activityが実行されているプロセスを強制終了する場合があります。

このプロセスを破棄する条件ですが、Activityのリファレンスを見ると以下のように書かれていました。

If an activity is completely obscured by another activity, it is stopped or hidden. It still retains all state and member information, however, it is no longer visible to the user so its window is hidden and it will often be killed by the system when memory is needed elsewhere.

(意訳)
あるアクティビティが他のアクティビティによって完全に見えなくなった場合、そのアクティビティは停止または非表示になります。この段階ではまだすべての状態と変数などのメンバ情報を保持していますが、ユーザーにはすでに見えていないので、ウィンドウは隠され、メモリが他の場所で必要になるとシステムによって終了されることがよくあります。

つまり、システムがメモリ解放を行う際にプロセスを強制終了する場合があるということです。
システムがプロセスを強制終了する可能性は、その時点でのプロセスの状態によって異なります。
興味がある方は以下の表と公式ドキュメントを参考にしてみてください。

▼ プロセスの状態、アクティビティの状態、およびシステムがプロセスを強制終了する可能性の相関関係表
(公式ドキュメント アクティビティの状態とメモリからの退避 より引用)

 

UIの状態が初期化されることによって、どのような影響があるか

Activityが破棄されてUIの状態が初期化されるとアプリではどのような影響が起こりうるか考えてみました。
一例としてAndroid版「マネーフォワード ME」の入出金明細画面を見てみましょう。

例えばユーザーがある明細の金額を1000円から500円に変更したいとします。
アプリを操作して金額を500と入力した後に、もしユーザーがアプリを離れて別のアプリを操作している最中にMEアプリのプロセスが終了されるとどうなるでしょうか。

以下に、後述するActivity破棄時の挙動の確認方法を使ってアプリの挙動を確認したgifを示しています。
左側のgifはUIの状態を保持する処理を入れた場合、右側のgifはその処理を入れなかった場合の挙動となっています。

UIの状態を保持する処理を入れた場合 UIの状態を保持する処理を入れなかった場合

UIの状態を保持している場合は、再度アプリに戻ってきてActivityが再生成されても、入力した金額の500が残っています。
一方でUIの状態を保持していない場合は、入力前の金額の1000に戻ってしまっています。
ユーザーはせっかく入力した項目をもう一度入力し直す必要がでてきてしまいます。

このような問題が起こりうる可能性があることから、主にユーザーから何か入力をするような機能を持つ画面に関してはUIの状態を保持することをおすすめします。

 

UIの状態を保持する方法

では、どうすればUIの状態を保持することができるのでしょうか。
公式ドキュメントでは3つの方法が紹介されています。

  • ViewModel
  • 保存済みインスタンスの状態 (View#onSaveInstanceState()の利用)
  • 永続ストレージ (SQLiteなどのデータベース)

▼ UI の状態の保持オプションの表 (公式ドキュメントの状態の保存 より引用)

本記事ではViewModelとonSaveInstanceStateメソッドについて紹介します。

ViewModel

Android Architecture ComponentのViewModel はActivityやFragmentのライフサイクルを意識したViewModel独自のライフサイクル管理を行っています。
そのため、ActivityやFragmentのライフサイクルが終了するまでメモリ上にデータが保持されるので高速にアクセスでき、画面回転などの構成変更が起きたとしてもViewModelは初期化されません。

▼ ActivityとViewModelのライフサイクルを表した表(VIewModelの公式ドキュメント より引用)

注意する点として、ViewModelはプロセスの終了時に破棄されるため、復帰後に状態を復元したい場合はonSaveInstanceStateメソッドや永続ストレージと組み合わせてデータを保管し、再読み込みできるようにする必要があります。

View#onSaveInstanceState()

onSaveInstanceStateメソッドはシステムがActivityやFragmentを破棄する際に、復帰後にUIの状態を再読み込みするためのデータを保存する役割を持っています。
データの保存はBundleオブジェクトを使ってkey-value形式で行います。

データの復元を行うには、onCreateメソッドやonRestoreInstanceStateメソッドから受け取れるBundleオブジェクトから取得することで可能です。

注意する点として、Bundleオブジェクトを使ったトランザクションには受け渡しのデータにサイズ制限があるということです。
公式ドキュメントによると、Activity単位ではなくアプリのプロセス単位でのトランザクションで1MBと記載されています。

Android 7.0 (API Level 24)以降では、このサイズ制限を超えると TransactionTooLargeExceptionが発生します。
(Android 6.0 以下ではlogcatに警告が表示されるのみです)

 

SavedStateHandleを使ってUIの状態を保存・復元する

ViewModelでは構成変更に対してもUIの状態を保持できるので画面回転などの心配をする必要がありません。
しかし、プロセスの終了には耐えうることができないため、通常はonSaveInstanceStateメソッドを使ってUIの状態を保存・復元する必要があります。
しかし、これを行うにはBundleオブジェクトを使ってデータを保存したり復元する処理が必要で、複雑な実装になってしまいがちです。

SavedStateHandleは、そのような複雑なボイラープレートコードを書く必要なく簡単にUIの状態保存・復元が行える仕組みです。
onSaveInstanceStateメソッドと同様にkey-value形式で値の保存と取得ができ、この値はシステムによってプロセスが終了された後も保持されます。

使用するには、バージョン1.2.0以降のFragmentバージョン1.1.0以降のActivityをプロジェクトの依存関係に追加してください。

SavedStateHandleの詳しい使い方については割愛しますが、公式ドキュメントにはサンプルコードも載っているので参考にしてみてください。LiveDataにも対応しているので扱いやすいです。
Daggerと併用するには一工夫必要なので、以下の記事を参考に導入するとよいでしょう。
Saving UI state with ViewModel SavedState and Dagger – Nimrod Dayan

 

一部のViewは自前で状態を保持する

以下のgifはEditTextにおいて、画面回転をしたときに状態が保持されているかを確認したものです。
ViewModelを使って値の管理をしたり、onSaveInstanceStateメソッドをoverrideして値の保存は行っていません。

構成変更の一つである画面回転が行われているにも関わらず、画面回転後もEditTextに入力したテキストが保持されています。
どういう仕組みになっているのか、EditTextの親クラスであるTextViewクラスの実装を少し見てみましょう。

    @Override
    public Parcelable onSaveInstanceState() {
        Parcelable superState = super.onSaveInstanceState();

        // Save state if we are forced to
        final boolean freezesText = getFreezesText();
        boolean hasSelection = false;
        int start = -1;
        int end = -1;

        ...

        if (freezesText || hasSelection) {
            SavedState ss = new SavedState(superState);

            if (freezesText) {
                if (mText instanceof Spanned) {
                    final Spannable sp = new SpannableStringBuilder(mText);

                    if (mEditor != null) {
                        removeMisspelledSpans(sp);
                        sp.removeSpan(mEditor.mSuggestionRangeSpan);
                    }

                    ss.text = sp;
                } else {
                    ss.text = mText.toString();
                }
            }

            ...
            return ss;
        }

        return superState;
    }

TextViewクラスに onSaveInstanceStateメソッドが実装されており、freezesTextというフラグがtrueになっている場合は状態を保持するように実装されています。
このように内部でUI状態を保存する処理が実装されているため、入力したテキストが画面回転後も保持されます。

なお、freezesTextはEditTextの場合はデフォルトでtrueが設定されているので状態を保持してくれていますが、TextViewはデフォルトでfalseが設定されています。
(xmlで android:freezesText="true" と指定すればTextViewも状態保持させることができます)

公式ドキュメントにもあるように、Androidフレームワークで提供されているViewはonSaveInstanceStateメソッドとonRestoreInstanceStateメソッドの固有の実装があるので、興味がある方は内部実装を見てみるとよいでしょう。

注意点として、EditTextやTextViewのUI状態を保持するためには以下のようにViewに対してidを定義する必要があります。
idが定義されていないと状態の保持を行ってくれません。

<EditText
    android:id="@+id/edit_text"
    android:layout_width="match_parent"
    android:layout_height="wrap_content" />

実際のアプリではViewModelのメンバ変数に定義しているLiveDataなどを参照していることが多いと思うので、必要に応じてそれらの値をonSaveInstanceStateメソッドやSavedStateHandleを利用して保持するのがよいでしょう。

 

Activity破棄時の挙動の確認方法

端末の設定画面の開発者向けオプションにある「 Activityを保持しない」という設定をONにすれば、Activityから離れた際にすぐに破棄されるので、アプリに戻ってきたときにUIの状態がどうなっているか確認できます。

▼ 開発者向けオプションの画面のスクリーンショット

 

Navigation ComponentではどうやってUIの状態保存・復元をしているのか

Android JetpackのNavigation Componentを使っているプロジェクトでよくあるのが、一つのActivityに対して複数のFragmentを持っているパターンです。

一つのActivityに対して一つのFragmentを持っている構成であれば、破棄されたActivityに紐づくFragmentが表示されるので問題無いですが、
一つのActivityに対して複数のFragmentを持っている場合だと、再生成時にどのFragmentを使えば良いのか知っている必要があります。

これについてNavigation ComponentではどのようにUIの状態保持を行っているのか、内部実装を見てみましょう。
(本記事ではどこで状態保持の処理を行っているのかを確認するのみとし、Navigation Componentの内部実装の詳細な解説は割愛します)
注目するのはNavHostFragmentの実装です。

    @CallSuper
    @Override
    public void onSaveInstanceState(@NonNull Bundle outState) {
        super.onSaveInstanceState(outState);
        Bundle navState = mNavController.saveState();
        if (navState != null) {
            outState.putBundle(KEY_NAV_CONTROLLER_STATE, navState);
        }
        if (mDefaultNavHost) {
            outState.putBoolean(KEY_DEFAULT_NAV_HOST, true);
        }
        if (mGraphId != 0) {
            outState.putInt(KEY_GRAPH_ID, mGraphId);
        }
    }

NavHostFragmentは複数のFragmentを切り替えるためのコンテナのような役割を持っています。
ユーザーがアプリを操作して画面遷移するたびにFragmentを切り替えています。
これはNavHostをimplements しているただのFragmentです。

NavHostFragmentにはonSaveInstanceStateメソッドがoverrideされています。
ここでは以下の3つのKEYを使ってデータが保存されています。

  • KEY_NAV_CONTROLLER_STATE
  • KEY_DEFAULT_NAV_HOST
  • KEY_GRAPH_ID

ここでKEY_NAV_CONTROLLER_STATEをbundleのkeyとして保存している値は、NavController#saveState()の返り値であるBundle型の値です。
この値にはNavigatorの状態やバックスタックの情報が入っています。

そして、NavHostFragmentのonCreateメソッドでBundleに値があれば復元し、バックスタックの情報をもとに画面を表示していました。

 

おわりに

AndroidのシステムによってUIの状態が初期化される場合があること、アプリのUIの状態を保持・復元するべきケースやその方法について解説してきました。

社内勉強会での発表後、社内のエンジニアからは以下のコメントを頂きました。

  • APIやローカルのDBなどから取得できるリソースはSavedStateに保存せずにActivity再生成後に再取得し、そこにしかない値 (ユーザーの入力値など) を保存するのがバランスとしては良さそう。

アプリ内のデータやUIの状態を適切に管理して、より良いユーザー体験を提供できるようにしていきましょう。


マネーフォワードでは、エンジニアを募集しています。
ご応募お待ちしています。

【サイトのご案内】
マネーフォワード採用サイト
Wantedly
京都開発拠点

【プロダクトのご紹介】
お金の見える化サービス 『マネーフォワード ME』 iPhone,iPad Android

ビジネス向けバックオフィス向け業務効率化ソリューション 『マネーフォワード クラウド』

おつり貯金アプリ 『しらたま』

お金の悩みを無料で相談 『マネーフォワード お金の相談』

だれでも貯まって増える お金の体質改善サービス 『マネーフォワード おかねせんせい』

金融商品の比較・申し込みサイト 『Money Forward Mall』

くらしの経済メディア 『MONEY PLUS』

Pocket