All Ruby on Rails Node JS Android iOS React Native Frontend Flutter QA

How to Correctly Save the State of a Custom View in Android

Some time ago I came across one problem related to the correct recreation of the state in the view. I tested various solutions on a separate project to learn about possible solutions. However, before I describe the exact problem that I came across, I will start with the complete basics. At the very beginning, let's try to answer the question - why should we actually save the state of the views?
Imagine a situation in which you fill a large questionnaire in a certain application on your smartphone . At some point, you accidentally rotate the screen or you want to check something in another application and, after returning to the questionnaire, it turns out that all the fields are empty. Such a situation effectively discourage users from using the application. For the purposes of this example, let's create a view: 

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="horizontal">

    <androidx.appcompat.widget.SwitchCompat
        android:layout_width="wrap_content"
        android:layout_height="wrap_content" />

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

And class:

class CustomSwitchViewNoId: FrameLayout {

    // constructors

    init {
        LayoutInflater.from(context).inflate(R.layout.view_custom_switch_no_id, this)
    }
}

Instead of rotating the screen every time to check the effect, we can enable the appropriate option in the developer settings of the phone (Settings -> Developer options -> Don’t keep activities -> turn it on).

view_state_1_no_id

As you can see - the state has not been saved. Let’s recall the hierarchy that is called by Android to save and read the state:

  • Save state
    • saveHierarchyState(SparseArray<Parcelable> container)
    • dispatchSaveInstanceState(SparseArray<Parcelable> container)
    • onSaveInstanceState()
  • Restore state
    • restoreHierarchyState(SparseArray<Parcelable> container)
    • dispatchRestoreInstanceState(SparseArray<Parcelable> container)
    • onRestoreInstanceState(Parcelable state)

 

Let’s investigate the internal implementation of saveHierarchyState:

public void saveHierarchyState(SparseArray container) {
    dispatchSaveInstanceState(container);
}

Which leads us to dispatchSaveInstanceState:

@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);
        }
    }
}

That calls for the container and every child  dispatchSaveInstanceState(container):

protected void dispatchSaveInstanceState(SparseArray<Parcelable> container) {
    if (mID != NO_ID && (mViewFlags & SAVE_DISABLED_MASK) == 0) {
        mPrivateFlags &= ~PFLAG_SAVE_STATE_CALLED;
        Parcelable state = onSaveInstanceState();
        if ((mPrivateFlags & PFLAG_SAVE_STATE_CALLED) == 0) {
            throw new IllegalStateException(
                    "Derived class did not call super.onSaveInstanceState()");
        }
        if (state != null) {
            // Log.i("View", "Freezing #" + Integer.toHexString(mID)
            // + ": " + state);
            container.put(mID, state);
        }
    }
}

As we can see in the 3rd line, there is check if the view has an ID assigned to call  onSaveInstanceState() and put the state into the container, where the ID is a key.

Let's add the needed ID and see what happens:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="horizontal">

    <androidx.appcompat.widget.SwitchCompat
        android:id="@+id/customViewSwitchCompat"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content" />

    <EditText
        android:id="@+id/customViewEditText"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_weight="1" />
</LinearLayout>

view_state_2_with_id

It works! Great, let’s add two more our custom views. In the end, we created this whole thing so as not to have to duplicate the code and check if everything is still working.

view_state_3_with_id_multipleWait… what just happened? Well, looking at the implementation of saveHierarchyState and dispatchSaveInstanceState, we can observe that every state is stored in one container and is shared for the entire view hierarchy. Let's draw up the current hierarchy:

view_state_graph_1

As you can see, the tags @+id/customViewSwitch and @+id/customViewEditText and  are repeated, so the generated sparse array saves the children of @+id/switch1, then @+id/switch2, and finally @+id/switch3.

From the documentation, we are able to learn that SparseArray is:

SparseArray maps integers to Objects and, unlike a normal array of Objects, its indices can contain gaps. SparseArray is intended to be more memory-efficient than a HashMap , because it avoids auto-boxing keys and its data structure doesn’t rely on an extra entry object for each mapping.

Let's check how the container behaves when we pass the same key again:

val array = SparseArray()
array.put(1, "test1")
array.put(1, "test2")
array.put(1, "test3")
Log.i("TAG", array.toString())

The result is:

I/TAG: {1=test3}

So new values overwrite the previous ones. Considering the fact that the key in the previous case is ID, this explains why each of the views received the state of the last child.  

view_state_graph_2

However, how can we avoid this? I would like to introduce you to two different solutions.

At the beginning, we should overwrite 2 callbacks: dispatchSaveInstanceState and dispatchRestoreInstanceState:

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

This way, super.onSaveInstanceState() will only return the super state, avoiding children views.

We are now ready to handle the state inside onSaveInstanceState and onRestoreInstanceState. Let’s start with the saving process:

override fun onSaveInstanceState(): Parcelable? {
    return Bundle().apply {
        putParcelable(SUPER_STATE_KEY, super.onSaveInstanceState())
        putSparseParcelableArray(SPARSE_STATE_KEY, saveChildViewStates())
    }
}

And of course the keys:

companion object {
    private const val SPARSE_STATE_KEY = "SPARSE_STATE_KEY"
    private const val SUPER_STATE_KEY = "SUPER_STATE_KEY"
}

In the examples above, I write the super state returned by super.onSaveInstanceState() to a parcelable with the key SUPER_STATE_KEY and then create a custom sparse array the with state of every child in the view using the saveChildViewStates() extension function and saving it with a SPARSE_STATE_KEY key:

fun ViewGroup.saveChildViewStates(): SparseArray<Parcelable> {
    val childViewStates = SparseArray<Parcelable>()
    children.forEach { child -> child.saveHierarchyState(childViewStates) }
    return childViewStates
}

