The “Jetpack MVVM seven Deadly SINS” series opens again today. This series focuses on common MVVM architecture errors and best practices to help you learn the proper posture for using Jetpack components.

  • One of the Seven deadly SINS of Jetpack MVVM: Taking Fragment as LifecycleOwner
  • Jetpack MVVM’s second of the seven deadly SINS: starting coroutines in launchWhenX
  • Jetpack MVVM seven deadly SINS: Loading data in onViewCreated

preface

In MVVM architecture, we usually use LiveData or StateFlow to realize data communication between ViewModel and View. Their responsive mechanism is very suitable for sending updated State to UI side. However, using them to send events is not appropriate as an EventBus

1. “States” and “Events”

Although both “states” and “events” can be notified to the UI side in a reactive manner, their consumption scenarios are different:

  • State: Is the content that needs to be rendered by the UI for a long time. The content rendered remains unchanged until the new State arrives. Such as displaying a Loading box or a set of requested data sets.
  • Event: an action that needs to be executed immediately by the UI. It is a short-term action. Display a Toast, SnackBar, complete a page navigation, etc.

We list the specific differences between states and events from three dimensions: coverage, timeliness and idempotence

state The event
coverage The new state overwrites the old state. If multiple status updates occur in a short period of time, you can discard the intermediate state and retain only the latest state. This is also why data loss occurs when LiveData continues to post values. New events should not override old events, and subscribers receive all events in the order they were sent, with no intermediate events left out.
timeliness The latest status is permanent and can be accessed all the time, so the status is typically “sticky”, sending the latest status to new subscriptions as they appear. Events can only be consumed once and should be discarded after consumption. Therefore events are generally not “sticky” and avoid multiple purchases.
idempotence States are idempotent, unique states determine unique UI, and the same state doesn’t need to respond more than once. Therefore, When StateFlow sets value, it compares old data with new data to avoid repeated sending. Subscribers need to consume each event sent, even if the same type of event is sent multiple times.

2. Event processing based on LiveData

Given the many differences between events and states, if you send events directly using LiveData or StateFlow, you can expect unexpected behavior. Perhaps the most common of these is the so-called “data dump” problem.

I usually don’t like to use the word “data inversion”, mainly because the word “inversion” is contrary to the idea of one-way data flow, which is easy to cause misunderstanding. I guess the inventor of the word wants to use it to emphasize a kind of “passive” reception.

The “data flooding” problem arises from the “sticky” design of LiveData, where the same subscriber receives the most recent event every time he subscribes to LiveData, because events are supposed to be “time-sensitive” and we don’t want to respond to events that have already been consumed.

Jose Alcerreca first discussed how LiveData handles events in his article LiveData with SnackBar, Navigation and Other Events, And the solution idea of SingleLiveEvent is given in architecture-sample-todoapp. Inspired by this article, a number of leaders have come up with better solutions to fix some defects in SingleLiveEvent, such as not supporting multiple subscribers, but the main solutions are basically the same: Flag bits are added to record whether events are consumed, and consumed events are not sent again at subscription time.

Here is a relatively complete solution:

open class LiveEvent<T> : MediatorLiveData<T>() {

    private val observers = ArraySet<ObserverWrapper<in T>>()

    @MainThread
    override fun observe(owner: LifecycleOwner, observer: Observer<in T>){ observers.find { it.observer === observer }? .let { _ ->// existing
            return
        }
        val wrapper = ObserverWrapper(observer)
        observers.add(wrapper)
        super.observe(owner, wrapper)
    }

    @MainThread
    override fun observeForever(observer: Observer<in T>){ observers.find { it.observer === observer }? .let { _ ->// existing
            return
        }
        val wrapper = ObserverWrapper(observer)
        observers.add(wrapper)
        super.observeForever(wrapper)
    }

