LoginSignup
19
12

More than 3 years have passed since last update.

View の状態を正しく保存する

Posted at

1. はじめに

Activity が破棄されたときや画面回転が行われたときに、ビューの状態が保存できていないとユーザが入力したり選択した内容が復元されません。
Activity の破棄は、端末設定の System -> Developer options -> Don't keep activities を有効にすることで、アプリをバックグラウンドにするだけで簡単に確認が可能です。
本記事では、このような状態の消失を正しく防ぐ方法を記述します。

:rotating_light:事故例:rotating_light:

入力が消える 全部同じになる

まとめると?

  • View にid を振ろう
  • dispatchFreezeSelfOnly, dispatchThawSelfOnlyをうまく使おう
  • CustomView ではSavedState を作ると良さそう

環境

  • Android Studio 4.1.1
  • kotlin 1.4.32

2. 単一のレイアウトの場合

次のようにView がid なしで定義されているとき、最初に挙げた「入力が消える」動画のように状態は保持されません。

fragment_no_id.xml
<layout ...>
    <LinearLayout ...>
        <CheckBox
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginEnd="16dp"
            />

        <EditText
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_weight="1"
            android:hint="Type anything"
            />
    </LinearLayout>
</layout>

ここに、id を振ってやると、それだけで状態が保持されます。

fragment_with_id.xml
<layout ...>
    <LinearLayout ...>
        <CheckBox
            android:id="@+id/checkBox"
            ...
            />

        <EditText
            android:id="@+id/editText"
            ...
            />
    </LinearLayout>
</layout>

どうなってる?

CheckBoxの継承元であるCompoundButtonを見てみると、onSaveInstanceStateonRestoreInstanceState で値の保存と復元を行っています。

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

        SavedState ss = new SavedState(superState);

        ss.checked = isChecked();
        return ss;
    }

    @Override
    public void onRestoreInstanceState(Parcelable state) {
        SavedState ss = (SavedState) state;

        super.onRestoreInstanceState(ss.getSuperState());
        setChecked(ss.checked);
        requestLayout();
    }

この二つの関数は、View#dispatchSaveInstanceState,View#dispatchRestoreInstanceStateを見るとわかるように、View にid が振られていないと呼ばれません。

View.java
    protected void dispatchSaveInstanceState(SparseArray<Parcelable> container) {
        if (mID != NO_ID && (mViewFlags & SAVE_DISABLED_MASK) == 0) {
            ...
            Parcelable state = onSaveInstanceState();
            ...
            if (state != null) {
                ...
                container.put(mID, state);
            }
        }
    }

    protected void dispatchRestoreInstanceState(SparseArray<Parcelable> container) {
        if (mID != NO_ID) {
            Parcelable state = container.get(mID);
            if (state != null) {
                ...
                onRestoreInstanceState(state);
                ...
            }
        }
    }

ここで、mIDはView 自身のid を表しています。
onSaveInstanceStateで返したParcelableSparseArray<Parcelable>にid をキーとして保存され、onRestoreInstanceStateには同様にid をキーとして得られたParcelableが渡されます。

3. CustomView を使う場合

単純な場合にはView にid を振るだけでいいことがわかりました。
では、これらのView をまとめてCustomView とし、これを複数並べたときの問題について見てみます。
まず、適当にCustomView を用意します。

MyView
MyView.kt
class MyView : FrameLayout {

    private val binding: MyViewBinding = MyViewBinding.inflate(LayoutInflater.from(context), this, true)

    constructor(context: Context) : this(context, null)

    constructor(context: Context, attrs: AttributeSet?) : this(context, attrs, 0)

    constructor(context: Context, attrs: AttributeSet?, defStyle: Int) : super(context, attrs, defStyle)
}
my_view.xml
<layout ...>
    <androidx.constraintlayout.widget.ConstraintLayout ...>
        <CheckBox
            android:id="@+id/checkBox"
            ...
            />

        <EditText
            android:id="@+id/editText"
            ...
            />
    </androidx.constraintlayout.widget.ConstraintLayout>
</layout>

そして、これを並べたレイアウトを作ります。

