Just another issue, unrelated to the above This o...
# compose-android
t
Just another issue, unrelated to the above This one is about a state flow which seems to have some sort of delay in emissions, which causes navigation problems in the app.
I have a few interrelated state flows in my application. One is the
authState
, which keeps track of whether the user is authorized/authenticated. Then there’s the
userState
, which is derived from the
authState
, but does some additional work when
authState
is authorized (attempts to download the user profile) Then, in a ViewModel, I have a
viewState
state flow. This is derived from
userState
, and combines with some other flows, to produce
ViewState.Loading
,
ViewState.Login
,
ViewState.Ready
, etc. So,
viewState
->
userState
->
authState
From virtually anywhere in the app, the user might decide to sign out. This calls a suspend function to sign out, which causes
authState
to change to
AuthState.Unauthorized
.
Copy code
fun signOut() {
    coroutineScope.launch {
        viewModel.signOut()
        navController.popBackStack(route = HomeDestination.route, inclusive = false)
    }
}
The call to
navController.popBackStack(HomeDestination.route)
, routes the user back to the home destination. This home destination observes the
viewState
, and, because the
authState
is
AuthState.Unauthorized
, the
viewState
should be
ViewState.Login
, and the app should navigate to the login screen. However, it seems that despite
signOut()
having completed, the
viewState
hasn’t yet updated. The home screen is still rendering its previous state. If that previous state should trigger navigation somewhere else, then that navigation occurs, we’re at the wrong destination, and the home destination is now on the back stack, stops observing the
viewState
, and we don’t navigate to the login screen like we should. If I put a small delay in between
viewModel.signOut()
and
navController.popBackStack()
, the issue goes away:
Copy code
fun signOut() {
    coroutineScope.launch {
        viewModel.signOut()
        delay(50)
        navController.popBackStack(route = HomeDestination.route, inclusive = false)
    }
}
Similarly, I could put a call to
viewState.filter{ it is ViewState.Login}.first()
before
popBackStack()
, and I’d at least ensure the
viewState
is correct before navigating.
Copy code
fun signOut() {
    coroutineScope.launch {
        viewModel.signOut()
        viewState.filter{it is ViewState.Login}.first()
        navController.popBackStack(route = HomeDestination.route, inclusive = false)
    }
}
However, these don’t really feel right to me. The first is an arbitrary delay. How long should I wait for? And the second - well, what if we change the behaviour somewhere, and
ViewState.Login
is no longer emitted after signing out? We’d suspend here forever.
Is it possible for the
ViewState
state flow to be out of sync with the
AuthState
flow from which it is derived? Is it incorrect to assume that if one stateflow updates, then any derived stateflows should also update synchronously? In other words, if
signOut()
has completed, and
authState
is
Unauthorized
, is it possible that
viewState
(derived from
authState
) is yet to update? And that you could pop the backstack, go to a screen that observes
viewState
, and
viewState
is still yet to update?
Apologies for the super long-winded question. I’m not able to be more concise when I’m not sure where the issue lies 😳
a
Are any of the `CoroutineScope`s you’re using
Dispatchers.Main.immediate
(either directly, or by default)?
In general, if you have precise ordering requirements with flows that are derived from other flows, there’s no great way to orchestrate that ordering. If they’re both being collected on
Dispatchers.Main
, one of them has to happen before the other.
Dispatchers.Main.immediate
can make it even harder to follow, since some changes can start happening immediately, but it’s still sensitive to other operations you’re doing
As soon as you have any
map { someSuspendingFunction() }
anywhere in your
Flow
pipeline, your ordering can change depending on what
someSuspendingFunction()
does and the dispatcher it runs on
In other words, if
signOut()
has completed, and
authState
is
Unauthorized
, is it possible that
viewState
(derived from
authState
) is yet to update?
So the answer is yes, with the details depending on exactly how “derived” is happening
t
The
map { someSuspendingFunction() }
I understand - and I actually was searching for any of these to see if I could put a delay in the suspending function to reliably reproduce the problem (but there are none on this code path) I’m not sure if I’m using
Dispatchers.Main.Immediate
- I’m not using that directly at all. How would I know where it might be being used by default? Generally speaking, when I create a StateFlow, I have a sort of ‘app level’ coroutine scope I use, which is made up of
CoroutineScope(Dispatchers.Main + SupervisorJob() + CoroutineExceptionHandler {})
The ViewModel whose
viewState
is derived from those state flows uses the
viewModelScope
If they’re both being collected on
Dispatchers.Main
, one of them has to happen before the other
Are you saying, if all derived flows are collected on
Dispatchers.Main
, then the ordering should be preserved? (Presumably because they can only run sequentially on that thread)
a
viewModelScope
uses
Dispatchers.Main.immediate
by default Here’s an example to play around with: https://pl.kotl.in/NapRd7LEo
Are you saying, if all derived flows are collected on
Dispatchers.Main
, then the ordering should be preserved?
More like “simultaneously” isn’t really possible with `Flow`s, there’s going to be some order they update it, and you probably don’t want to depend on that precise ordering (which you can influence with things like
Dispatchers.Main.immediate
) Even in a
combine
of the same
stateFlow
, you can get the old value and the new value, and not necessarily in a particular order.
t
Yeah, fair enough. This is sort of the answer I expected, but I wasn’t 100% sure. So, ultimately I’m trying to sign out, and once sign out is complete, navigate the user back to the home screen and let it decide what state should be rendered. But that navigation shouldn’t occur before the sign out is reflected in the view state. So, rather than
Copy code
fun signOut() {
    coroutineScope.launch {
        viewModel.signOut()
        navController.popBackStack(route = HomeDestination.route, inclusive = false)
        onLaunchSignOutActivity()
    }
}
Perhaps I should do something like:
Copy code
LaunchedEffect(viewState) {
    when(viewState is Login) {
        navController.popBackStack(route = HomeDestination.route, inclusive = false)
    }
}
(
ViewState.Login
is the state rendered when the user is signed out)
I dunno, I’m sort of tripping over myself. This all happens in a sort of top-level composable (trying to handle sign out happening from anywhere, in one place). But, if the user is on the home screen already and the viewstate changes to
Login
, then actually we should navigate to the login screen, rather than pop the backstack. Perhaps part of the problem is trying to do some sort of global navigation handling
Hmm, or maybe I could just do something like:
Copy code
LaunchedEffect(viewState) {
    when (viewState is Login) {
        if (navController.currentDestination != LoginDestination.route) {
            navController.popBackStack(route = HomeDestination.route, inclusive = false)
        }
    }
}
a
Yeah, navigation related logic is the most common place these edges show up (and relates back to the thread above for wanting alternative ways to handle this that could feel nicer in Compose) For login specifically, you could also explore something like this at the very top-level of your app, but with the understanding that you’re taking some of the navigation considerations into your own hands at this point:
Copy code
if (viewState is Login) {
    // The login screen
} else {
    // The logged in NavHost and screens
}
t
I have looked at something like this in the past, but it usually falls down when login has some additional sub-destinations. Then you end up with multiple nav hosts, which is problematic. So, I’m trying to use the nav controller - but it’s kind of tricky, because you’re modelling reactive state changes into imperative navigation calls
a
Yup, that’s a pretty good summary of the situation and tradeoffs