compose-multiplatform: `rememberSaveable` does not work - restore/save logic is never called

I currently have:

    val appState = remember { AppState() }

I thought I would try switching to rememberSaveable so that app settings would persist across app invocations.

So I changed it to:

    val appState = rememberSaveable(saver = AppState.Saver) { AppState() }

AppState.Saver is currently just:

    val Saver = object : Saver<AppState, Any> {
        override fun restore(value: Any): AppState? {
            TODO("Not yet implemented")
        }

        override fun SaverScope.save(value: AppState): Any? {
            TODO("Not yet implemented")
        }
    }

If I put a breakpoint on those TODO lines, I can see that they are never called. I’d expect a call to save during application exit, and a call to restore some time during startup.

I assume there’s a way to call the right things programmatically to get this to happen as a workaround but haven’t figured it out yet.

About this issue

  • Original URL
  • State: closed
  • Created 2 years ago
  • Comments: 23 (7 by maintainers)

Most upvoted comments

Let me try to summarize this thread.

Android Jetpack Compose behavior

rememberSaveable relies (via a few abstraction layers) on system’s Saved instance state mechanism. From the documentation:

Saved instance state
Storage location in memory
Survives configuration change Yes
Survives system-initiated process death Yes
Survives user complete activity dismissal No
Read/write time slow (requires serialization/deserialization)

So, it’s NOT stored on disk, it does NOT survive when the user closes the application.

Compose Multiplatform behavior

Currently save/restore aren’t invoked out of the box because SaveableStateRegistry is not provided by default (yet). We do not plan to provide saving it to disk out of the box since it’s not the initial purpose of this API. Since the lifecycle/scope of stored data is outside of Compose itself and relies on navigation, currently we cannot provide expected/correct implementation out of the box, but it’s on our radar as an integration point with the future navigation solution.

For now you can provide a custom implementation of SaveableStateRegistry (see below).

Providing custom savable logic

If you want to provide custom storing logic, you can do it via providing LocalSaveableStateRegistry:

CompositionLocalProvider(
    LocalSaveableStateRegistry provides saveableStateRegistry,
) {
    // your compose content
}

Example of simpleSaveableStateRegistry that stores values as-is inside process memory (might be useful if you recreate Compose during the run and want to restore the state):

class GlobalSaveableStateRegistry(
    val saveableId: String,
) : SaveableStateRegistry by SaveableStateRegistry(
    restoredValues = map[saveableId],
    canBeSaved = { true }
) {
    fun save() { map[saveableId] = performSave() } // Should be called manually before Compose's dispose
    companion object {
        private val map = mutableMapOf<String, SaveableStateData>()
    }
}

Based on this you can implement saving the state on the disk if you want to.

I understand, what happens is sometimes rememberSaveable is misused / overused, saving state that perhaps don’t belong there. But definitely the data or ui-state that goes into the database shouldn’t know about process death at all. Thanks for the DisposableEffect clarification.

It has to be in the root Composable right Ivan?

No, it’s regular compose local and might be applied/replaced only to part of your application. I didn’t test it personally, but I don’t see any reason why it wouldn’t work

Take for example the Platform Lifecycle library, which is independent from compose. Integrates with it but is not required.

I’m currently working on porting lifecycle, things a bit more complicated there. On Android lifecycle library is independent from Compose, but vice versa - Compose has it in the dependencies to find “lifecycle owner” from View tree and provide as composition local. However, there is no such “lifecycle owner” like activity in multiplatform, and Compose does window management related things itself. So, as the first step Compose will be “lifecycle owner”. With ViewModels and “state registry owner” the situation is similar - there is no API at the moment to hold it outside of Compose. I wrote about it above:

Since the lifecycle/scope of stored data is outside of Compose itself and relies on navigation, currently we cannot provide expected/correct implementation out of the box

But it’s another topic.

If my screen data/state is already backed up by a database why do I need to save it twice

Usually current visual state is not stored in database. I mean for example collapsed spoiler or selected text range. rememberSaveable and saving state on config changes in general is about such cases.

all we need is an event for when is it gonna happen

What happen? The last example is about custom registry with custom logic. It’s up to you when you’re going to save the current state. Maybe you want to save it every minute? The saving just before disposing just allows you to save the state for next run - it’s what @hakanai wanted. My example shows how to achieve that. Anyway, “event” is out of scope of the original issue. Please follow https://github.com/JetBrains/compose-multiplatform/issues/2915 for lifecycle events. But I doubt that it’s directly related.

how do you know when you should call it then?

I thought about a general case with ComposePanel when you have direct control of disposing it/Compose.

When I wrote that code, I think I was assuming that disposals are called in the opposite order from the order they are initialised.

It’s true, the order is opposite and consistent. But you need to save it BEFORE disposing the content that you will initialize later

You just need to make sure you call DisposableEffect in the right place:

setContent {
    val saveableStateRegistry = remember { GlobalSaveableStateRegistry("KEY") }
    CompositionLocalProvider(LocalSaveableStateRegistry provides saveableStateRegistry) {
        ComposeContent()
    }
    DisposableEffect(Unit) {
        onDispose { saveableStateRegistry.save() }
    }
}

Previously, since I had full control over Compose lifecycle, I thought it would be just easier not to rely on that order. But if it done correctly, it will work with DisposableEffect too

Buuuuut, how do you know when you should call it then? DisposableEffect is the only obvious API I was able to think of. Is there some other way to find out that the current context is about to be disposed? I certainly have no idea from my own code.

When I wrote that code, I think I was assuming that disposals are called in the opposite order from the order they are initialised. Which is usually a fairly sane assumption to make, but I don’t know how Compose has actually implemented it. (Maybe what I should have done was had the DisposableEffect also create the thing which actually does the saving? Then I’d have it in scope and would know that it hasn’t been disposed by anyone else.)

What I wish Compose had was something like a rememberDisposable which would allow me to remember a thing but also have a hook that would be called to dispose it.

I did some investigation and looks like rememberSaveable actually works, but you need to implement additional thing to make it work, it’s just that android’s navigation library already has it implemented underneath. Also on desktop serialization is not really is necessary since everything is stored in memory

To make it work you need to create instance of SaveableStateHolder with rememberSaveableStateHolder() and wrap you navigation destinations with stateHolder.SaveableStateProvider(stringKey) { content }, it will assosiate your content with key and save data for specific destinations, then when removing destination you just need to invoke SaveableStateHolder.removeState(key)

Here is snippet of what I did in my project:

interface MultiplatformMainNavigationState : MainNavigationState {
    val currentDestination: State<MainDestination>
    val stateHolder: SaveableStateHolder
}

@Composable
fun MultiplatformMainNavigation(
    state: MainNavigationState
) {

    state as MultiplatformMainNavigationState

    val destination = state.currentDestination.value

    state.stateHolder.SaveableStateProvider(destination.toString()) {
        when (destination) {
            MainDestination.Home -> {
                val homeNavigationState = rememberHomeNavigationState()
                HomeScreen(
                    viewModel = getMultiplatformViewModel(),
                    mainNavigationState = state,
                    homeNavigationState = homeNavigationState
                )
            }
            MainDestination.About -> {
                AboutScreen(
                    mainNavigationState = state,
                    viewModel = getMultiplatformViewModel()
                )
            }
            // ...others
        }
    }

}

@Composable
fun rememberMultiplatformMainNavigationState(): MainNavigationState {
    val stack = remember { mutableStateOf<List<MainDestination>>(listOf(MainDestination.Home)) }
    val currentDestinationState = remember { derivedStateOf { stack.value.last() } }

    val stateHolder = rememberSaveableStateHolder()

    return remember {
        object : MultiplatformMainNavigationState {

            override val currentDestination = currentDestinationState
            override val stateHolder: SaveableStateHolder = stateHolder

            override fun navigateBack() {
                val lastItem = stack.value.last()
                stack.value = stack.value.dropLast(1)
                stateHolder.removeState(lastItem.toString())
            }

            override fun popUpToHome() {
                val itemsToRemove = stack.value.drop(1)
                stack.value = stack.value.take(1)
                itemsToRemove.forEach { stateHolder.removeState(it) }
            }

            override fun navigate(destination: MainDestination) {
                stack.value = stack.value.plus(destination)
            }

        }
    }
}

I partially agree, except:

  1. It isn’t clear that Compose is just a “UI framework”. The docs really make it seem like an “app framework” instead - code examples show the main function directly calling their singleWindowApplication function, i.e. the very first line of code is Compose code.

  2. This means I have to reinvent the wheel even though surely I am not the only person trying to use this framework to make full applications.

So yeah, Compose may be a UI framework and not an app framework, but if that’s the case, things like rememberSaveable are confusing at best, and it means we have to wait for someone to make an actual app framework which is built on Compose until we can really develop apps with ease, because “just putting state in some object and serialising it” is not trivial.

Initially, from what I gathered reading docs, it seemed like rememberSaveable was the way to remember state across runs of an application.

But the impression I get now is that perhaps rememberSaveable is just a hack to work around Android’s terrible behaviour where it throws your app state away when you rotate the device, and on desktop I appear to be able to resize the window totally fine without this sort of hack, so perhaps rememberSaveable was never meant to work on desktop?

And worse - the impression I get from talking to people in a few places now is that both remember and rememberSaveable should be avoided as much as possible, and we should be initialising all our app state before even calling the first composable function.

So instead of doing this:

fun main() = singleWindowApplication {
    MaterialTheme {
        val state = remember { AppState() }
        MainUi(state)
    }
}

We should actually be doing this:

fun main() {
    val state = AppState()
    singleWindowApplication {
        MaterialTheme {
            MainUi(state)
        }
    }
}

Which is fine, but now I’m back to having no convenient way to store my app state at all, and I’m back to reinventing the wheel.