Application migration to StateFlow
StateFlow and SharedFlow are the main innovations of the 1.4.0 release of the kotlinx.coroutines library. They are needed to manage a state in the context of asynchronous operations. Let’s figure out whether it is worth switching to the new Flows from LiveData and how to migrate your applications to them.
Weaknesses of LiveData
With the release of Kotlin Coroutines 1.4.0, StateFlow and SharedFlow have become stable. Does this mean that you no longer need to use LiveData? While this tool does an excellent job of linking different layers of an application, it has certain disadvantages:
-
A value is always a nullable type
The LiveData class is written in Java, and as we know in Java, all objects are nullable. If we are sure that we set some default value, then we still have to check it through ? or enter !!.
-
Correct LifecycleOwner
If you use LiveData with a fragment, you must send the correct LifecycleOwner there, because the life cycles of the View fragment and the fragment itself are different. Accordingly, if you transmit a fragment there and update some UI elements, a crash or some unexpected state may occur. By the way, the latest version of Android Studio shows that you need to transmit the viewLifecycleOwner.
-
Much code
If LiveData is in a repository and you want to subscribe to it in the ViewModel, you will have to write much code:
private val observer = Observer {…}
init {
userName.observeForever(observer)
}
override fun onCleared() {
userName.removeObserver(observer)
super.onCleared()
}
You must not forget to unsubscribe the observer in the onCleared method.
-
LiveData is not allowed for cross-platform development
LiveData is part of the AndroidX library and is therefore not used for cross-platform development.
Are Coroutines and Flows an alternative to LiveData?
Unfortunately no. Coroutines were designed to work like regular functions. You can call them and get a specific response. It is impossible to transmit a data stream through them, therefore, they are not suitable.
What about Flows? The main disadvantage is that they are cold. This means that every time you subscribe to a Flow, you are executing the same code. Here is a simple example:
val ints: Flow = flow {
for (i in 1..10) {
delay(100)
emit(i)
}
}
ints.collect { printIn(it) }
ints.collect { printIn(it) }
If you do heavy calculations, a network request, or a database query in a Flow, then these operations will be performed every time you subscribe to it. A Flow cannot store any state.
Is BroadcastChannel an alternative to LiveData?
The BroadcastChannel is a channel. Some types of channels can store states inside. Accordingly, a BroadcastChannel can be used as an alternative to LiveData. When doing so, the following factors must be taken into account:
-
If a channel is closed and you try to add a new value there, an error will occur.
-
The BroadcastChannel is marked as experimental. It is quite dangerous to use experimental features in production code, they may contain bugs or change a lot after the release. In this case, the BroadcastChannel was deprecated because StateFlow and SharedFlow appeared.
Let’s find out if JetBrains was able to create a worthy alternative to LiveData that will solve all the problems.
How SharedFlow Works
SharedFlow is a hot Flow containing a list of elements that will be delivered to a subscriber. SharedFlow is divided into SharedFlow and MutableSharedFlow by type, just like in LiveData.
public interface SharedFlow : Flow {
public val replayCache: List
}
public interface MutableSharedFlow : SharedFlow, FlowCollector {
override suspend fun emit(value: T)
public fun tryEmit(value: T): Boolean
public val subscriptionCount: StateFlow
}
SharedFlow contains a replayCache list containing a set of elements that we send to it.
A MutableSharedFlow has functions of emit (a suspend function that can be suspended if a Flow is full) and tryEmit (which will return true or false in case of success or failure). The number of active subscribers can be observed through the SubscriptionCount field.
See the MutableSharedFlow constructor below:
public fun MutableSharedFlow(
replay: Int = 0,
extraBufferCapacity: Int = 0,
onBufferOverflow: BufferOverflow = BufferOverflow.SUSPEND
) : MutableSharedFlow
It contains three parameters:
-
replay – the quantity of elements that are delivered during the subscription,
-
extraBufferCapacity – the number of elements that are stored in SharedFlow in addition to replay,
-
onBufferOverflow – a strategy that will work if replay and extraBufferCapacity overflow. This is a regular enum-class that has three strategies: SUSPEND, DROP_OLDEST, DROP_LATEST.
How StateFlow works
StateFlow is the closest analog to LiveData. Here, even the name of the value of the variable is the same as the variable in LiveData:
public interface StateFlow : SharedFlow {
public val value: T
}
public interface MutableStateFlow : StateFlow, MutableSharedFlow {
public override var value: T
public fun compareAndSet(expect: T, update: T) : Boolean
}
StateFlow is divided into StateFlow and MutableStateFlow and inherits from SharedFlow. The compareAndSet function is primarily used in the StateFlow source code to deal with multithreading. The constructor is pretty simple:
public fun MutableStateFlow(value: T): MutableStateFlow
Finally, the type we define in StateFlow is what we’ll get when we subscribe. Now, you needn’t worry that a team member accidentally sends null to LiveData. But now we have to pass the default value to the constructor in StateFlow.
Another difference between StateFlow and LiveData: in StateFlow we compare the old and new values, if they match, we ignore them.
if (oldState == newState) return true
// Don’t do anything if value is not changing
As we mentioned above, StateFlow inherits from SharedFlow. We have found some interesting features in the implementation of StateFlow.
replayCache for StateFlow is a regular list with one element:
override val replayCache: List
get( ) = listOf(value)
But the most interesting thing is how emit and tryEmit look like for StateFlow:
override fun tryEmit(value: T): Boolean {
this.value = value
return true
}
override suspend fun emit(value: Е) {
this.value = value
}
They essentially do the same thing: tryEmit always returns true by simply assigning a value and emit cannot be paused. So instead of using these two functions, you can assign a value:
_userName.value = value
An interesting feature of StateFlow is that it can work with multithreading.
StateFlow vs. SharedFlow
It may seem that StateFlow is SharedFlow but with one element. But it’s not! The main difference between StateFlow and SharedFlow can be illustrated by an example:
val stateFlow = MutableStateFlow(-1)
val sharedFlow = MutableSharedFlow( )
(0..10) .forEach {
stateFlow.emit(it) -> -1 10
}
(0..10) .forEach {
sharedFlow.emit(it) -> 0 1 2 3 4 5 6 7 8 9 10
}
It turns out that there is StateFlow with a default value of -1 and SharedFlow, for which you needn’t pass a default value. Next, we emit a value from 0 to 10 and output them to the console. If you run this code, then for StateFlow you will see only the values of -1 and 10. This is because StateFlow is designed to store the most recent and up-to-date state. In turn, SharedFlow outputs all values from 0 to 10. So, if you are interested in the whole data flow, SharedFlow is the way to go.
How to migrate an application to StateFlow and SharedFlow
Below is a step-by-step guide.
First we need to replace MutableLiveData with MutableStateFlow:
private val _userName = MutableLiveData(“Andrew”)
fun userName( ) : LiveData = _userName
replace with
private val _userName = MutableStateFlow(“Andrew”)
fun userName( ) : StateFlow = _userName
Then we replace Observer with Collect:
viewModel.userName( ) .observe(viewLifecycleOwner) {}
lifecycleScope .launch {
viewModel.userName( ) .collect {…}
}
The idea behind the migration is a simple app that contains a title, a username input, and a snackbar.
Infographic 1: https://drive.google.com/file/d/1sUlbCrYluZ-DPuP8BkBc7Ng6H824oOok/view?usp=sharing
Main fragment code:
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super .onViewCreated(view, savedInstanceState)
…
viewModel.headerText( ) .observe(viewLifecycleOwner) {
binding.textViewHeader .text = it
}
viewModel.showSnackbar( ) .observe(viewLifecycleOwner) {
showSnackbar(it)
}
}
In the main fragment, we subscribe to LiveData from the ViewModel and do simple actions on the UI.
Code in the ViewModel:
val userName = MutableLiveData(“ “)
private val _headerText = MutableLiveData( )
fun headerText( ): LiveData = _headerText
private val _showSnackbar = LiveEvent( )
fun showSnackbar(): LiveData = _showSnackbar
fun updateHeaderClick( ) {
_headerText .postValue(userName .value)
}
fun showSnackbar() {
_showSnackbar.postValue(userName .value)
}
If you are using DataBinding with StateFlow, you must use Android Studio Arctic Fox. But at startup, the error “two-way DataBinding is not supported” still appears. Update the Android Gradle plugin to version 7.0.0-alpha04 or higher, then everything will work.
MainViewModel has two LiveData that pass our actions to the fragment. Using DataBinding, you can immediately send actions to click to the ViewModel and post userName values in LiveData.
For migration, you need to replace observe with collect, but collect is a suspend function that can only be called from some coroutine. Therefore, we need to use the coroutine builder on the lifecycleScope and where we will call the collect method:
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super .onViewCreated(view, savedInstanceState)
…
lifecycleScope .launch {
viewModel.headerText( ) .collect {
binding.textViewHeader .text = it
}
viewModel.showSnackbar( ) .collect {
showSnackbar(it)
}
}
Next, in the ViewModel, we replace MutableLiveData with MutableStateFlow, LiveData with StateFlow, and postValue with value.
val userName = MutableStateFlow(“ “)
private val _headerText = MutableStateFlow(“Header”)
fun headerText( ): StateFlow = _headerText
private val _showSnackbar = MutableStateFlow(“ “ )
fun showSnackbar(): StateFlow = _showSnackbar
fun updateHeaderClick( ) {
_headerText value = userName.value
}
fun showSnackbarClick () {
_showSnackbar .value = userName.value
}
Now, if you enter a name in the application and click on the Update Header button, the header is updated:
But if you click Show snackbar, nothing will happen. The problem is pretty simple:
lifecycleScope .launch {
viewModel .headerText ( ) .collect {
binding .textViewHeader .text = it
}
viewModel .showSnackbar ( ) .collect {
showSnackbar (it)
}
}
As we said earlier, collect is a suspend function: when it is called, it will pause until the Flow ends. In our case, the Flow never ends, and the second collect does not occur, which is simple to solve:
lifecycleScope .launch {
viewModel .headerText ( ) .collect {
binding.textViewHeader.text = it
}
}
lifecycleScope .launch {
viewModel .showSnackbar ( ) .collect {
showSnackbar (it)
}
}
For each Flow, you need to run a separate coroutine. If you don’t want to do this, a Flow has a launchIn operator that calls the launch method on Scope, and calls the collect method on it:
public fun .launchIn(scope: CoroutineScope) : Job = scope .launch {
collect( )
}
The code will look like this:
viewModel .headerText( )
.onEach { binding .textViewHeader .text = it }
.launchIn(lifecycleScope)
viewModel .showSnackbar( )
.onEach { showSnackbar (it) }
.launchIn(lifecycleScope)
On the Flow, we call the onEach method, which will process events, and the launchIn method where we need to transmit Scope.
In general, it is not advisable to use lifecycleScope, because it is incorrect to use the lifecycle of a fragment when we are working with a “view”. Unforeseen situations may arise, so it is better not to update the UI this way. Fortunately, special coroutine builders were written for this:
public abstract class LifececleCoroutineScope {
public fun launchWhenCreated(block: suspend CoroutineScope. ( ) – Unit) : Job
public fun launchWhenStarted(block: suspend CoroutineScope. ( ) – Unit) : Job
public fun launchWhenResumed(block: suspend CoroutineScope. ( ) – Unit) : Job
}
lifecycleScope .launchWhenStarted {
viewModel .headerText( ) .collect {
binding .textViewHeader .text = it
}
}
When the fragment changes the state to onStarted, a coroutine is created. In the opposite state onStop, the coroutine is paused. When we return to onStart, it continues.
What does a coroutine on pause mean?
How is this possible, since a coroutine can either be active or canceled? A special PausingDispatcher was written for this task. It only pauses the collector, not the emitter. The emitter still continues to emit data, so we waste device resources when we do not use the Flow.
You can solve this problem by saving Jobs:
private var headerJob : Job? = null
private var snackberJob : Job? = null
override fun onStart( ) {
super .onStart( )
headerJob = lifecycleScope .launch {
viewModel .headerText ( ) .collect {
binding .textViewHeader /text = it
}
}
snackberJob = lifecycleScope .launch {
viewModel .showSnackbar ( ) .collect {
showSnackbar(it)
}
}
}
override fun onStop( ) {
headerJob? .cancel( )
snackberJob? .cancel( )
super .onStop( )
}
lifecycleScope .launch returns Jobs to us, we first save them and then cancel in the onStop method. But if we have not two, but twenty Stop Flows, we risk missing some Jobs.
We can run one coroutine and run the rest inside it. In such a case, if we cancel the parent coroutine, all child coroutines will also be canceled. Therefore, we store only one Job, which we then cancel.
But we were lucky that the lifecycle-runtime-ktx:2.4.0-alpha01 library has been recently released. There, the repeatOnLifeCycle extension was added:
android.lifecycle:lifecycle-runtime-ktx:2.4.0-alpha01
public suspend fun Lifecycle.repeatOnLifecycle(
state: Lifecycle.State,
block: suspend CoroutineScope.( ) – Unit
)
We pass in a State (e.g. STARTED) and our coroutine will be created after onStart and canceled after onStop. If we return to the onStart method again, the coroutine is recreated. When the fragment is destroyed, we will simply continue the Flow, and this will delete the suspend function.
All we have to do is call lifecycleScope and coroutine:
lifecycleScope .launch {
lifecycle .repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel .headerText( ) .collect {
binding .textViewHeader .text = it
}
}
}
Next, we pass the State there and call collect on our Flow:
lifecycleScope .launch {
lifecycle .repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel .headerText( ) .collect {
binding .textViewHeader .text = it
}
}
}
lifecycleScope .launch {
lifecycle .repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel .showSnachbar( ) .collect {
showSnackbar( it)
}
}
If we compare this code with the code from LiveData, then it looks quite solid.
viewModel .headerText( ) .observe(viewLifecycleOwner) {
binding .textViewHeader .text = it
}
viewModel .showSnackbar( ) .observe(viewLifecycleOwner) {
showSnackbar(it)
}
Since we are using Kotlin, we can improve it. Partially, this was done for us in the ktx library, where another extension, addRepeatingJob, was added:
public fun LifecycleOwner .addRepeatingJob(
state: Lifecycle .State,
coroutineContext: CoroutineContext = EmptyCoroutineContext,
block: suspend CoroutineScope. ( ) – Unit
): Job {
return lifecycleScope .launch(coroutineContext) {
lifecycle .repeatOnLifecycle(state, block)
}
}
This code looks solid, but it does a fairly simple thing: it calls the launch method on lifecycleScope, where our repeatOnLifecycle extension is already called with the State that we passed there. If we use this code, we get:
viewLifecycleOwner .addRepeatingJob(Lifecycle .State .STARTED) {
viewModel .headerText( ) .collect {
binding .textViewHeader .text = it
}
}
To improve the code, let’s write our collectWhenStarted extension:
inline fun Flow.collectWhenStarted(
lifecycleOwner: LifecycleOwner,
crossinline action: suspend (value: T) -> Unit
) {
lifecycleOwner .addRepeatingJob(Lifecycle .State.STARTED) {
collect(action)
}
}
Given that you almost always need to subscribe with the STARTED state, you do not want to constantly pass it. Therefore, we call the addRepeatinhJob extension with State STARTED on lifecycleOwner and at the same time call the collect method with a lambda:
viewModel .headerText( ) .collectWhenStarted(this) {
binding .textViewHeader .text = it
}
viewModel .showSnackbar( ) .collectWhenStarted(this, ::showSnackbar)
This code is very similar to the code for LiveData. Compare:
Flow:
viewModel .headerText( ) .collectWhenStarted(this) {
binding .textViewHeader .text = it
}
viewModel.showSnackbar( ).collectWhenStarted(this, ::showSnackbar)
LiveData:
viewModel .headerText( ) .observe(viewLifecycleOwner) {
binding.textViewHeader .text = it
}
viewModel .showSnackbar( ) .observe(viewLifecycleOwner, ::showSnackbar)
We have just reinvented an already known API. The only difference is that you can pass this instead of ViewLifecycyleOwner, since the life cycle between OnStart and OnStop for the View and the fragment will be the same. If you don’t want to write your own extensions, you can use another extension from the runtime-ktx library called flowWithLifecycle:
public fun Flow.flowWithLifecycle(
lifecycle: Lifecycle,
minActiveState: Lifecycle.State = Lifecycle.State.SARTED
) : Flow
You send a lifecycle and a State into it. If you use it, you will have to call the onEach method, LaunchIn and pass the Lifecyclescope there:
viewModel .headerText( )
.flowWithLifecycle(lifecycle, Lifecycle.State.STARTED)
onEach {
binding.textViewHeader.text = it
}
.launchIn(lifecycleScope)
viewModel .showSnackbar( )
.flowWithLifecycle(lifecycle, Lifecycle.State.STARTED)
.onEach {
showSnackbar(it)
}
.launchIn(lifecycleScope)
As a result, the code is fixed, and the Snackbar works and is shown after updating the title. But it is shown every time you rotate the device, which should be avoided.
SingleLiveEvent in StateFlow and SharedFlow
The SingleLiveEvent class delivers a UI event to LiveData only once. This is not part of the library, it is often copied from ready-made solutions. Thanks to it, such a request as “show Snackbar” occurs once. How can this be done with StateFlow and SharedFlow?
public fun <T? MutableSharedFlow(
replay: Int = 0,
extraBufferCapacity: Int = 0,
onBufferOverflow: BufferOverflow = BufferOverflow.SUSPEND
): MutableSharedFlow
MutableSharedFlow has a Replay parameter. It corresponds to the number of items that are delivered to the subscriber and the number of items that will be stored in SharedFlow. If we use the default value of 0, we get the event only when we are subscribed to it. When we rotate the screen, the activity is recreated. And when we resubscribe to the Flow, nothing happens, because we do not store anything.
You need to replace MutableStateFlow with MutableSharedFlow. In this case, you don’t even need to pass the parameter by default:
private val _showSnackbar = MutableSharedFlow( )
fun showSnackbar( ) : SharedFlow = _showSnackbar
This is not a perfect solution, but the goal was to show how to solve this problem using StateFlow and SharedFlow. After that, you only need to check the code.
Conclusion
StateFlow won’t solve all LiveData problems. When migrating from LiveData to StateFlow, there are pitfalls that need to be considered. Some of the StateFlow tools are still in alpha testing, they are unstable. Therefore, analyze all the information to understand whether migration is relevant in your case.
Media Contact
Company Name: Andersen Inc
Email: Send Email
Phone: +18009677762
City: New York
Country: United States
Website: https://andersenlab.com/