Safely collecting hot flows from Android native UI

Chao Zhang
3 min readSep 2, 2021
Photo: Merlene Goulet from Unsplash

Along with the coroutine adoption, StateFlowbecomes an alternative observable data holder for LiveDatasince StateFlowhas almost all the functionalities of LiveDatawithout 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 sourceFlowin 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 Flowpurely 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.

--

--