fragment_three_my_view.xml
<layout ...>
    <androidx.constraintlayout.widget.ConstraintLayout ...>
        <io.github.warahiko.testrestorecustomviewapp.MyView
            android:id="@+id/myView1"
            ...
            />

        <io.github.warahiko.testrestorecustomviewapp.MyView
            android:id="@+id/myView2"
            ...
            />

        <io.github.warahiko.testrestorecustomviewapp.MyView
            android:id="@+id/myView3"
            ...
            />
    </androidx.constraintlayout.widget.ConstraintLayout>
</layout>

その結果が、最初に挙げた事故の二つ目です。全て同じになってしまいました。

どうなってる?

各View のid を確認してみます。

MyView.kt
    constructor(context: Context, attrs: AttributeSet?, defStyle: Int) : super(context, attrs, defStyle) {
        Log.i("idCheck", "checkBox: ${binding.checkBox.id.toString(16)}")
        Log.i("idCheck", "editText: ${binding.editText.id.toString(16)}")
    }

結果は次のようになりました。

I/idCheck: checkBox: 7f080069
I/idCheck: editText: 7f0800a1
I/idCheck: checkBox: 7f080069
I/idCheck: editText: 7f0800a1
I/idCheck: checkBox: 7f080069
I/idCheck: editText: 7f0800a1

一致してますね。
先ほど確認したように、Parcelableはid をキーとして保存されるため、保存の度に上書きされてしまいます。
その結果、最後に保存された状態が全てのMyView に復元されてしまったわけです。

どうする?

onSaveInstanceState, onRestoreInstanceStateを自分で実装してしまいます。

MyView.kt
class MyView : FrameLayout {
    ...
    override fun onSaveInstanceState(): Parcelable {
        return Bundle().apply {
            putParcelable(KEY_SUPER, super.onSaveInstanceState())
            putString(KEY_TEXT, binding.editText.text.toString())
            putBoolean(KEY_IS_CHECKED, binding.checkBox.isChecked)
        }
    }

    override fun onRestoreInstanceState(state: Parcelable?) {
        if (state is Bundle) {
            val superState = state.getParcelable<Parcelable>(KEY_SUPER)
            super.onRestoreInstanceState(superState)
            binding.editText.setText(state.getString(KEY_TEXT))
            binding.checkBox.isChecked = state.getBoolean(KEY_IS_CHECKED)
        } else {
            super.onRestoreInstanceState(state)
        }
    }

    companion object {
        private const val KEY_SUPER = "keySuper"
        private const val KEY_TEXT = "keyText"
        private const val KEY_IS_CHECKED = "keyIsChecked"
    }
}

しかし、これだけではうまくいきません。
ViewGroup#dispatchSaveInstanceState, ViewGroup#dispatchRestoreInstanceState を見てみると、自身の処理の後に子View の処理をしています。

ViewGroup.java
    @Override
    protected void dispatchSaveInstanceState(SparseArray<Parcelable> container) {
        super.dispatchSaveInstanceState(container);
        final int count = mChildrenCount;
        final View[] children = mChildren;
        for (int i = 0; i < count; i++) {
            View c = children[i];
            if ((c.mViewFlags & PARENT_SAVE_DISABLED_MASK) != PARENT_SAVE_DISABLED) {
                c.dispatchSaveInstanceState(container);
            }
        }
    }

    @Override
    protected void dispatchRestoreInstanceState(SparseArray<Parcelable> container) {
        super.dispatchRestoreInstanceState(container);
        final int count = mChildrenCount;
        final View[] children = mChildren;
        for (int i = 0; i < count; i++) {
            View c = children[i];
            if ((c.mViewFlags & PARENT_SAVE_DISABLED_MASK) != PARENT_SAVE_DISABLED) {
                c.dispatchRestoreInstanceState(container);
            }
        }
    }

そのため、自身のdispatchRestoreInstanceStateをoverride しても子View での処理で上書きされてしまいます。
そこで、自身だけの処理を行うdispatchFreezeSelfOnly, dispatchThawSelfOnlyを使います。

ViewGroup.java
    protected void dispatchFreezeSelfOnly(SparseArray<Parcelable> container) {
        super.dispatchSaveInstanceState(container);
    }

    protected void dispatchThawSelfOnly(SparseArray<Parcelable> container) {
        super.dispatchRestoreInstanceState(container);
    }

