Pitfalls of observing flows in launchWhenResumed
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.launchclass 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