https://kotlinlang.org logo
#compose
Title
# compose
t

Tim Malseed

11/24/2021, 6:42 AM
Hi all, trying to debug a strange crash during an Instrumented Test of a Composable: The Composable attempts to navigate when a particular Flow emits a new value:
Copy code
@Composable
fun RootScreen(navController: NavHostController, viewModel: RootViewModel) {
    LaunchedEffect("navigation") {
        viewModel.authenticationState
            .map { it.toDestination() }
            .onEach {
                navController.navigate(it.route)
            }
            .launchIn(this)
    }

    RootScreen(navController)
}
The test:
Copy code
@Test
fun testNavigateToHome() {
    userManager._authenticationState.value = UserAuthenticationState.Authenticated(
        User(null, null),
        UserEmployeeState.Pending
    )

    assert(navController.currentDestination?.route == NavigationDestination.Root.Home.route)

    composeTestRule
        .onNodeWithTestDescription("Home Screen")
        .assertIsDisplayed()
}
🧵 2
It seems to crash on:
Copy code
userManager._authenticationState.value = ...
The stacktrace:
Copy code
java.lang.IllegalStateException: Restarter must be created only during owner's initialization stage
	at androidx.savedstate.SavedStateRegistryController.performRestore(SavedStateRegistryController.java:58)
	at androidx.navigation.NavBackStackEntry.setMaxLifecycle(NavBackStackEntry.kt:146)
	at androidx.navigation.NavController.updateBackStackLifecycle$navigation_runtime_release(NavController.kt:987)
	at androidx.navigation.NavController$NavControllerNavigatorState.markTransitionComplete(NavController.kt:359)
	at androidx.navigation.compose.ComposeNavigator.onTransitionComplete$navigation_compose_release(ComposeNavigator.kt:71)
	at androidx.navigation.compose.NavHostKt$NavHost$4$2$1$invoke$$inlined$onDispose$1.dispose(Effects.kt:486)
	at androidx.compose.runtime.DisposableEffectImpl.onForgotten(Effects.kt:85)
	at androidx.compose.runtime.CompositionImpl$RememberEventDispatcher.dispatchRememberObservers(Composition.kt:793)
	at androidx.compose.runtime.CompositionImpl.dispose(Composition.kt:496)
	at androidx.compose.ui.platform.WrappedComposition.dispose(Wrapper.android.kt:171)
	at androidx.compose.ui.platform.WrappedComposition.onStateChanged(Wrapper.android.kt:179)
	at androidx.lifecycle.LifecycleRegistry$ObserverWithState.dispatchEvent(LifecycleRegistry.java:354)
	at androidx.lifecycle.LifecycleRegistry.backwardPass(LifecycleRegistry.java:284)
	at androidx.lifecycle.LifecycleRegistry.sync(LifecycleRegistry.java:302)
	at androidx.lifecycle.LifecycleRegistry.moveToState(LifecycleRegistry.java:148)
	at androidx.lifecycle.LifecycleRegistry.handleLifecycleEvent(LifecycleRegistry.java:134)
	at androidx.lifecycle.ReportFragment.dispatch(ReportFragment.java:68)
	at androidx.lifecycle.ReportFragment$LifecycleCallbacks.onActivityPreDestroyed(ReportFragment.java:224)
	at android.app.Activity.dispatchActivityPreDestroyed(Activity.java:1516)
	at android.app.Activity.performDestroy(Activity.java:8312)
	at android.app.Instrumentation.callActivityOnDestroy(Instrumentation.java:1364)
	at androidx.test.runner.MonitoringInstrumentation.callActivityOnDestroy(MonitoringInstrumentation.java:756)
	at android.app.ActivityThread.performDestroyActivity(ActivityThread.java:5374)
	at android.app.ActivityThread.handleDestroyActivity(ActivityThread.java:5420)
	at android.app.servertransaction.DestroyActivityItem.execute(DestroyActivityItem.java:47)
	at android.app.servertransaction.ActivityTransactionItem.execute(ActivityTransactionItem.java:45)
	at android.app.servertransaction.TransactionExecutor.executeLifecycleState(TransactionExecutor.java:176)
	at android.app.servertransaction.TransactionExecutor.execute(TransactionExecutor.java:97)
	at android.app.ActivityThread$H.handleMessage(ActivityThread.java:2210)
	at android.os.Handler.dispatchMessage(Handler.java:106)
	at android.os.Looper.loopOnce(Looper.java:201)
	at android.os.Looper.loop(Looper.java:288)
	at android.app.ActivityThread.main(ActivityThread.java:7839)
	at java.lang.reflect.Method.invoke(Native Method)
	at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:548)
	at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:1003)
