Back to Insights

June 7, 2023

Android developers – Here are the best practices for saving the UI state

Check out some tips on how to save the UI state during Android development.

Android developers – Here are the best practices for saving the UI state

This year’s Google I/O event was an exciting showcase of new details and announcements that are crucial for Android developers to stay informed about.

To assist the developer community, our App Solutions Studio has reviewed the various talks, and we are thrilled to share a series of blog posts highlighting the most captivating updates.

Our first blog post revolves around the topic of managing UI state in apps. We delve into the best practices for avoiding UI state loss and implementing effective strategies for saving the UI state. For a comprehensive understanding, here is the link to the full video of the talk on YouTube.

How an app can lose the UI state

There are several ways in which this can happen.

  • Configuration changes. Activity is recreated and initialized with a new configuration. This can occur due to rotation, resize, entering or leaving multi-window mode, or switching between light and dark mode. More information on runtime changes can be found on the Android Developer documentation.
  • System needs resources and the app is in the background.
  • Unexpected app dismissal. The app may be unexpectedly closed by the system when it requires resources.
  • Swiping off the app from recent apps. Users can manually remove the app from the recent apps list, causing the UI state to be lost.
  • Force quit. In some cases, users may force quit the app, leading to the loss of UI state.

The best practices to save the state in each case

Configuration changes

  • ViewModel: Use ViewModels, even if you’re not using them directly, to survive configuration changes. Try to utilize ViewModels under the hood.
  • Services configuration changes: Save the state in memory, limited by available memory. This provides quick read/write access. Navigation caches the state if the destination is in the backstack.

Unexpected app dismissal

  • Persistent storage (locally): Use persistent storage solutions to save the state locally. There are two types:
    • DataStore: Recommended for small or simple datasets, acting as a replacement for SharedPreferences.
    • Room: Suitable for large or complex datasets, allowing partial updates and ensuring referential integrity. It survives configuration changes, system needed resources, and unexpected app dismissal.
  • On Disk: While this can be used to store UI data, it’s not recommended due to slow read/write times. It is usually used for application data and is limited by disk space.

System needs resources

  • Saved State APIs: Available for Compose, ViewModels, and the View system. Save the state in memory as a serialized copy of your data. It is limited by the Bundle (do not store more than 50 KB) or an exception will be thrown. The read/write speed depends on the size of the bundle and the complexity of the data type. Avoid storing large objects or lists, as they can consume a lot of memory. Typically, data stored in the save state is transient and depends on navigation or user input, such as scroll position, item ID in a detail screen, in-progress selection of user preferences, or input in text fields.

View system

  • onSaveInstanceState: Override this method to save the state of the View system.
  • onRestoreInstanceState: Override this method to restore the previously saved state.
class ChatBubbleView(context: Context, ...) : View(context, ...) {

    private var isExpanded = false

    override fun onSaveInstanceState(): Parcelable {
        super.onSaveInstanceState()
        return bundleOf(IS_EXPANDED to isExpanded)
    } 

    override fun onRestoreInstanceState(state: Parcelable) {
        isExpanded = (state as Bundle).getBoolean(IS_EXPANDED)
        super.onRestoreInstanceState(null)
    }

    companion object {
        private const val IS_EXPANDED = "is_expanded"
    }
}

To test activity recreation

  • ActivityScenario.recreate(): Use this method to test activity recreation.

Jetpack Compose

  • rememberSaveable: Use this function in Jetpack Compose to save and restore state.
@Composable
fun ChatBubble(
    message: Message
) { 
    var showDetails by rememberSaveable { mutableStateOf(false) }

    ClickableText(
        text = AnnotatedString(message.content),
        onClick = { showDetails = !showDetails }
    )

    if (showDetails) {
        Text(message.timestamp)
    }
}

ViewModel

  • SavedStateHandle: Inject this object into the ViewModel constructor. It saves data when the Activity is stopped.
class ConversationViewModel (
    savedStateHandle: SavedStateHandle
) : ViewModel() {

    var message by savedStateHandle.saveable(stateSaver = TextFieldValue.Saver) {
        mutableStateOf(TextFieldValue(""))
    }
        private set

    fun update(newMessage: TextFieldValue) {
        message = newMessage
    }

    fun send() { /* Send current message to the data layer */ }

    /*...*/
}

For more information on ViewModel and SavedStateHandle, refer to the Android Developer documentation.

Using UI saving state under the hood with advanced use cases

First use case: Contribute to saved state from your own classes

Jetpack Compose:

Imagine you have a NewsSearchState with a mutable state searchInput:

class NewsSearchState(
    private val newsRepository: NewsRepository,
    initialSearchInput: String
) {

  var searchInput = mutableStateOf(TextFieldValue(initialSearchInput))
    private set

}

You can create a new composable rememberNewsSearchState which is kind of a wrapper that uses rememberSavable under the hood, but with a custom saver.

@Composable
fun rememberNewsSearchState(
    newsRepository: NewsRepository,
    initialSearchInput: String = ""
) {
    return rememberSaveable(
        newsRepository, initialSearchInput,
        saver = // TODO: Custom Saver
    ) {
        NewsSearchState(newsRepository, initialSearchInput)
    }
}

Here is the NewsSearchState modified with the new saver function. Note that it creates a Saver object with a save and restore properties.

