Pitfalls of observing flows in launchWhenResumed

Michael Ferguson
4 min readJan 17, 2021

Halil Ozercan has a great deep drive article on the various lifecycle coroutines that are available. I feel it is worth pointing out that there are some caveats that developers need to be aware of when using the launchWhenResumed or its sibling type functions.

Often, viewLifeCycleOwner.lifecycleScope.launchWhenResumed is used to ensure that a given coroutine run only in that specific lifecycle state. Halil’s article covers that very well so I don’t go into too much depth on that subject. He also points out that the coroutine block associated with launchWhenResumed is paused when outside the appropriate lifecycle state and is eventually cancelled when the destroy lifecycle state is reached. This has very subtle implications for flow collectors.

If we consider the following flow collector contained within a fragment:

viewLifecycleOwner
.lifecycleScope
.launchWhenResumed {
viewModel.flow.collect {
print("********** Received $it in lifecycle state ")
println(viewLifecycleOwner.lifecycle.currentState.name)
}
}

It seems benign enough. During the RESUME lifecycle state the flow collector will receive emissions and print out the line. On the surface this seems great for some types of events, say fragment navigation or anything where you need to ensure that the lifecycle is in a given state before action is taken.

However, there are some subtleties that need to be pointed out. Specifically with regards to configuration changes. Since the launchWhenX function pauses the collector outside its respective lifecycle state and then eventually cancels it when the ON_DESTROY lifecycle state is reached there is a possibility that an emission down the flow may be dropped.

The conditions to make this happen have to be just right but it’s absolutely possible that a view model may emit a value down a flow while a configuration change is underway. (Say as a response to a web service result being received.)

Sample code that demonstrates the issue:

package com.example.launchwhendemo.ui.mainimport android.os.Bundle
import androidx.fragment.app.Fragment
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.lifecycle.*
import com.example.launchwhendemo.R
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch
class MainFragment : Fragment() { companion object {
fun newInstance() = MainFragment()
}
private lateinit var viewModel: MainViewModel
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
return inflater.inflate(R.layout.main_fragment, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
viewModel = ViewModelProvider(this).get(MainViewModel::class.java)
// Only collect in the resumed state
viewLifecycleOwner
.lifecycleScope
.launchWhenResumed {
viewModel.flow.collect {
print("********** Received $it in lifecycle state ")
println(viewLifecycleOwner.lifecycle.currentState.name)
}
}
// Notify the view model of a lifecycle event, specifically the view
// lifecycle, just to cause emissions during lifecycle states. In
// practice the view model will emit values down the flow without care
// to the observer's lifecycle state. This just forces the conditions
// necessary to demonstrate the issue.
viewLifecycleOwner
.lifecycle.addObserver(LifecycleEventObserver {
source: LifecycleOwner, event: Lifecycle.Event ->
viewModel.lifecycleEvent(event.name)
})
}
}
class MainViewModel : ViewModel() { // Using a channel so no values are conflated,
// no values are dropped when there are no observers,
// emissions are buffered
private val channel = Channel<String>(Channel.BUFFERED)
val flow = channel.receiveAsFlow()
fun lifecycleEvent(eventName: String) {
viewModelScope.launch {
// Send the event name down the flow to make some traffic
println
("********** Emitting $eventName on the flow")
channel.send(eventName)
}
}
}

Of note in the sample code is the following:

The collector is attempting to receive emissions from a flow only during the ON_RESUME lifecycle state:

viewLifecycleOwner.lifecycleScope.launchWhenResumed {
viewModel.flow.collect {
print("********** Received $it in lifecycle state ")
println(viewLifecycleOwner.lifecycle.currentState.name)
}
}

Events are being sent to the view model just to force emissions during all lifecycle events. In reality a view model could just emit a value on the flow whenever it was necessary without regard to the observer’s lifecycle state.

viewLifecycleOwner.lifecycle.addObserver(LifecycleEventObserver { 
source: LifecycleOwner, event: Lifecycle.Event ->
viewModel.lifecycleEvent(event.name)
})

Running this code you’ll see

********** Emitting ON_CREATE on the flow
********** Emitting ON_START on the flow
********** Received ON_CREATE in lifecycle state RESUMED
********** Received ON_START in lifecycle state RESUMED
********** Emitting ON_RESUME on the flow
********** Received ON_RESUME in lifecycle state RESUMED

This makes sense. The view model is emitting a string with the name of the lifecycle event just received. The ON_CREATE and ON_START events are emitted but not observed until the RESUMED lifecycle state is reached. The ON_RESUME event is then emitted and immediately observed because the fragment is still within the RESUMED lifecycle state.

Things get interesting during a configuration change:

********** Emitting ON_PAUSE on the flow
********** Received ON_PAUSE in lifecycle state RESUMED
********** Emitting ON_STOP on the flow
********** Emitting ON_DESTROY on the flow
********** Emitting ON_CREATE on the flow
********** Emitting ON_START on the flow
********** Received ON_DESTROY in lifecycle state RESUMED
********** Received ON_CREATE in lifecycle state RESUMED
********** Received ON_START in lifecycle state RESUMED
********** Emitting ON_RESUME on the flow
********** Received ON_RESUME in lifecycle state RESUMED

The ON_PAUSE event is emitted and because the observer is still within the RESUMED lifecycle state it is observed. This makes sense.

During configuration change the current fragment (and activity) is destroyed and a new one is created. If we continue to follow the lifecycle events that are emitted we note that the stop event is emitted, the destroy event is emitted, a new fragment’s create, start and resume events are emitted.

However, it is worth noting that when the ON_STOP event is emitted the collector is paused. When the ON_DESTROY event is emitted the collector gets cancelled. This drops the ON_STOP event. The collector gets cancelled so the ON_DESTROY emission and all subsequent emissions get buffered for the next collector to pick up.

Again, this is because it the emission was paused before the collector could receive it and then cancelled as part of the scope being destroyed. This, in my opinion, makes these launchWhenX functions unsuitable to ever be used to collect flows.

Edit: I’ve written an article on how to handle “single live events” and avoid these pitfalls. You can find it here: https://proandroiddev.com/android-singleliveevent-redux-with-kotlin-flow-b755c70bb055

--

--

Michael Ferguson
Michael Ferguson

Written by Michael Ferguson

Android software developer. Views and opinions expressed are my own and not that of my employer.

No responses yet