Hi all I am collecting flow in compose using collectAsState() But don't know how to make it lifecycl...
l
Hi all I am collecting flow in compose using collectAsState() But don't know how to make it lifecycle aware or collectAsState() is already lifecycle aware. In compose screen
Copy code
val isAlarmOn by viewModel.alarmBtnState.collectAsState()
In ViewModel
Copy code
val alarmBtnState = isAlarmActivated(Unit).map {
    it.successOr(DEF_VAL_ALARM_ACTIVATED)
}.stateIn(viewModelScope, WhileViewSubscribed, DEF_VAL_ALARM_ACTIVATED)
a
collectAsState
by itself will continue to collect even if the application is in the background. https://medium.com/androiddevelopers/a-safer-way-to-collect-flows-from-android-uis-23080b1f8bda#27a9 describes how to make a flow that is lifecycle aware, and
androidx.lifecycle
2.6.0-alpha01
added a
collectAsStateWithLifecycle
utility to accomplish this as well!
o
But what if we do use
State
inside VM? How could we then make it lifecycle-aware?
a
If you have Compose
State
inside a
ViewModel
, then I’m assuming you have some other suspending method that is updating the
State
that you want to cancel when the lifecycle changes. The most straightforward way to do that would be to expose the
suspend fun
from the
ViewModel
directly, and use
repeatOnLifecycle
inside the
LaunchedEffect
. Something like:
Copy code
val lifecycleOwner = LocalLifecycleOwner.current
    LaunchedEffect(viewModel, lifecycleOwner) {
        lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
            viewModel.update()
        }
    }
This fundamentally does the same thing as the
StateFlow
approach. The
ViewModel
is exposing 2 things: a state, and a way to initiate and cancel updating that state, based on the
Lifecycle
. The difference, is that for the
StateFlow
, the state and the method to initiate and cancel updating are tied together in the
StateFlow
object. For the Compose
State
, the
State
object exists separately, and there is a different
suspend fun
that will update the
State
.
o
Got it, thx Actually we do use VM’s scope to launch coroutines and update
State
. So I guess this part should be changed if we want to make
State
aware of different lifecycle 🤔
a
One note:
State
itself is completely unaware of lifecycle. It just exists. You can always read the current value, transform it, or pass it around elsewhere in your state production pipeline. What is modifying the
State
can be lifecycle aware, and when you want it active depends on your use case. You might want to have that be a
suspend fun
launched in the
ViewModel
scope, if you have a request that you want to continue executing while an Activity recreation is happening. Or you might want to cancel long running database connections if the application goes to the background.
l
Okay so what is the use of WhileViewSubscribed in stateIn operator?
The above shared link says:
Copy code
The MutableStateFlow and MutableSharedFlow APIs expose a subscriptionCount field that you can use to stop the underlying producer when subscriptionCount is zero. By default, they will keep the producer active as long as the object that holds the flow instance is in memory. There are some valid use cases for this though, for example, a UiState exposed from the ViewModel to the UI using StateFlow. That's ok! This use case demands the ViewModel to always provide the latest UI state to the View.