It doesn't crash if I don't update the StateFlow (or if I make sure
navController.navigate()
isn't called)
I've tried introducing
composeTestRule.waitForIdle()
before attempting navigation, but there's no difference
Maybe something to do with the behaviour of
LaunchedEffect
?
i

Ian Lake

11/24/2021, 7:13 AM
You haven't included where you create your
NavController
and where your
NavHost
is? If you're doing an integration test with those, they'd work just fine, but generally you shouldn't be passing your
NavController
to any screen at all, as per our testing guide: https://developer.android.com/jetpack/compose/navigation#testing
That way when you are testing your
RootScreen
, you don't have any dependency on Navigation at all - you can just verify that your lambda method is called
t

Tim Malseed

11/24/2021, 7:15 AM
Thanks, I did read that guide - and specifically the section about decoupling navigation from composables, still trying to figure out exactly how to do that..
i

Ian Lake

11/24/2021, 7:16 AM
Your code seems to be mixing state with events - state is something you can apply many times over and the applying of that state shouldn't have any side effects. Events are one time things that can trigger side effects
t

Tim Malseed

11/24/2021, 7:17 AM
I'm trying to drive the navigation based on the state of authentication. So, I guess this is some form of 'reactive' navigation?
The idea is that the users
authenticationState
is a
StateFlow
which can change over time, and the app would navigate to an associated destination in response to those changes
You haven't included where you create your 
NavController
 and where your 
NavHost
 is
I'll clear that up momentarily!
i

Ian Lake

11/24/2021, 7:21 AM
Generally, if your RootScreen requires login, you'd write something like
Copy code
fun RootScreen(
  authenticationState: AuthState,
  navigateToLogin: () -> Unit
) {
  if (authenticationState is Loading) {
    // Show progress
  } else if (authenticationState is LoggedIn) {
    // Show your logged in state
  } else if (authenticationState is LoggedOut) {
    // Process the logged out state only once
    LaunchedEffect(authenticationState) {
      navigateToLogin()
    }
  }
}
Where your navigation graph then uses it like:
Copy code
composable("root") {
  val viewModel: RootViewModel = viewModel()
  RootScreen(viewModel.authenticationState) {
    navController.navigate("login")
  }
}
Now your
RootScreen
doesn't depend on ViewModel, doesn't depend on Navigation, and is super simple to test
t

Tim Malseed

11/24/2021, 7:28 AM
So, let's say for argument's sake, 'show progress' is a navigation destination, and 'show logged in state' is another nav destination
i

Ian Lake

11/24/2021, 7:28 AM
You shouldn't do that at all
You might take a look at the conditional navigation page, which explains the concepts behind conditionally navigating to destinations, such as your login flow: https://developer.android.com/guide/navigation/navigation-conditional - the same concepts apply to Navigation Compose as well
t

Tim Malseed

11/24/2021, 7:31 AM
OK, I'll have a read, thanks. I see the benefit in
Now your 
RootScreen
 doesn't depend on ViewModel, doesn't depend on Navigation, and is super simple to test
I've just gone a little astray, trying to port some iOS code over!
t

Tim Malseed

11/24/2021, 7:32 AM
No worries, I will definitely be digesting these.
I think I'm perhaps a little confused about when one should 'navigate', versus when one should just render a different composable. Maybe that's cleared up in that documentation.. Will have a read
Like, why is it OK to do this:
Copy code
if (authenticationState is Loading) {
    // Show progress
  } else if (authenticationState is LoggedIn) {
    // Show your logged in state
  } else if (authenticationState is LoggedOut) {
    // Process the logged out state only once
    LaunchedEffect(authenticationState) {
      navigateToLogin()
    }
  }
But not this:
Copy code
if (authenticationState is Loading) {
    // Show progress
  } else if (authenticationState is LoggedIn) {
    // Process the logged in state only once
    LaunchedEffect(authenticationState) {
      navigateToHome()
    }
  } else if (authenticationState is LoggedOut) {
    // Process the logged out state only once
    LaunchedEffect(authenticationState) {
      navigateToLogin()
    }
  }
You haven't included where you create your 
NavController
 and where your 
NavHost
 is
// The NavController:
Copy code
override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)

    val userManager = MockUserManager()

    val viewModel = RootViewModel(
        userManager = userManager
    )

    setContent {
        val navController = rememberNavController()

        RootScreen(navController, viewModel)
    }
}
// Root Screen
Copy code
@Composable
fun RootScreen(navController: NavHostController, viewModel: RootViewModel) {
    LaunchedEffect("navigation") {
        viewModel.authenticationState
            .map { it.toDestination() }
            .onEach {
                navController.navigate(it.route)
            }
            .launchIn(this)
    }

    RootScreen(navController)
}
// NavHost
Copy code
@Composable
fun RootScreen(navController: NavHostController) {
    Theme {
        NavHost(
            navController = navController,
            startDestination = NavigationDestination.Root.Loading.route,
            modifier = Modifier.fillMaxHeight()
        ) {

            composable(NavigationDestination.Root.Loading.route) {
                LoadingScreen()
            }

            onboardingGraph(navController)

            composable(NavigationDestination.Root.Home.route) {
                HomeScreen(navController)
            }
        }
    }
}
generally you shouldn't be passing your 
NavController
 to any screen at all
Maybe
RootScreen
is the wrong name for this Composable. But some Composable has to receive the NavController, in order to pass it into the NavHost.
--- Anyway, I understand what you're saying about making the navigation imperative rather than reactive. I think I'm sort of fighting against the tools here, and making a mess of my compose functions and testability at the same time
i

Ian Lake

11/24/2021, 7:53 AM
I'd definitely suggest watching that Navigating Navigation talk, as we talk about login quite a bit there as well
👍 1
t

Tim Malseed

11/24/2021, 11:23 PM
Thanks Ian! I've had a read, watched the video and re-read this discussion and it's all making sense to me now.
FYI, the exception was caused because I was attempting to call
navigate()
from a thread other than Main, during the test From the codelab on testing with Compose:
A third option is to call 
navController.navigate
 directly — there's one caveat here. Calls to 
navController.navigate
 need to be made on the UI thread
. You can achieve this by using 
Coroutines
 with the 
Main
 thread dispatcher. And since the call needs to happen before you can make an assertion about a new state, it needs to be wrapped in a 
runBlocking
 call.
2 Views