class NewsSearchState(
    private val newsRepository: NewsRepository,
    initialSearchInput: String
) {
    var searchInput = mutableStateOf(TextFieldvalue(initialSearchInput))
    private set

    companion object {
        fun saver (newsRepository: NewsRepository): Saver<NewsSearchState, *> = Saver(
            save = {
                with(TextFieldValue.Saver) { save(it.searchInput)}
            },
            restore = {
                TextFieldValue.Saver.restore(it)?.let { searchInput ->
                    NewsSearchState(newsRepository, searchInput)
                }
            }
        )
    }
}

And the usage is the following.

@Composable
fun rememberNewsSearchState(
    newsRepository: NewsRepository,
    initialSearchInput: String = ""
) {
  return rememberSaveable(
    newsRepository, initialSearchInput,
    saver = NewsSearchState.saver(newsRepository)
  ) {
    NewsSearchState(newsRepository, initialSearchInput)
  }
}

View system

Here we have the same situation. Suppose that we have a NewsSearchState.

class NewsSearchState(
    private val newsRepository: NewsRepository,
    private val initialSearchInput: String,
) {
  private var currentQuery: String = initialSearchInput
    // ... Rest of business logic ...
}

As we are not in a ViewModel we cannot use SaveStateHandler and as we are not in a fragment or in an Activity we cannot use onSaveinstanceState/onRestoreInstancesState. We have to make use of SavedStateRegistry. By overriding the saveState method in our class would leave something like:

class NewsSearchState(
    private val newsRepository: NewsRepository,
    private val initialSearchInput: String,

) : SavedStateRegistry.SavedStateProvider {

    private var currentQuery: String = initialSearchInput

    // ... Rest of business logic ...

    override fun saveState(): Bundle {
        return bundleOf(QUERY to currentQuery)
    }

    companion object {
        private const val QUERY = "current_query"
        private const val PROVIDER = "news_search_state"
    }
}

Now we have to connect this with a registry owner that we pass a parameter in the constructor.

class NewsSearchState(
    private val newsRepository: NewsRepository,
    private val initialSearchInput: String,
    registryOwner: SavedStateRegistryOwner
) : SavedStateRegistry.SavedStateProvider {

    private var currentQuery: String = initialSearchInput

    init {
        registryOwner.lifecycle.addObserver(LifecycleEventObserver { _, event ->
            if (event == Lifecycle.Event.ON_CREATE) {
                val registry = registryOwner.savedStateRegistry
                if (registry.getSavedStateProvider(PROVIDER) == null ) {
                    registry.registerSavedStateProvider(PROVIDER, this)
                }
                val savedState = registry.consumeRestoredStateForKey(PROVIDER)
                currentQuery = savedState?.getString(QUERY) ?: initialSearchInput
            }
        })
    }
}

The usage of this State in a fragment is as simple as:

class NewsFragment : Fragment() {

    private var newsSearchState = NewsSearchState(this)
    ...

}

Note of caution: We took these images from the video, but this example is missing the passing of the repository and search input as parameters. The search input can also be handle as a property, for more information: Android Developer documentation.

Second use case: Control rembemberSavable value’s lifecycle

First remember the Composable lifecycle. The composable enters the composition, can recompose 0 or more times and finally leave the composition.

Second use case: Control rembemberSavable value’s lifecycle

It means that when the UI enters the composition the rememberSavable values are stored in Saved State. If a configuration change happens and the activity is recreated, the old composition is destroyed, a new composition is created and rememberSavable values are restored.

Note that rememberSavableValues are restored, but values using the remember API won’t. They are lost after activity is recreated.

Lastly when the composable leaves the composition the rememberSavable values are removed from Saved State.

when the composable leaves the composition the rememberSavable values are removed from Saved State.

This is the default behavior. But how can we modify this? This can also be done with SavableStateRegistry (remember that we saw it when we talked about saving state in View system).

Take a look at the current existing rememberSavable composable.

// androidx/compose/runtime/saveable/RememberSaveable.kt
@Composable
fun <T : Any> rememberSaveable(
    vararg inputs: Any?,
    saver: Saver<T, out Any> = autoSaver(),
    key: String? = null,
    init: () -> T
): T {
    // ...

    val registry = LocalSaveableStateRegistry.current
    val value = remember (*inputs) {
        val restored = registry?.consumeRestored(finalKey)?.let {
            saver.restore(it)
        }
        restored ?: init()
    }
    // ...
}

It accesses the current SavableState registry and is initialized by calling consumeRestored from it. If there was no value previously stored it is initialized with the init LaMDA. So, if we define a new SavableStateRegistry we can control for how long rememberSavable stores their values. This is what Jetpack’s compose navigation library does. Check out the following part of the video to see the explanation.

See explanation on YouTube

Summary

In conclusion, when developing Android apps, it’s crucial to understand how the UI state can be lost, and implement effective strategies to save and restore it. By following best practices, developers can ensure a much improved user experience and prevent data loss.

Android developers – Here are the best practices for saving the UI state

andres de la grana

By Andres De La Grana

Mobile Developer

Andres de la Grana is a Mobile Developer at Qubika. With 5+ years of experience, Andres is passionate about Android development. He has worked on various scalable applications with lots of users, and believes in creating clean code and following best practices.

News and things that inspire us

Receive regular updates about our latest work

Let’s work together

Get in touch with our experts to review your idea or product, and discuss options for the best approach

Get in touch