1. はじめに
Activity が破棄されたときや画面回転が行われたときに、ビューの状態が保存できていないとユーザが入力したり選択した内容が復元されません。
Activity の破棄は、端末設定の System
-> Developer options
-> Don't keep activities
を有効にすることで、アプリをバックグラウンドにするだけで簡単に確認が可能です。
本記事では、このような状態の消失を正しく防ぐ方法を記述します。
事故例
入力が消える | 全部同じになる |
---|---|
まとめると?
- View にid を振ろう
-
dispatchFreezeSelfOnly
,dispatchThawSelfOnly
をうまく使おう - CustomView では
SavedState
を作ると良さそう
環境
- Android Studio 4.1.1
- kotlin 1.4.32
2. 単一のレイアウトの場合
次のようにView がid なしで定義されているとき、最初に挙げた「入力が消える」動画のように状態は保持されません。
<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 を振ってやると、それだけで状態が保持されます。
<layout ...>
<LinearLayout ...>
<CheckBox
android:id="@+id/checkBox"
...
/>
<EditText
android:id="@+id/editText"
...
/>
</LinearLayout>
</layout>
どうなってる?
CheckBox
の継承元であるCompoundButton
を見てみると、onSaveInstanceState
とonRestoreInstanceState
で値の保存と復元を行っています。
@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 が振られていないと呼ばれません。
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 を用意します。
MyView
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)
}
<layout ...>
<androidx.constraintlayout.widget.ConstraintLayout ...>
<CheckBox
android:id="@+id/checkBox"
...
/>
<EditText
android:id="@+id/editText"
...
/>
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>
そして、これを並べたレイアウトを作ります。
<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 を確認してみます。
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
を自分で実装してしまいます。
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 の処理をしています。
@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
を使います。
protected void dispatchFreezeSelfOnly(SparseArray<Parcelable> container) {
super.dispatchSaveInstanceState(container);
}
protected void dispatchThawSelfOnly(SparseArray<Parcelable> container) {
super.dispatchRestoreInstanceState(container);
}
これを使って、MyView#dispatchSaveInstanceState
, MyView#dispatchRestoreInstanceState
もoverride します。
override fun dispatchSaveInstanceState(container: SparseArray<Parcelable>?) {
dispatchFreezeSelfOnly(container)
}
override fun dispatchRestoreInstanceState(container: SparseArray<Parcelable>?) {
dispatchThawSelfOnly(container)
}
失敗例
API level 17 からView.generateViewId
というメソッドが追加されています。
これは、レイアウト側で追加したid とぶつからない新しいid を生成します。
view.id = View.generateViewId()
これを使うことでview に新しいid を設定できますが、
- 複数のview がある場合その分の処理が必要
- コンストラクタに記述しても、再生成時にはコンストラクタが走ってまた違うid になってしまうため復元できない
ので、今回の用途には適していないと思われます。
4. さらにネストされている場合
以下のようなCustomView を作ってみます。
MySecondView
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)
}
}
<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
<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
の実装を使いたいですね。
しかし、onSaveInstanceState
とdispatchSaveInstanceState
はprotected
が付いているため呼ぶことができません。
ここで、渡したSparseArray<Parcelable>
で値を保持・復元できるsaveHierarchyState
,restoreHierarchyState
が使えます。
public void saveHierarchyState(SparseArray<Parcelable> container) {
dispatchSaveInstanceState(container);
}
public void restoreHierarchyState(SparseArray<Parcelable> container) {
dispatchRestoreInstanceState(container);
}
まず、これを使ったViewGroup
の拡張関数を定義します。
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 していきます。
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"
}
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
に渡します。
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
を配置しようとして、復元時に同じ画面になってしまったことがありました。
このあたりは浅く触っているとスルーしがちだと思うのでしっかり理解していきたいです。
今回作成したサンプルをこちらに置いておきます。
参考
-
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