これを使って、MyView#dispatchSaveInstanceState, MyView#dispatchRestoreInstanceState もoverride します。

MyView.kt
    override fun dispatchSaveInstanceState(container: SparseArray<Parcelable>?) {
        dispatchFreezeSelfOnly(container)
    }

    override fun dispatchRestoreInstanceState(container: SparseArray<Parcelable>?) {
        dispatchThawSelfOnly(container)
    }

これにより復元がうまくできるようになりました:tada:

失敗例

API level 17 からView.generateViewId というメソッドが追加されています。
これは、レイアウト側で追加したid とぶつからない新しいid を生成します。

view.id = View.generateViewId()

これを使うことでview に新しいid を設定できますが、

  • 複数のview がある場合その分の処理が必要
  • コンストラクタに記述しても、再生成時にはコンストラクタが走ってまた違うid になってしまうため復元できない

ので、今回の用途には適していないと思われます。

4. さらにネストされている場合

以下のようなCustomView を作ってみます。

MySecondView
MySecondView.kt
class MySecondView : FrameLayout {

    constructor(context: Context) : this(context, null)

    constructor(context: Context, attrs: AttributeSet?) : this(context, attrs, 0)

    constructor(context: Context, attrs: AttributeSet?, defStyle: Int) : super(context, attrs, defStyle) {
        MySecondViewBinding.inflate(LayoutInflater.from(context), this, true)
    }
}
my_second_view.xml
<layout ...>
    <androidx.constraintlayout.widget.ConstraintLayout ...>
        <androidx.appcompat.widget.SwitchCompat
            android:id="@+id/switchCompat"
            ...
            />

        <io.github.warahiko.testrestorecustomviewapp.MyView
            android:id="@+id/myView"
            ...
            />
    </androidx.constraintlayout.widget.ConstraintLayout>
</layout>


そして、これを並べたレイアウトを作ります。
fragment_nested.xml
fragment_nested.xml
<layout ...>
    <androidx.constraintlayout.widget.ConstraintLayout ...>

        <io.github.warahiko.testrestorecustomviewapp.MySecondView
            android:id="@+id/mySecondView1"
            ...
            />

        <io.github.warahiko.testrestorecustomviewapp.MySecondView
            android:id="@+id/mySecondView2"
            ...
            />

        <io.github.warahiko.testrestorecustomviewapp.MySecondView
            android:id="@+id/mySecondView3"
            ...
            />
    </androidx.constraintlayout.widget.ConstraintLayout>
</layout>


これもやはりうまく状態を復元することができません。

SwitchCompat, MyView のid がそれぞれ全て一致しているため、onRestoreInstanceStateには同じ(最後に保存された)インスタンスが渡されてきます。
先ほどと同じようにして、MyViewの状態もMySecondViewで保存・復元をすることもできますが、わざわざそのような依存を作らずにMyViewの実装を使いたいですね。

しかし、onSaveInstanceStatedispatchSaveInstanceStateprotectedが付いているため呼ぶことができません。
ここで、渡したSparseArray<Parcelable>で値を保持・復元できるsaveHierarchyState,restoreHierarchyState が使えます。

View.java
    public void saveHierarchyState(SparseArray<Parcelable> container) {
        dispatchSaveInstanceState(container);
    }

    public void restoreHierarchyState(SparseArray<Parcelable> container) {
        dispatchRestoreInstanceState(container);
    }

まず、これを使ったViewGroupの拡張関数を定義します。

ViewGroupExt.kt
fun ViewGroup.saveChildStates(): SparseArray<Parcelable> {
    val childStates = SparseArray<Parcelable>()
    children.forEach { it.saveHierarchyState(childStates) }
    return childStates
}

fun ViewGroup.restoreChildStates(childStates: SparseArray<Parcelable>) {
    children.forEach { it.restoreHierarchyState(childStates) }
}

これらを用いて、MySecondViewの各関数をoverride していきます。

