5

Background

I am creating a chat application where I need to execute logic when the user leaves a chat (a chat is just a @Composable fun and I am using the LocalLifecycleOwner.current combined with a ViewModel which watches the onDestroy method to unsubscribe the user). Now that logic is also executed when the user changes tab, this should not happen.

Problem

I am using a Scaffold with a BottomNavigation. When I switch tabs, the old tab is destroyed. I don't want this behavior, the old tab should remain in memory. The remember blocks are also re-executed when coming back to the tab, I don't want this. Should I use multiple navigation hosts or something?

Goal

Navigating between tabs without remember blocks being re-executed (and also LocalLifecycleOwner.current should not publish onDestroy).

Sample code

The whole project can be found here: https://github.com/Jasperav/JetpackComposeNavigation. You can see when switching tabs the remember blocks are re-executed and that the VM is destroyed (see logging). I don't want this behavior, it should be kept in memory. This is the relevant code:

@Composable
fun Screen() {
    val items = listOf(
        Triple("a", Icons.Default.Person, Icons.Filled.Person),
        Triple("b", Icons.Default.Notifications, Icons.Filled.Notifications),
    )
    var selectedTab = items[0]
    val navHostController = rememberNavController()

    Scaffold(
        bottomBar = {
            BottomNavigation {
                items.forEachIndexed { index, item ->
                    val isSelected = index == items.indexOf(selectedTab)

                    BottomNavigationItem(
                        icon = { Icon(if (isSelected) item.second else item.third, contentDescription = null) },
                        label = { Text(text = item.first) },
                        selected = isSelected,
                        onClick = {
                            navHostController.navigate(item.first) {
                                popUpTo(navHostController.graph.findStartDestination().id)

                                launchSingleTop = true
                            }
                        }
                    )
                }
            }
        }
    ) {
        NavHost(
            navHostController,
            startDestination = items[0].first,
            Modifier.padding(it)
        ) {
            composable(items[0].first) {
                selectedTab = items[0]

                val lifecycle = LocalLifecycleOwner.current
                val viewModel: ModelDontDestory = viewModel(factory = viewModelFactory {
                    ModelDontDestory(lifecycle)
                })

                remember {
                    println("Recomposed first")

                    ""
                }

                Text("first")
            }
            composable(items[1].first) {
                selectedTab = items[1]

                val lifecycle = LocalLifecycleOwner.current
                val viewModel: ModelDontDestory = viewModel(factory = viewModelFactory {
                    ModelDontDestory(lifecycle)
                })

                remember {
                    println("Recomposed second")

                    ""
                }

                Text("Second")
            }
        }
    }
}

inline fun <VM : ViewModel> viewModelFactory(crossinline f: () -> VM) =
    object : ViewModelProvider.Factory {
        override fun <T : ViewModel> create(aClass: Class<T>): T = f() as T
    }

class ModelDontDestory(val lifecycle: LifecycleOwner): ViewModel(), DefaultLifecycleObserver {
    init {
        lifecycle.lifecycle.addObserver(this)
    }

