Replace SingleLiveEvent with kotlin Channel / Flow
Warning: This article solution is obsolete now. The solution with BroadcastChannel is deprecated and should be replaced by SharedFlow or Channel. Still, it's worth reading :)
After the announcement of the StateFlow implementation this year, I got curious about the possibility to totally replace LiveData. This means one less project dependency and achieve a more independent code from Android framework.
StateFlow is not the subject of this post but we can change the view states represented by a LiveData using a StateFlow. For SingleLiveEvent class, we need a different solution.
SingleLiveEvent
Following the MVVM pattern, ViewModel provides the view state and events/actions to the View. In the context of LiveData, the second could be implemented using the class SingleLiveEvent. Some examples of actions are: dialog show, snack bar display, screen navigation.
First approach: Channel
Change from val action = SingleLiveEvent<Action>()
to val action = Channel<Action>(Channel.BUFFERED)
and on the Activity side as simple as this:viewModel.action.onEach{ ... }.launchIn(lifecycleScope)
Problem
Everything seemed to be working fine until I tested a configuration change that recreates my Activity. After that, the action is not executed anymore 😔
The Channel attached to the Activity lifecycle coroutine scope is canceled when Activity.onDestroy()
is called as a side effect of coroutine context cancellation.
Solution
Instead of using Channel, I changed to BroadcastChannel + Flow. Similar but different. Let's see!😉
and observing it in the Activity:
BroadcastChannel vs Channel
BroadcastChannel is NOT a specialization of a Channel as the name would suggest. I even found Roman Elizarov comment about this:
Having thought about it a bit more, it looks the whole
BroadcastChannel
is a misnomer. They are not really channels! They are more like some kind of "hot flows".
Using the first approach with Channel, it implements SendChannel and ReceiveChannel that gets closed when the view lifecycle scope is cancelled.
On the other hand, BroadcastChannel only implements SendChannel. A new ReceiveChannel is created to collect items from the BroadcastChanel (openSubscription) every time we launch the Flow (from .asFlow). This way, only the ReceiveChannel is closed when the scope is cancelled and the BroadcastChannel remains opened.
Every flow collector will trigger a new broadcast channel subscription.
fun <T> BroadcastChannel<T>.asFlow()
launch vs launchWhenStarted
LiveData only emits when the LifecycleOwner is on active state (State.STARTED).
If we use launch on our solution, we may have the problematic scenario:
- App in background
Our screen has gone to saved state but our action continues to be consumed. It may lead to an exception if we are trying to commit a fragment transaction for example.
Using launchWhenStarted we achieve the same LiveData behaviour that pauses its consumption if the lifecycle state is "lower" than Started.
Conclusion
I've written about a single LiveData use case exploring some common scenarios in which it may fail, improving our solution. LiveData is really useful and easy to work with Android, but we always need to consider and learn from other solutions.
Further reading
Kotlin: Diving in to Coroutines and Channels
Amazing general article about Channels guided through a coffee shop analogy.
Cold flows, hot channels
Differences between flow and channel.