    @MainThread
    override fun removeObserver(observer: Observer<in T>) {
        if (observer is ObserverWrapper && observers.remove(observer)) {
            super.removeObserver(observer)
            return
        }
        val iterator = observers.iterator()
        while (iterator.hasNext()) {
            val wrapper = iterator.next()
            if (wrapper.observer == observer) {
                iterator.remove()
                super.removeObserver(wrapper)
                break}}}@MainThread
    override fun setValue(t: T?). {
        observers.forEach { it.newValue() }
        super.setValue(t)
    }

    private class ObserverWrapper<T>(val observer: Observer<T>) : Observer<T> {

        private var pending = false

        override fun onChanged(t: T?). {
            if (pending) {
                pending = false
                observer.onChanged(t)
            }
        }

        fun newValue(a) {
            pending = true}}}Copy the code

The code is clear. After wrapping the Observer with ObserverWrapper, we can use Pending to log the consumption of events for a single consumer, avoiding a second consumption.

With the support for Coroutine by Lifecycle runtime-kTX, Flow will become the mainstream way for data communication. Flow will become the mainstream data communication method.

3. Event processing based on SharedFlow

StateFlow is as sticky as LiveData, suffers from “data flooding” and even “data loss” because it filters and compares new data to new data when updateState is performed. Events of the same type may be discarded.

In Shared Flows, Broadcast Channels, Roman Elizarov proposed using SharedFlow to implement EventBus:

class BroadcastEventBus {
    private val _events = MutableSharedFlow<Event>()
    val events = _events.asSharedFlow() // read-only public view

    suspend fun postEvent(event: Event) {
        _events.emit(event) 
    }
}
Copy the code

SharedFlow is a good choice, and many of its features match the way events are consumed:

  • First, it can have multiple collectors (subscribers) that “share” events and broadcast them, as shown below:

  • Second, SharedFlow data will be sent as a stream and will not be lost. New events will not overwrite old events.
  • Finally, its data is not sticky, and consumption once won’t come back.

However, there is a problem with SharedFlow. The receiver cannot receive events sent before COLLECT. See the following example:

class MainViewModel : ViewModel(), DefaultLifecycleObserver {

    private val _toast = MutableSharedFlow<String>()
    val showToast = _toast.asSharedFlow()
    
    init {
        viewModelScope.launch {
            delay(1000)
            _toast.emit("Toast")}}}//Fragment side
viewLifecycleOwner.lifecycleScope.launch {
    viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
        mainViewModel.showToast.collect {
            Toast.makeText(context, it, Toast.LENGTH_SHORT).show()
        }
    }
}
Copy the code

In the example, we use repeatOnLifecycle to ensure that the event collection starts after STARTD. If delay(1000) code is commented out at this point, the emit is earlier than collect, so toast will not be displayed.

Sometimes we issue an event before the subscription occurs and expect the response to be executed when the subscriber appears, such as completing an initialization task, etc. Note that this is not a “data dump” because it is only allowed to consume once. Once consumed, it is no longer sent, so the replay parameter of SharedFlow cannot be used. Because Repaly can’t guarantee a one-time purchase.

4. Channel-based event processing

Roman Elizarov also provides a solution to SharedFlow’s shortcomings by using channels.

class SingleShotEventBus {
    private val _events = Channel<Event>()
    val events = _events.receiveAsFlow() // expose as flow