Now It’s time to restore the state: 

override fun onRestoreInstanceState(state: Parcelable?) {
    var newState = state
    if (newState is Bundle) {
        val childrenState = newState.getSparseParcelableArray<Parcelable>(SPARSE_STATE_KEY)
        childrenState?.let { restoreChildViewStates(it) }
        newState = newState.getParcelable(SUPER_STATE_KEY)
    }
    super.onRestoreInstanceState(newState)
}

In the example above, I get the previously saved super state and pass it to the  super.onRestoreInstanceState(). After getting the created sparse array using SPARSE_STATE_KEY, I use another extension function restoreChildViewStates:

fun ViewGroup.restoreChildViewStates(childViewStates: SparseArray<Parcelable>) {
    children.forEach { child -> child.restoreHierarchyState(childViewStates) }
}

In this example, I call the restoreHierarchyState function for each child to which I pass the previously saved SparseArray.  Let’s check how the hierarchy looks now:

view_state_graph_3

And check if everything is working:

view_state_4_save_state_by_hand

Everything works well! This is one of the ways to save the state of your own view, but there is another interesting solution using the BaseSavedState class.

 

At the beginning, we should override the functions dispatchSaveInstanceState and dispatchRestoreInstanceState in the same way as we did before and then create an internal class that extends BaseSavedState:

internal class SavedState : BaseSavedState {

    var childrenStates: SparseArray<Parcelable>? = null

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

    constructor(source: Parcel) : super(source) {
        childrenStates = source.readSparseArray(javaClass.classLoader) as SparseArray<Parcelable>?
    }

    override fun writeToParcel(out: Parcel, flags: Int) {
        super.writeToParcel(out, flags)
        out.writeSparseArray(childrenStates as SparseArray<Any>)
    }

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

Then create an internal variable childrenStates with the children’s states, handle saving the state inside writeToParcel, and write an inside constructor. It’s crucial to create a CREATOR object and override createFromParcel to provide the SavedState constructor and a newArray to provide an empty array of nulls.

The last thing is to override and handle onSaveInstanceState and onRestoreInstanceState:

public override fun onSaveInstanceState(): Parcelable? {
    return SavedState(super.onSaveInstanceState()).apply {
        childrenStates = saveChildViewStates()
    }
}

I just pass the super state and set childrenStates using the saveChildViewStates extension function.

public override fun onRestoreInstanceState(state: Parcelable) {
    when (state) {
        is SavedState -> {
            super.onRestoreInstanceState(state.superState)
            state.childrenStates?.let { restoreChildViewStates(it) }
        }
        else -> super.onRestoreInstanceState(state)
    }
}

Inside onRestoreInstanceState, I check if the provided parcelable is SavedState and then call super.onRestoreInstanceState providing superState, retrieve  childrenStates, and pass it to the restoreChildViewStates extension function. Let's check if everything is working properly:

view_state_5_all_working

The advantages of using BaseSavedState are, among others, automatic superState handling and more intelligent memory management. Let’s compare the operation of both methods in the case of screen rotation and real loss of state (from developer settings). In each of the two methods, I put the logs at the time of calling onSaveInstanceStateonRestoreInstanceState and the places where the children's status is written to and read from the sparse array. I tagged the first method with the ByHand log tag, and the second with the SavedState tag:

Logs from rotating the screen:

I/ByHand: onSaveInstanceState
I/ByHand: Writing children state to sparse array
I/ByHand: onSaveInstanceState
I/ByHand: Writing children state to sparse array
I/ByHand: onSaveInstanceState
I/ByHand: Writing children state to sparse array
I/SavedState: onSaveInstanceState
I/SavedState: onSaveInstanceState
I/SavedState: onSaveInstanceState

I/ByHand: onRestoreInstanceState
I/ByHand: Reading children children state from sparse array
I/ByHand: onRestoreInstanceState
I/ByHand: Reading children children state from sparse array
I/ByHand: onRestoreInstanceState
I/ByHand: Reading children children state from sparse array
I/SavedState: onRestoreInstanceState
I/SavedState: onRestoreInstanceState
I/SavedState: onRestoreInstanceState

And from losing state using developer settings:

I/ByHand: onSaveInstanceState
I/ByHand: Writing children state to sparse array
I/ByHand: onSaveInstanceState
I/ByHand: Writing children state to sparse array
I/ByHand: onSaveInstanceState
I/ByHand: Writing children state to sparse array
I/SavedState: onSaveInstanceState
I/SavedState: onSaveInstanceState
I/SavedState: onSaveInstanceState
I/SavedState: Writing children state to sparse array
I/SavedState: Writing children state to sparse array
I/SavedState: Writing children state to sparse array

I/SavedState: Reading children children state from sparse array
I/SavedState: Reading children children state from sparse array
I/SavedState: Reading children children state from sparse array
I/ByHand: onRestoreInstanceState
I/ByHand: Reading children children state from sparse array
I/ByHand: onRestoreInstanceState
I/ByHand: Reading children children state from sparse array
I/ByHand: onRestoreInstanceState
I/ByHand: Reading children children state from sparse array
I/SavedState: onRestoreInstanceState
I/SavedState: onRestoreInstanceState
I/SavedState: onRestoreInstanceState

It’s worth noting that when the screen is rotated using BaseSavedState, the state of children not saved to the sparse array, but kept in memory, which I think is a good solution taking into consideration the fact that during screen rotation the system doesn’t really need to release all the memory which does not cause unnecessary overhead.

As we can see now, saving the state of your own view is not a particularly complicated process, and it significantly improves the user's experience.

Working code can be found in the following repository here.

We're building our future. Let's do this right - join us
READ ALSO FROM Kotlin
Read also
Need a successful project?
Estimate project or contact us