Safely collecting hot flows from Android native UI

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.

--

--

--

Android Developer@Instacart

Love podcasts or audiobooks? Learn on the go with our new app.

Recommended from Medium

Android BDD with Robolectric and Espresso — how to refactor your Android project tests

Implementing Android Open Accessory Protocol

#1 Daily Issues -Android

A Simple Mobile Media Reminder Made with Flutter

Android 12 Privacy Changes For Location

Use Firebase to Optimize Share Campaign

How to run Android “App Crawler” testing tool

These tools would make Android Development more fun

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store
Chao Zhang

Chao Zhang

Android Developer@Instacart

More from Medium

Bloody (Android) Handlers

Requesting Multiple Permissions in Jetpack Compose…

Start with GraphQL in baby steps for Android

The case against Rx for going async on Android