    suspend fun postEvent(event: Event) {
        _events.send(event) // suspends on buffer overflow}}Copy the code

When a Channel has no subscribers, the data sent to it is suspended to ensure that the subscriber indirectly receives the data when it appears, similar to the principle of blocking queues. The Channel itself is also the basis for the Flow implementation, so receiveAsFlow can be turned into a Flow to expose to subscribers. Looking back at the previous example, change to Channel as follows:

class MainViewModel : ViewModel(), DefaultLifecycleObserver {

    private val _toast = Channel<String>()
    val showToast = _toast.receiveAsFlow()
    
    init {
        viewModelScope.launch {
            _toast.send("Toast")}}}//Fragment side
viewLifecycleOwner.lifecycleScope.launch {
    viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
        mainViewModel.showToast.collect {
            Toast.makeText(context, it, Toast.LENGTH_SHORT).show()
        }
    }
}
Copy the code

The UI side is still for Flow subscription, and the code does not change, but it can also accept sent events after state.

Note that channels also have a usage limitation. When a Channel has multiple collectors, they cannot share the data transmitted by the Channel. Each data can only be owned by one collector, so channels are better suited for one-to-one communication scenarios.

To sum up, SharedFlow and Channel have their own characteristics in event processing, so you need to choose them flexibly according to the actual scenario:

SharedFlow Channel
Number of subscribers Subscribers share notifications, enabling one-to-many broadcasting Only one subscriber can receive each message for one-to-one communication
The event to accept Events before collect are lost The first subscriber can receive events prior to Collect

In order to receive events at the right time, an event subscription is usually done with Lifecycle – Run-time KTX, such as repeatOnLifecycle used in the previous example (see Jetpack MVVM SINS 2: Launch coroutines in launchWhenX), here is a way to avoid template code for your reference only

inline fun <reified T> Flow<T>.observeWithLifecycle(
        lifecycleOwner: LifecycleOwner,
        minActiveState: Lifecycle.State = Lifecycle.State.STARTED,
        noinline action: suspend (T) - >Unit
): Job = lifecycleOwner.lifecycleScope.launch {
    flowWithLifecycle(lifecycleOwner.lifecycle, minActiveState).collect(action)
}

inline fun <reified T> Flow<T>.observeWithLifecycle(
        fragment: Fragment,
        minActiveState: Lifecycle.State = Lifecycle.State.STARTED,
        noinline action: suspend (T) - >Unit
): Job = fragment.viewLifecycleOwner.lifecycleScope.launch {
    flowWithLifecycle(fragment.viewLifecycleOwner.lifecycle, minActiveState).collect(action)
}
Copy the code

As mentioned above, observeWithLifecycle is an extension to Flow that subscribs to the specified lifecycle, so the code on the UI side can be simplified as follows:

viewModel.events
    .observeWithLifecycle(fragment = this, minActiveState = Lifecycle.State.RESUMED) {
        // do things
    }

viewModel.events
    .observeWithLifecycle(lifecycleOwner = viewLifecycleOwner, minActiveState = Lifecycle.State.RESUMED) {
        // do things
    }

Copy the code

Had here is the end of the article, but suddenly found that Google recently updated the architecture specification, especially for MVVM event handling to the new recommendation: developer.android.com/jetpack/gui… , hence the following section……

5. About the latest Google Guide

Here is only a summary of the Guide on event handling, which can be summarized as the following three:

  1. All events sent to the View should involve UI changes, and uI-unrelated events should not be listened for by the View
  2. Since they are UI-related events, they can be sent along with the UI state (based on StateFlow or LiveData) without having to create a new channel.
  3. After processing the event, the View needs to inform the ViewModel that the event has been handled, and the ViewModel updates the state to avoid further consumption

All three of these are summed up in one sentence: Manage Events like States

With the official example code, see the concrete implementation:

// Models the message to show on the screen.
data class UserMessage(val id: Long.val message: String)

// Models the UI state for the Latest news screen.
data class LatestNewsUiState(
    val news: List<News> = emptyList(),
    val isLoading: Boolean = false.val userMessages: List<UserMessage> = emptyList()
)
Copy the code

As above, List

is a List of message events that are managed together with UiState.

class LatestNewsViewModel(/ *... * /) : ViewModel() {

    private val _uiState = MutableStateFlow(LatestNewsUiState(isLoading = true))
    val uiState: StateFlow<LatestNewsUiState> = _uiState

