Best practices for saving UI state on Android

Video Statistics and Information

Video
Captions Word Cloud
Reddit Comments
Captions
[MUSIC PLAYING] MANUEL VICENTE VIVO: Hi, everyone. I'm Manuel Vivo, part of the Android Developer Relations team. When an activity or app is destroyed and recreated, you must restore the UI state quickly to provide a good user experience. In most of these cases, the user expects the UI state to remain the same. In this talk, we'll cover how your app can lose state and how to avoid it by providing best practices for saving your state. We'll also see how some solutions work under the hood and how you can apply that knowledge to solve advanced use cases. And lastly, a recap to make sure you didn't miss anything. So how can an app lose UI state? Firstly, through configuration changes. Some device configurations can change while the app is running as a device rotates, resizes, enters and leaves multi-window mode, or the user switches to light or dark mode. When the configuration changes, by default, the activity will be recreated and initialized with a new configuration. But even if you can avoid activity recreation in some cases in a manifest file, it is impossible to completely opt out of activity recreation. Some configuration changes will always recreate the activity. To learn more about this and why it happens, check out the Handling Configuration Changes documentation. By the way, the app you see on the screen, that's the Now in Android app. It's open source, and you can take a look at the code on GitHub. Another way to lose app state is when the system needs resources, and your app is in the background. If the system is low on resources, it will do its best to keep your app processing memory. However, that's not guaranteed. The system may destroy it while the user is away interacting with other apps. And, boom, it's gone. Lastly, the user or the system can destroy your application abruptly. The user can swipe your app off in the resident screen, can force quit your app. The app might be updated in the background. So many different things can happen. For each of these scenarios, there are ways to save your state. Let's dive into them. As I mentioned earlier, it is impossible to completely opt out of activity recreation. So you need to ensure that your app can react appropriately to configuration changes. You cannot just lock your orientation to portrait and hope for the best. OK, but I like that. To make data survive conflict changes, use the ViewModel API. ViewModel instances are cached in memory when the owner goes through a configuration change. This makes the ViewModel's data remain intact after the config change happens. ViewModels are only limited by the available memory in the device, and reads and writes to a memory state in ViewModels are fast. In addition to this, the navigation library also caches ViewModels when the destination is kept in the back stack, which is a nice touch, given that the data will be instantly available when the user goes back to the destination. Because all of this, ViewModels are the recommended solution for making your state survive conflict changes. You can see an example of that in this code snippet. We have a ViewModel as a stakeholder for a particular screen that exposes screen UI state to the UI. The screen UI state is built with information from other layers of the hierarchy and will still be there after a config change. Note that in the previous code snippet, we were using ViewModels for two purposes-- first, as a mechanism to make data survive config changes and, second, as a screen-level state holder that exposes data to the UI. If you don't use ViewModel as a state holder, that's fine. But consider using ViewModel under the hood to make data survive config changes. The ViewModel API is the only supported way to make large and arbitrary objects survive configuration changes on Android in the scope of an activity, fragment, or a navigation destination. Before diving into the system needing resources, let's cover unexpected app dismissals first. It is a more common use case. And it's going to help us understand the other use case better. For data to survive unexpected app dismissals, we have to use a completely different approach. Instead of memory, we need to persist our information to disk. Persisting the data over the network in your own servers is also another option. But in this talk, we are just covering persisting locally on the device. For that, we have two APIs available in Jetpack, DataStore and Room. DataStore is ideal for small or simple data sets. And you should consider Room if your data is well structured and has needs for partial updates, referential integrity, or is a large or complex data set. Persistent storage also survives conflict changes and the system needing resources. It stores the information on disk, making the solution limited by disk space. And because it requires I/O operations, it has slow reads and writes. You would usually store application data on disk. Due to how slow the solution is, it wouldn't make sense to generally store UI state. UI state is dynamic. It can change quite often. If you stored UI state directly on disk, your app could be slow to respond to changes in the UI. However, application data is totally dependent on the business requirements of your app. For some apps, some UI states might be considered application data. The last area we need to cover is when the system needs resources. In this case, the system might be in a critical situation, and it might kill your process. Then it will recreate it at some point in the future when the user goes back to your app again. We don't have a problem with application data because it's persisted on disk. However, UI state is in memory, and we are going to lose it all. Luckily, to not completely damage the user experience, Android provides a mechanism to save essential data so that the user can return to the state they were in before the process was recreated. This solution is the saved state APIs, which rely on Android bundled objects under the hood. There are APIs for Jetpack Compose, the view system, and ViewModels. We'll look at each of them later. The system persists saved state bundles through both configuration changes and when the system needs resources. The bundle's stored in memory. Android keeps a serialized copy of the data in memory outside of your process. The size of the bundle is limited, so use it to store a minimal amount of data necessary. Trying to store large objects could lead to runtime exceptions. We recommend that you don't store more than 50 kilobytes. Due to the need of serialization and deserialization, the read and write times could be slow. The time depends on the complexity of their types and the size of the bundle. The system might even try to optimize this and leave the same bundle object in memory without serialization for quicker access. But these behaviors might change across Android API versions. So please, don't store large objects or lists. Serialization could consume a lot of memory if the objects being serialized are complex and this process happens on the main thread. Remember that the system might be in a critical situation. Usually, data stored in safe state is transient state that depends on user input or navigation. Examples of this can be the scroll position of at list, the ID of the item the user wants more detail about, the in-progress selection of user preferences, or input in text fields. There are different APIs for each UI toolkit. If you are using Compose, use the rememberSaveable API, and in the view system, the onSaveInstanceState callback. You could use these APIs when the state is needed by UI logic-- for example, when tracking whether or not a UI element is expanded. The Compose code we have on the screen is for a text message. Tapping on the text shows or hides more details. The show details variable uses rememberSaveable. This makes it survive configuration changes and when the system needs resources. In the View system, you could have a custom view like the following with the same isExpanded Boolean. To save state, you would overwrite the unsaved instance state method, returning a bundle. To restore state, you get the bundle from the onRestoreInstanceState method. Something to watch out for is that the view must have the isSaveEnabled property set to true, and it needs to have a unique ID. To create automated tests for these behaviors, you can use the StateRestorationTester in Compose and the ActivityScenario.recreate function in the View system. In the following code, we are testing the Compose code we showed earlier using the Create Compose rule function and different Compose testing APIs. See how we are creating a state restoration test instance passing in the Compose test rule and how we use the emulate saved instance state to restore function to test that remembered saveable behavior. These APIs makes sense when state is part of the UI logic because your state is present in the UI. However, when state is needed by business logic, your state will likely be present in screen-level state holders. If you are using ViewModels as a state holder to handle the business logic complexity of the UI, you have to use the SavedStateHandle API instead to contribute to saved state. Here, we can see a ViewModel that holds the message the user is currently typing in a conversation. It's using the saveable function, which is SavedStateHandle's integration with Compose state. SavedStateHandle also provides integrations with other streams of data, like state flow. But something to keep in mind when working with SavedStateHandle is that it only saves data returned to it when the activity is stopped. If you update it when the app is in the background, the system will store the data the next time the activity is stopped. For more information about SavedStateHandle, check out the documentation. As a summary, here is a table of the saved state APIs we recommend, depending on the type of logic that you applied to the data-- rememberSaveable or onSaveInstanceState if you are using the state for just UI logic or SavedStateHandle if you need the data for business logic, and you are using ViewModels to handle the screen complexity. If you don't use ViewModels for that, don't skip the next section of the talk. Cool. We are going to move to more complicated use cases now. And for that, we need to do a deep dive and see what's happening under the hood. The first use case we are covering is how to contribute to saved state from your own classes. We've been presenting the ViewModels of state holder implementation for screens in your app. However, due to their scope, ViewModels are not a good solution for managing the complexity of reusable UI elements. Imagine that we have a reusable search UI element for news, and we want to save the searched user input into saved state. Our state holder in Compose would look like this. We are passing the news repository and the initial search input as parameters. Then we have a mutable variable with Compose's text field value. As we saw earlier, the way to contribute the state to saved state in Compose is with a rememberSaveable API. Following Compose API conventions, we can create a remember function that uses rememberSaveable under the hood. It would look something like this. RememberSaveable is going to take a variable number of inputs that indicate when a new state needs to be recreated with the new values. However, because NewsSearchState is a complicated object, we need to provide a custom saver. A saver describes how an object can be simplified and be converted to something that is saveable, which makes it eligible to be stored in the saved state. Back to our NewsSearchState, here is a saver implementation for the class. A saver needs to provide two functions, save and restore. Because our text field value state has its own saver already, we can simply delegate that functionality to it and save our current search input. Same thing with the restore LaMDA. We delegate that to the saver and call restore(it). With the result, we create a new instance of our NewsSearchState, passing in the restored searchInput and the newsRepository that we are passing to the saver function. In our rememberNewsSearchState function, now we call the saver, passing in the news repository. That wasn't too bad, was it? We are using rememberSaveable to do it. How can we do the same in the view system? Here, we have our NewsSearchState holder, again, with the current query as a string. We cannot use SavedStateHandle because this class isn't extending ViewModel. Also, we cannot use UnsafeInstanceState, because that's only available in a view class. To better understand the solution, we need to look at the different APIs that saveState provides in the view system. The SavedStateRegistry is a nonspecific interface that allows components to save and restore their state using the saved instance state mechanism. Then you also have providers that can contribute content to saveState within a registry owner. So let's put this into practice. Back to our NewsSearchState, if we wanted to save current query into saveState, we would need to make the class implement the SavedStateProvider interface, then implement the saveState method that is called before the registry owner is stopped. In there, we save our state into a bundle that we'll return. Now we have to connect this with a registry owner that we pass as a parameter in the constructor. At init time, we registered the search state as a provider in the owner SavedStateRegistry. And then we can restore the state if it was previously saved by calling the consumeRestoredStateForKey method. And that's it. We can save and restore our mutable state from saveState. If you're using the search UI element in a fragment, then you would initialize the state holder like this. And that would be it. That's how we can contribute to saved state from our own classes in Compose and in the View system. Now we can move on to another advanced use case, how to control the lifecycle of rememberSaveableValues. Yeah, you can control this. By default, if rememberSaveableValues will be restored as long as the UI element was in the composition before the save event happened. If you remember the composable lifecycle diagram from our docs, a composable enters the composition, can recompose zero or more times, and finally leaves the composition. What we said means that when the UI enters the composition, the rememberSaveableValues are stored in saveState. Now, if a configuration change happens and the activity is recreated, the old composition is destroyed, a new composition is created, and rememberSaveableValues are restored. Note that rememberSaveableValues are restored, but values using the remember API once, they are lost after the activity is recreated. And then, lastly, when the composable finally lifts the composition, the values inside rememberSaveable are removed from saved state. Let's see how we can modify this default behavior. We've seen the View system APIs involved in saved state. If we draw a parallel line and talk about Compose APIs, we're going to find a lot of similarities. We have the saveable state registry interface that allows components to save and restore the state. One big difference here is that this interface is platform agnostic. It is not specific to Android. When Compose runs on an Android target, SaveableStateRegistry is connected with SavedStateRegistry via the disposable saveable state registry implementation. Then we have saveable state holders that can control how to contribute content to SavedState with a SaveableStateProvider. In Compose, you can create instances of these APIs with a rememberSaveableStateHolder function. And this is exactly what you need if you want to control the lifecycle of rememberSaveable values in a particular part of the composition. Looking at that rememberSaveable implementation, it accesses the current SaveableState registry, and it gets initialized by calling consumeRestored from it. If there was no value previously stored, it gets initialized with the init LaMDA. So if we define a new SaveableStateRegistry, we can control for how long rememberSaveable stores their values. And this is precisely what navigation Compose does. Navigation, apart from caching ViewModel instances when the destination is part of the back stack, it also keeps in memory the rememberSaveable values of those destinations as well. Let's see how we are doing it. On the screen, we have the NavHost composable function, which declares a new SaveableState registry and state holder using that rememberSaveableStateHolder API. Here, we have the API in action. Then the content of a particular destination is placed inside the local owner's provider that is called on the current backStackEntry. LocalOwnersProvider has some composition locals and calls a custom SaveableStateProvider. This SaveableStateProvider controls for how long the rememberSaveableValues are going to be kept in the registry. In this case, if you look at the implementation details, it's going to keep them as long as the destination is in the back stack. Let's see an example of that. Here we are on the Interest screen of the Now in Android app. The bottom bar navigation has three tabs that we have in the back stack. See how its destination has a different ID that is connected with the navigation's SaveableStateHolder. On the interest screen, when we tap on an interest to see more details of it, a new screen opens, and the new destination is added to the back stack. Now, if we go back to the previous screen, the destination will be removed from the back stack. When this happens, navigation calls the removeState function with the corresponding ID to remove all associated rememberSaveableState. Now, both the destination and the state saved in its SaveableStateHolder are gone. They are no longer in memory. We've seen an example of how to control for how long rememberSaveableValues remain in memory. If you happen to need the same behavior for your particular use case, rememberSaveableStateHolder is the API you need. Coming to an end, here are the different ways you can lose up state. Remember that your activity and process could recreate, and there is nothing you can do to avoid it. We also looked at the different solutions we have to mitigate this and provide a good user experience. The ViewModel survives configuration changes, SavedState, config changes plus the system needing resources, and persistent storage, all of the above plus unexpected app dismissals. They're limited by the available memory, the bundle, and disk space. Use ViewModel to hold UI state that needs to survive config changes like screen UI state, saved state for transient UI state that depends on user input or navigation, and persistent storage for application data. At the top, we have the fastest solution for a type of data that changes more often and requires almost no delays to provide a good user experience. At the bottom, the slowest and most reliable solution to store application data that cannot be lost. Which one to use? You might need none of them or all of them. It depends on the necessities of your UI and the state that it contains. As many other architectural recommendations, treat this as guidelines and adapt them to your requirements as needed. Thank you, everyone, for watching, and hope to see you in future architecture talks. Bye. [MUSIC PLAYING]
Info
Channel: Android Developers
Views: 37,644
Rating: undefined out of 5
Keywords: Android, Google I/O, Google IO, IO, I/O, IO 23, I/O 23, Google I/O 23, Google IO 23, Google I/O 2023, Google IO 2023, IO 2023, Google New, Google Announcement, Google Developers, Developer, Development
Id: V-s4z7B_Gnc
Channel Id: undefined
Length: 20min 35sec (1235 seconds)
Published: Wed May 10 2023
Related Videos
Note
Please note that this website is currently a work in progress! Lots of interesting data and statistics to come.