Safely collecting hot flows from Android native UI
Along with the coroutine adoption, StateFlow
becomes an alternative observable data holder for LiveData
since StateFlow
has almost all the functionalities of LiveData
without the main thread confinment. On top of following the best practice of the safer way to collect flows from Android UI, this story covers another common pitfall in detail.
Setup
I would like to display 2 text views and 1 button:
- First text view: Display a number X.
- Second text view: Display X² reactively.
- Button: Upon click, increments the number X.
Apologize for the Jetpack Compose fans, I am still going to define the layout in XML with ViewBinding.
The data structure can be defined in the ViewModel :
To subscribe to these flows in the activity, I would use lifecycleScope.launchX
to collect them, just like collecting LiveData in the old days:
Bug
When I run the app, I am expecting to see that every time I click the button, both text views will be updated, the first one with the new X and the second one with the new X².
Let’s run the app and see!
What is happening? Why is the second text view not showing X²?
Cause
Reading the documentation of StateFlow
, there is an important piece of information: State flow never completes. From the implementation side, StateFlowImpl
has a while(true)
loop that does not exit normally.
Since we are subscribing a hot flow sourceFlow
in the coroutine, the subsequent operations inside the coroutine will not ever be executed.
lifecycleScope.launchWhenResumed {
viewModel.sourceFlow.collect {
binding.sourceFlow.text = it.toString() // Execution stops here
}
viewModel.transformedFlow.collect {
binding.transformedFlow.text = it.toString()
}
}
Note that if the upstream flow does not complete, the downstream flow does not complete either. In our case, sinkFlow
won’t be complete. Therefore, it is not possible to infer the completability of a Flow
purely on its type.
Another good learning, as pointed out by other folks, is that Flow.collect()
runs in the calling coroutine.
Fix
The fix is to break up the coroutine and launch an individual one for each subscriber. We can do it in the following way:
lifecycleScope.launchWhenResumed {
viewModel.sourceFlow.collect {
binding.sourceFlow.text = it.toString()
}
}
lifecycleScope.launchWhenResumed {
viewModel.transformedFlow.collect {
binding.transformedFlow.text = it.toString()
}
}
Or we could also use Flow.flowWithLifecycle() to resolve the problem.
Seeing is believing:
Learning
Because we can’t tell whether a Flow
would complete or not based on its type, we need to inspect to setup logic of Flow
setup logic to completability. Defensively, we could extract every collect
into an individual coroutine.
This pitfall makes StateFlow
much more delicate that LiveData
, because wiring up the observation logic from top to bottom does not introduce such an issue at all.