MySecondView.kt
    override fun dispatchSaveInstanceState(container: SparseArray<Parcelable>?) {
        dispatchFreezeSelfOnly(container)
    }

    override fun dispatchRestoreInstanceState(container: SparseArray<Parcelable>?) {
        dispatchThawSelfOnly(container)
    }

    override fun onSaveInstanceState(): Parcelable {
        return Bundle().apply {
            putParcelable(KEY_SUPER, super.onSaveInstanceState())
            putSparseParcelableArray(KEY_CHILDREN, saveChildStates())
        }
    }

    override fun onRestoreInstanceState(state: Parcelable?) {
        if (state is Bundle) {
            val superState = state.getParcelable<Parcelable>(KEY_SUPER)
            super.onRestoreInstanceState(superState)
            val childStates = state.getSparseParcelableArray<Parcelable>(KEY_CHILDREN) ?: return
            restoreChildStates(childStates)
        } else {
            super.onRestoreInstanceState(state)
        }
    }

    companion object {
        private const val KEY_SUPER = "keySuper"
        private const val KEY_CHILDREN = "keyChildren"
    }

これにより、うまく復元することができました:tada:

5. SavedState

TextViewCompoundButtonでのonSaveInstanceState, onRestoreInstanceStateでは、Bundleの代わりにBaseSavedStateを継承したSavedStateというクラスが実装されています。
View.BaseSavedState には

Base class for derived classes that want to save and restore their own state in {@link android.view.View#onSaveInstanceState()}.

とあるので推奨されているんですかね。
使い方としては、以下のように親クラスのonSaveInstanceStateの結果を渡してインスタンス化し、自身のonSaveInstanceStateの返値とします。
再生成時には、メンバであるsuperStateを親クラスのonRestoreInstanceStateに渡します。

MyThirdView.kt
class MyThirdView : FrameLayout {
    ...
    override fun onSaveInstanceState(): Parcelable {
        return SavedState(super.onSaveInstanceState(), saveChildStates())
    }

    override fun onRestoreInstanceState(state: Parcelable?) {
        if (state is SavedState) {
            super.onRestoreInstanceState(state.superState)
            state.childStates?.let { restoreChildStates(it) }
        } else {
            super.onRestoreInstanceState(state)
        }
    }

    internal class SavedState : BaseSavedState {
        var childStates: SparseArray<Parcelable>? = null
            private set

        constructor(superState: Parcelable?) : super(superState)

        constructor(superState: Parcelable?, childStates: SparseArray<Parcelable>) : super(superState) {
            this.childStates = childStates
        }

        constructor(source: Parcel) : super(source) {
            childStates = source.readSparseArray(javaClass.classLoader)
        }

        override fun writeToParcel(out: Parcel, flags: Int) {
            super.writeToParcel(out, flags)
            out.writeSparseArray(childStates)
        }

        companion object {
            @JvmField
            val CREATOR = object : Parcelable.Creator<SavedState> {
                override fun createFromParcel(source: Parcel): SavedState = SavedState(source)
                override fun newArray(size: Int): Array<SavedState?> = arrayOfNulls(size)
            }
        }
    }
}

ここで、親クラスの処理を行わないと、例えばViewで行われているActivityResult を受け取るための情報や、autofill の情報の復元が行われません。
また、

  • 書き込み時(writeToParcel)と読み込み時(constructor(source: Parcel))の順番を間違える
  • CREATORを実装しない

とそれぞれ実行時に落ちてしまうので注意が必要です。
実装は多少面倒ですが、このクラスを用いる利点として、親クラスの処理を忘れにくいこと、キーを用意してやるが必要ないことが挙げられます。1

6. おわりに

この現象について調べることになったきっかけとして、同じ画面に複数のViewPagerを配置しようとして、復元時に同じ画面になってしまったことがありました。
このあたりは浅く触っているとスルーしがちだと思うのでしっかり理解していきたいです。
今回作成したサンプルをこちらに置いておきます。

参考


  1. このブログにはmore intelligent memory managementとあり、画面回転時にはスパース配列に保存されずメモリに保持されるということが書かれていますが、これはBundleではMapに、SavedStateではメンバとして保持されているくらいの違いしかないと思っています。有識者に教えてほしい。 

19
12
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
19
12