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

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

### :rotating_light:事故例:rotating_light:
| 入力が消える | 全部同じになる |
|:-:|:-:|
| <img src=https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/193108/10fe47f3-67c7-a8b5-ad16-1c4ecf33958c.gif width=200px> | <img src=https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/193108/3ee87405-48a2-92c5-3d71-8fea1d4d9f7d.gif width=200px> |



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

## 環境
- Android Studio 4.1.1
- kotlin 1.4.32

# 2. 単一のレイアウトの場合
次のようにView がid なしで定義されているとき、最初に挙げた「入力が消える」動画のように状態は保持されません。

```xml: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 を振ってやると、それだけで状態が保持されます。

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

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

<img src=https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/193108/2b8a4ded-ff3d-d019-fc07-6b466851e04a.gif width=200px>

## どうなってる？
`CheckBox`の継承元である`CompoundButton`を見てみると、`onSaveInstanceState`と`onRestoreInstanceState` で値の保存と復元を行っています。

```java: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 が振られていないと呼ばれません。

```java: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`で返した`Parcelable`は`SparseArray<Parcelable>`にid をキーとして保存され、`onRestoreInstanceState`には同様にid をキーとして得られた`Parcelable`が渡されます。

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

```kotlin: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)
}
```

```xml:my_view.xml
<layout ...>
    <androidx.constraintlayout.widget.ConstraintLayout ...>
        <CheckBox
            android:id="@+id/checkBox"
            ...
            />

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

</div></details>

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

```xml: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>
```

その結果が、最初に挙げた事故の二つ目です。全て同じになってしまいました。
<img src=https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/193108/3ee87405-48a2-92c5-3d71-8fea1d4d9f7d.gif width=200px>

## どうなってる？
各View のid を確認してみます。

```kotlin: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`を自分で実装してしまいます。

```kotlin: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 の処理をしています。

```java: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`を使います。

```java: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 します。

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

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

これにより復元がうまくできるようになりました:tada:
<img src=https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/193108/dec0ff4e-cfa7-fa5e-c003-c32933c161d1.gif width=200px>

## 失敗例
API level 17 から[`View.generateViewId`](https://developer.android.com/reference/android/view/View#generateViewId()) というメソッドが追加されています。
これは、レイアウト側で追加したid とぶつからない新しいid を生成します。

```kotlin
view.id = View.generateViewId()
```

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

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

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

# 4. さらにネストされている場合
以下のようなCustomView を作ってみます。
<details><summary>MySecondView</summary><div>

```kotlin: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)
    }
}
```

```xml: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>
```

</div></details>
そして、これを並べたレイアウトを作ります。
<details><summary>fragment_nested.xml</summary><div>

```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>
```

</div></details>
これもやはりうまく状態を復元することができません。
<img src=https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/193108/933ef06e-7658-a02c-4a21-8bf3d02e4e8a.gif width=200px>

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

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

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

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

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

```kotlin: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 していきます。

```kotlin: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:
<img src=https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/193108/48454537-2f30-eed4-88e6-89dca0896c35.gif width=200px>

# 5. SavedState
`TextView`や`CompoundButton`での`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`に渡します。

```kotlin: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]