    fun refreshNews(a) {
        viewModelScope.launch {
            // If there isn't internet connection, show a new message on the screen.
            if(! internetConnection()) { _uiState.update { currentUiState ->val messages = currentUiState.userMessages + UserMessage(
                        id = UUID.randomUUID().mostSignificantBits,
                        message = "No Internet connection"
                    )
                    currentUiState.copy(userMessages = messages)
                }
                return@launch
            }

            // Do something else.}}}Copy the code

As above, the ViewModel requests the latest data in refreshNews, and if the network is not connected, adds a userMessage to the View along with the state.

class LatestNewsActivity : AppCompatActivity() {
    private val viewModel: LatestNewsViewModel by viewModels()

    override fun onCreate(savedInstanceState: Bundle?). {
        / *... * /lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.STARTED) { viewModel.uiState.collect { uiState -> uiState.userMessages.firstOrNull()? .let { userMessage ->// TODO: Show Snackbar with userMessage.
                        // Once the message is displayed and
                        // dismissed, notify the ViewModel.
                        viewModel.userMessageShown(userMessage.id)
                    }
                    ...
                }
            }
        }
    }
}
Copy the code

Lateral View subscription UiState state changes, received notice from the state changes, processing the UserMessage events, for example, here is to display a SnackBar, event handling, after calling the viewModel. UserMessageShown method, Notifies the ViewModel that processing is complete.

    fun userMessageShown(messageId: Long) {
        _uiState.update { currentUiState ->
            val messages = currentUiState.userMessages.filterNot { it.id == messageId }
            currentUiState.copy(userMessages = messages)
        }
    }
Copy the code

Finally, take a look at the implementation of userMessageShown, which removes the relevant information from the message list to indicate that the message has been consumed.

In fact, Jose Alcerreca had mentioned this processing idea and denied it in the article “LiveData with SnackBar, Navigation and other events”.

With this approach you add a way to indicate from the View that you already handled the event and that it should be reset.

The problem with this approach is that there’s some boilerplate (one new method in the ViewModel per event) and it’s error prone; it’s easy to forget the call to the ViewModel from the observer.

The negative reason is that this increases the template code and is easy to miss the View -> ViewModel reverse notification. Jose’s post is personal, but since it’s so popular, Google’s backrecommendation feels a bit insulting. But if you think about it, it does make sense:

  1. It avoids the introduction of SharedFlow, Channel and other more tools, the technology stack is more concise.
  2. Weakening the concept of “events” and strengthening the concept of “states” is essentially giving way to the imperative logic of state-driven thinking, which is closer to the Compose concept and conducive to the further promotion of declarative UI
  3. Manage “events” like “states,” with return receipt and traceability, and increase the chance of “post-processing” events

Of course, there are also hidden dangers. For example, before the event processing ends and the receipt is given, if a new status notification comes, will repeated consumption be caused because the current event is not cleared in the event list? This needs to be further verified.

6. Summary

This article introduces a variety of MVVM event processing schemes, there is no perfect scheme, we need to combine the specific scene to make a choice:

  • If your project is still using LiveData, you will need to keep track of the consumption of events to avoid secondary consumption of events, as described in this articleLiveEventAn example of
  • If most of your code is Kotlin, it is recommended to use Coroutine for MVVM data communication first. In this case, events can be handled using SharedFlow or Channel if you want to receive events before Collect
  • If possible, consider adopting Google’s latest architectural specification, although it is a bit redundant in writing and adds a load to the View, so whether it will be approved by developers remains to be seen.

The most efficient way to handle events is to avoid defining “events” and try to design your data communications with “states” instead of “events,” which is more in line with data-driven architecture.

reference

  • [1] Jose Alcerreca, LiveData with SnackBar, Navigation and Other Events
  • [2] Hadi Lashkari Ghouchani , LiveData with single events
  • [3] Roman Elizarov , Shared flows, broadcast channels
  • [4] Michael Ferguson , Android SingleLiveEvent Redux with Kotlin Flow