Similarly, the Flow.stateIn and Flow.shareIn operators can be configured with the sharing started policy for this. WhileSubscribed() will stop the underlying producer when there are no active observers! On the contrary, Eagerly or Lazily will keep the underlying producer active as long as the CoroutineScope they use is active.
a
The
WhileViewSubscribed
is custom, is it probably something like
WhileSubscribed(5000)
? That will keep collecting alive for an additional 5 seconds after being cancelled. So the upstream flow won’t be cancelled if the UI goes away and comes back quickly (say, during activity recreation), but it will eventually be canceled if the application goes into the background for a while. https://medium.com/androiddevelopers/things-to-know-about-flows-sharein-and-statein-operators-20e6ccb2bc74 to go in more detail about those APIs and explains the
WhileSubscribed(5000)
option.
l
Okay so it means we should collect flow as a lifecycle aware whether it is cold or hot flow doesn't matter?
a
Shamelessly plugging Manuel’s new article which goes into more detail about
collectAsStateWithLifecycle
, and why you’d want to use it over `collectAsState`: https://medium.com/androiddevelopers/consuming-flows-safely-in-jetpack-compose-cde014d0d5a3 Hopefully that helps give some more context!
m
@Alex Vanyo if we have screens that are restricted to login, we use on them something like
Copy code
...
val state by viewModel.state.collectAsStateWithLifecycle()
LaunchedEffect(key1 = state.isAuthenticated) {
   if (!state.isAuthenticated) {
      showLogin()
   }
}
...
With collectAsState the viewmodel gets updated in the background and when the UI is shown, it will have the correct state. However, when using collectAsStateWithLifecycle, it won't update and if UI is shown, it will still forward with the LaunchedEffect as state.isAuthenticated is false (even though in next ms it would be true, but at that time it is already paused again and we are moved to login screen). How would you prevent this from happening? I suspect this will be pretty common, because this kind of "login" is suggested by google and very common. Thank you.
a
I think you’d have the same problem the very first time you’re loading that screen with
collectAsState
or
collectAsStateWithLifecycle
I’d take the approach of having some sort of intermediate loading state, which you’d probably want anyway if knowing whether you are authenticated or not is asynchronous due to disk I/O (if for example it’s coming from
datastore
) Right now, I’m assuming that the default is
isAuthenticated
is
false
, but you could have something like
loginState
that is
Unknown
,
Authenticated
,
Unauthenticated
, and you’d only
showLogin()
if
Unauthenticated
.
m
When loading for the first time, it isn't a problem, because we initialize the state to the correct state and it is known synchronously, because it depends on async loading that has its own loading screen in the login screen. So we have that loading state not on every screen, but only on the login screen. A similar problem would happen if we do any LaunchedEffect on a state, which isn't updated in the background, and then when reopening, we get the stale data. If we have loginState, it is Unknown or Unauthenticated and the user is moved to login screen, because of the collectAsStateWithLifecycle it will not get updated on the previous screen in background and when user is moved back there, it will again send Unknown or Unauthenticated which triggers the LaunchedEffect again. The next ms would send state Authenticated, but that is too late, because the LaunchedEffect opens the login again.
a
I was thinking of
Unknown
as a no-op. So you’d only redirect to the login screen if
Unauthenticated
. If
Unknown
, you’d keep waiting (showing a loading state of some sort) and then redirecting if it becomes
Unauthenticated
and showing the screen as normal if
Authenticated
So we have that loading state not on every screen, but only on the login screen.
What happens if you’re further in the app, and then your app is backgrounded and killed due to process death? When the user returns to the app, they’ll still be further in the app, but the current log in state won’t be available synchronously there. At any destination, the app could be recreated with a fresh process, so you can’t assume you’ve been to any other destination to already have handled some form of asynchronous loading.
m
Even after process death we will have a correct state, because it won't be authenticated, will go to the login screen and there it will first try to refresh everything by showing a loading indicator. If failed, it will show login screen, if successful it will go back to the previous screen. In your example, I see the same problem: Screen A = current screen, B = login screen A - is Unknown, updates to Unauthenticated LaunchedEffect is triggered, B is shown, A stops getting updates user authenticates, B sets something to authenticated and clears the screen A is shown, but gets state Unauthenticated (it couldn't be updated because of lifecycle) - in the case of collectAsState this would already have the correct Authenticated state when the UI is shown. LaunchedEffect is triggered, B is shown, A stops getting updates (repeating)
a
Thanks for the detailed scenario!
A is shown, but gets state Unauthenticated (it couldn’t be updated because of lifecycle)
Ah I think I understand the issue: the state flow for screen A is using a stale value for authentication, which will be reused (and trigger the effect again) before it has a chance to see the updated value. I think this is a use case for
replayExpirationMillis = 0
as part of
SharingStrategy.WhileSubscribed
or
onSubscription
. Essentially, you want to avoid using that stale value in the case of authentication, so that it instead goes back to unknown when not subscribed, until it gets a fresh, updated value.
Alternatively, not use a state flow at all for authentication state. To ensure that you’re always getting the most up to date value (with a potentially initial delay while waiting for it)
m
Thank you! replayExpirationMillis makes sense, but I would have to split the flow to multiple flows for the UI, because some other values are good to be cached for better UX (not the best to split it). It is a little drawback of collectAsStateWithLifecycle. Normally in fragment/activity onStop and onStart I would update the values before they are visible. I wonder if this won't be possible in the viewmodel - based on compose lifecycle it would updates something with onStart before it is send. That would resolve this and there wouldn't be possibility to run into weird issues with clearing the cache or keeping multiple streams that might have race conditions.
1855 Views