Search code examples
android-jetpack-composeandroid-jetpack-navigation

Compose Navigation pops a route before recomposing it again and looking for this route crashes the app


I have the following use case:

  1. Single Activity which uses Jetpack Compose + Compose Navigation.
  2. There are 3 screens: main, addresses (child of main), and address details (child of addresses). addresses is defined using navigation sub-graph (in reality the navigation graph is much more complex so I want/need sub graphs), but the crash isn't related to it (i.e. it still occurs when I put all the routes 'inline').
  3. addresses creates an AddressesViewModel scoped to its NavBackStackEntry and it as well as its children use the view model - children look for the addresses NavBackStackEntry to look up the same instance of the model (and it does have to be the same instance). The reason is that I want the view model to be destroyed when the addresses screen is popped.

This works fine when going deeper into the navigation graph; however, when going 'up' (popping the screens), popping the addresses screen and going to main causes a crash. The reason is that right after the pop the addresses @Composable is recomposed again, which looks up the view model, but at this point the back stack already doesn't have the addresses route, and looking up the back stack entry for addresses crashes.

An ugly workaround is to hook the view model to main screen, but it is too high up, and other modules (siblings of addresses) will also have it, which is undesired.

Another workaround is to catch the exception and don't render the content of the screen (but still render the Scaffold with the title etc. to prevent flickering. But this is also ugly.

I have a couple of questions:

  1. I noticed that when using Compose Navigation the same screens are recomposed a few times (like 3, 4 or more) - why is that?
  2. Why is the screen recomposed even after being popped?
  3. I think I can't use the viewModel helper from lifecycle-viewmodel-compose because it scopes view models to the Activity / Fragment (i.e. a very broad scope I don't want), right? And even if it did scope it to the current route, this would effectively prevent me from sharing the view model for the whole graph (as each nav back stack entry would get its own instance).
  4. Finally, how can I implement my use case without a crash, with property scoping using navigation back stack entries, without the aforementioned workarounds?

I created the sample using the latest Android Studio template for Jetpack Compose, here are the dependencies:

dependencies {
    implementation 'androidx.core:core-ktx:1.6.0'
    implementation 'androidx.appcompat:appcompat:1.3.1'
    implementation 'com.google.android.material:material:1.4.0'
    implementation 'androidx.compose.ui:ui:1.0.2'
    implementation 'androidx.compose.material:material:1.0.2'
    implementation 'androidx.compose.ui:ui-tooling-preview:1.0.2'
    implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.3.1'
    implementation 'androidx.activity:activity-compose:1.3.1'
    implementation 'androidx.navigation:navigation-compose:2.4.0-alpha09'

    debugImplementation 'androidx.compose.ui:ui-tooling:1.0.2'
}

And here is the code (the view model lookup function causing the crash is at the end, its code is based on https://androidx.tech/artifacts/hilt/hilt-navigation-compose/1.0.0-alpha02-source/androidx/hilt/navigation/compose/HiltViewModel.kt.html):

package com.example.nav_sample

import android.os.Bundle
import android.util.Log
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.lifecycle.*
import androidx.navigation.NavController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navigation
import com.example.nav_sample.ui.theme.NavsampleTheme

const val main = "main"
const val addressesModule = "addressesModule"
const val addresses = "addresses"
const val addressDetails = "addressDetails"

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        setContent {
            NavsampleTheme {
                val navController = rememberNavController()

                NavHost(navController = navController, startDestination = main) {
                    composable(main) {
                        ScaffoldScreen(
                            "Main screen",
                            onBack = { this@MainActivity.onBackPressed() },
                        ) {
                            Center {
                                Button(
                                    onClick = { navController.navigate(addressesModule) },
                                ) {
                                    Text("Addresses")
                                }
                            }
                        }
                    }
                    navigation(route = addressesModule, startDestination = addresses) {
                        composable(addresses) {
                            val addressesViewModel: AddressesViewModel =
                                navController.scopedViewModel(route = addresses)
                            Log.wtf(
                                "NavSample",
                                "Route: '$addresses', AddressesViewModel instance: $addressesViewModel",
                            )

                            ScaffoldScreen(
                                "Addresses",
                                onBack = { navController.popBackStack() },
                            ) {
                                Center {
                                    Column {
                                        Button(
                                            onClick = { navController.navigate(addressDetails) },
                                        ) {
                                            Text("Address #1 details")
                                        }
                                        Button(
                                            onClick = { navController.navigate(addressDetails) },
                                        ) {
                                            Text("Address #2 details")
                                        }
                                    }
                                }
                            }
                        }
                        composable(route = addressDetails) {
                            val addressesViewModel: AddressesViewModel =
                                navController.scopedViewModel(route = addresses)
                            Log.wtf(
                                "NavSample",
                                "Route: '$addressDetails', AddressesViewModel instance: $addressesViewModel",
                            )

                            ScaffoldScreen(
                                "Address details",
                                onBack = { navController.popBackStack() },
                            ) {
                                Center {
                                    Text("Address details")
                                }
                            }
                        }
                    }
                }
            }
        }
    }
}

@Composable
fun ScaffoldScreen(
    topBarTitle: String,
    onBack: () -> Unit,
    content: @Composable () -> Unit,
) {
    Scaffold(
        topBar = {
            TopAppBar(
                navigationIcon = {
                    IconButton(onClick = onBack) {
                        Icon(
                            imageVector = Icons.Filled.ArrowBack,
                            contentDescription = "back icon",
                        )
                    }
                },
                title = {
                    Text(text = topBarTitle)
                },
            )
        },
        content = { paddingValues ->
            Box(
                modifier = Modifier
                    .padding(paddingValues)
            ) {
                content()
            }
        },
    )
}

@Composable
fun Center(
    content: @Composable ColumnScope.() -> Unit,
) {
    Column(
        modifier = Modifier.fillMaxSize(),
        verticalArrangement = Arrangement.Center,
        horizontalAlignment = Alignment.CenterHorizontally,
        content = content,
    )
}

class AddressesViewModel : ViewModel()

@Composable
inline fun <reified T : ViewModel> NavController.scopedViewModel(route: String): T {
    val viewModelStoreOwner = try {
        getBackStackEntry(route)
    } catch (e: Exception) {
        Log.wtf(
            "NavSample",
            "Thrown looking up route: '$route'",
            e,
        )

        throw e
    }

    val provider = ViewModelProvider(viewModelStoreOwner)

    return provider[T::class.java]
}

Solution

  • Recomposition happens because of transition animations. There's nothing you can do with this fact, your app should work fine with such recompositions.

    Your local problem can be solved quite easily: inside addresses create a view model by calling viewModel() and inside addressDetails you will be able to get it with scopedViewModel.

    View model scope is bind to the navigation route, as declared in documentation.