    override fun onDestroy(owner: LifecycleOwner) {
        super.onDestroy(owner)

        println("This should never happen, this should be kept in memory")
    }
}
J. Doe
  • 12,159
  • 9
  • 60
  • 114
  • Note that keeping a `ModelDontDestroy` in your ViewModel is a memory leak, something [LeakCanary](https://square.github.io/leakcanary/) would tell you right away. You should never pass a Lifecycle to a ViewModel. – ianhanniballake Jun 01 '23 at 02:08

2 Answers2

3

You already have an object that remains in memory - the ViewModel instances themselves. You shouldn't ever be looking at the destruction of the Lifecycle instance, since that also happens when your app goes through a configuration change.

Instead, you should be looking at the onCleared method of the ViewModel - that's what is called only when the instance is actually removed from the back stack (e.g., the user leaves the chat).

Similarly, you shouldn't be using remember for state that needs to be saved (again, remember variables are wiped for many reasons, including when you do a config change). You should be using rememberSaveable for values that need to be retained for the entire time the screen remains on the back stack.

ianhanniballake
  • 191,609
  • 30
  • 470
  • 443
2

From what I understand of "Lifecycle of composables" and from an example such as "Implement Bottom Bar Navigation in Jetpack Compose" by Ziv Kesten, the remember block (explained here) and lifecycle events are inherently tied to the lifecycle of the @Composable function. When you navigate away from a screen, its @Composable functions are disposed of, and when you return, they are recomposed.

Lifecycle of a composable in the Composition -- https://developer.android.com/static/images/jetpack/compose/lifecycle-composition.png

In Jetpack Compose, the remember function is used to retain state across recompositions.
However, state saved using remember does not survive configuration changes or process death. If you need to retain state across configuration changes or process death, you should use the rememberSaveable function instead.

To use rememberSaveable, you will need to ensure the data you're saving is serializable, as it will need to be put into a Bundle.
As in:

@Composable
fun MyComposable() {
    val myState = rememberSaveable { mutableStateOf(MyState()) }

    // Use myState in your composable
}

MyState would need to be a data class with properties that are all serializable. The rememberSaveable function ensures that the state is saved and restored across configuration changes and process death.

However, in your provided code, it seems like you are using remember to trigger a println when the composable is recomposed. remember (and rememberSaveable) is not usually used for this purpose. Instead, remember and rememberSaveable are used to retain state across recompositions or configuration changes.
If you just want to print a log message when the composable is recomposed, you can just use println directly, without remember.
If you want to keep some state in memory, you can use ViewModel and rememberSaveable together, where rememberSaveable holds UI state and ViewModel holds more permanent data state.

That means this would be a more straightforward implementation:

@Composable
fun Screen() {
    val items = listOf(
        Triple("a", Icons.Default.Person, Icons.Filled.Person),
        Triple("b", Icons.Default.Notifications, Icons.Filled.Notifications),
    )
    // Use rememberSaveable to persist the selected tab across configuration changes.
    val selectedTabIndex = rememberSaveable { mutableStateOf(0) }
    val navHostController = rememberNavController()

    Scaffold(
        bottomBar = {
            BottomNavigation {
                items.forEachIndexed { index, item ->
                    val isSelected = index == selectedTabIndex.value

                    BottomNavigationItem(
                        icon = { Icon(if (isSelected) item.second else item.third, contentDescription = null) },
                        label = { Text(text = item.first) },
                        selected = isSelected,
                        onClick = {
                            // Update the selected tab index when a tab is clicked.
                            selectedTabIndex.value = index
                            navHostController.navigate(item.first) {
                                popUpTo(navHostController.graph.findStartDestination().id)
                                launchSingleTop = true
                            }
                        }
                    )
                }
            }
        }
    ) {
        NavHost(
            navHostController,
            startDestination = items[0].first,
            Modifier.padding(it)
        ) {
            composable(items[0].first) {
                val viewModel: ModelDontDestroy = viewModel()

                Text("first")
            }
            composable(items[1].first) {
                val viewModel: ModelDontDestroy = viewModel()

                Text("Second")
            }
        }
    }
}

class ModelDontDestroy: ViewModel() {
    // Override onCleared to perform clean-up when this ViewModel is no longer needed.
    override fun onCleared() {
        super.onCleared()

        println("This should never happen, this should be kept in memory")
    }
}

With the changes:

  1. Removed the LifecycleOwner from the ViewModel. The ViewModel does not need a reference to the LifecycleOwner. Instead, the ViewModel's onCleared method should be used to perform clean-up when the ViewModel is no longer needed.

  2. Changed remember to rememberSaveable for the selected tab index. This ensures that the selected tab is remembered even when a configuration change occurs (e.g., screen rotation).

  3. Removed the remember blocks that were used to print a log message when the composable is recomposed. If you need to perform an action when a Composable is recomposed, you can just call that action directly in the composable. The remember function is not needed for this.

VonC
  • 1,262,500
  • 529
  • 4,410
  • 5,250