[^1]: [このブログ](https://www.netguru.com/codestories/how-to-correctly-save-the-state-of-a-custom-view-in-android)には`more intelligent memory management`とあり、画面回転時にはスパース配列に保存されずメモリに保持されるということが書かれていますが、これは`Bundle`では`Map`に、`SavedState`ではメンバとして保持されているくらいの違いしかないと思っています。有識者に教えてほしい。


<!---
これを用いることの利点として、画面回転時の保存・復元処理では`Parcel`への書き込み・読み出し処理が行われないことが挙げられます。
ログを表示して実際に確認してみます。
<details><summary>MySecondView</summary><div>

```kotlin:MySecondView.kt
    override fun onSaveInstanceState(): Parcelable {
        Log.i("withBundle", "onSaveInstanceState")
        return Bundle().apply {
            putParcelable(KEY_SUPER, super.onSaveInstanceState())
            Log.i("withBundle", "putSparseParcelableArray")
            putSparseParcelableArray(KEY_CHILDREN, saveChildStates())
        }
    }

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

</div></details>
<details><summary>MyThirdView</summary><div>

```kotlin:MyThirdView.kt
class MyThirdView : 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)
    }

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

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

    override fun onSaveInstanceState(): Parcelable {
        Log.i("withSavedState", "onSaveInstanceState")
        return SavedState(super.onSaveInstanceState(), saveChildStates())
    }

    override fun onRestoreInstanceState(state: Parcelable?) {
        Log.i("withSavedState", "onRestoreInstanceState")
        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) {
            Log.i("withSavedState", "readSparseArray")
            childStates = source.readSparseArray(javaClass.classLoader)
        }

        override fun writeToParcel(out: Parcel, flags: Int) {
            super.writeToParcel(out, flags)
            Log.i("withSavedState", "writeSparseArray")
            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)
            }
        }
    }
}
```

</div></details>

```text:画面回転の結果
I/withBundle: onSaveInstanceState
I/withBundle: putSparseParcelableArray
I/withBundle: onSaveInstanceState
I/withBundle: putSparseParcelableArray
I/withBundle: onSaveInstanceState
I/withBundle: putSparseParcelableArray
I/withBundle: onRestoreInstanceState
I/withBundle: getSparseParcelableArray
I/withBundle: onRestoreInstanceState
I/withBundle: getSparseParcelableArray
I/withBundle: onRestoreInstanceState
I/withBundle: getSparseParcelableArray

I/withSavedState: onSaveInstanceState
I/withSavedState: onSaveInstanceState
I/withSavedState: onSaveInstanceState
I/withSavedState: onRestoreInstanceState
I/withSavedState: onRestoreInstanceState
I/withSavedState: onRestoreInstanceState
```

```text:Activity破棄時の結果
I/withBundle: onSaveInstanceState
I/withBundle: putSparseParcelableArray
I/withBundle: onSaveInstanceState
I/withBundle: putSparseParcelableArray
I/withBundle: onSaveInstanceState
I/withBundle: putSparseParcelableArray
I/withBundle: onRestoreInstanceState
I/withBundle: getSparseParcelableArray
I/withBundle: onRestoreInstanceState
I/withBundle: getSparseParcelableArray
I/withBundle: onRestoreInstanceState
I/withBundle: getSparseParcelableArray

I/withSavedState: onSaveInstanceState
I/withSavedState: onSaveInstanceState
I/withSavedState: onSaveInstanceState
I/withSavedState: writeSparseArray
I/withSavedState: writeSparseArray
I/withSavedState: writeSparseArray
I/withSavedState: readSparseArray
I/withSavedState: readSparseArray
I/withSavedState: readSparseArray
I/withSavedState: onRestoreInstanceState
I/withSavedState: onRestoreInstanceState
I/withSavedState: onRestoreInstanceState
```

`SavedState`を使う方では、Activity破棄時に処理されていた`writeSparseArray`, `readSparseArray`の部分が画面回転時には行われていません。
-->

# 6. おわりに
この現象について調べることになったきっかけとして、同じ画面に複数の`ViewPager`を配置しようとして、復元時に同じ画面になってしまったことがありました。
このあたりは浅く触っているとスルーしがちだと思うのでしっかり理解していきたいです。
今回作成したサンプルを[こちら](https://github.com/warahiko/TestRestoreCustomViewApp)に置いておきます。

## 参考
- https://www.netguru.com/codestories/how-to-correctly-save-the-state-of-a-custom-view-in-android
    - 大いに参考にさせていただきました
- https://y-anz-m.blogspot.com/2010/03/androidparcelable.html
    - `Parcelable` の作り方について
- https://y-anz-m.blogspot.com/2012/04/androidfragment-view.html
- https://qiita.com/KazaKago/items/758076137e8d4a962dd0#5-%E7%94%BB%E9%9D%A2%E5%9B%9E%E8%BB%A2%E7%AD%89%E3%81%AEactivity%E5%86%8D%E7%94%9F%E6%88%90%E5%AF%BE%E5%BF%9C