or yet another redux of SingleLiveEvent for 2022
It’s been almost a year since I wrote an article on how I handle the communication pattern of sending one-shot actions from an Android view model to it’s associated view such as a fragment or activity.
As a quick recap, my previous article suggested using a Kotlin channel, received as a flow, to emit events that an observer may receive during appropriate lifecycle states. I described why it should be a channel (received as a flow) and not something like SharedFlow as well as other pitfalls that might happen when observing the flow.
This article was itself a response to an earlier 2018 article from Jose Alcérreca: The SingleLiveEvent case.
LiveData with SnackBar, Navigation and other events (the SingleLiveEvent case)
Much has happened since both our articles were published. Jetpack Compose has come roaring on to the scene with its dramatically different take on UI. With it has come some very opinionated guidance from Google on the different communication patterns that an app should adopt and the way that data flows between the UI and the view model.
This article is going to focus on Google’s new guidance with respect to the communication pattern for one-shot actions between an Android view model and its associated view.
Background
Following with the pattern of previous articles, I’m going to continue to define an event as a notice to take an action once and once only. Some developers use the term side effect. Google uses the term ViewModel event, to differentiate it from a UI event which is something like a click or a swipe.
Whatever your terminology, the purpose of this event is for the view model to inform the view to do something once and once only. Some examples that come to mind: displaying a toast or a snackbar; performing fragment navigation using navigation components; starting an activity; initiating a permission request.
For the purpose of this article I’m also going to continue to use the MVVM pattern where view model exposes the state of the UI following the observer pattern. My personal preference is to expose the UI state as a StateFlow
in the view model. For example:
class MyViewMode: ViewModel() { | |
data class ViewState( | |
val someUIProperty: String = "", | |
val someOtherUIProperty: Int = 1, | |
) | |
private val _viewState = MutableStateFlow<ViewState>(ViewState()) | |
val viewState = _viewState.asStateFlow() | |
} |
Some developers prefer LiveData
but whatever your choice the UI state is exposed by the ViewModel as an observable property that can be observed by the view during whatever lifecycle state might be convenient. Something like this perhaps:
// In your view/fragment | |
viewLifecycleOwner.lifecycleScope.launch { | |
viewModel.viewState | |
.flowWithLifecycle(viewLifecycleOwner.lifecycle, Lifecycle.State.STARTED) | |
.collect { | |
// do something with the UI updates | |
} | |
} |
Finally, as a recap, I had previously implemented events using a channel received as a flow. (It’s this event channel we’re going to remove/update.) For example:
// In your view model | |
private val _eventChannel = Channel<Event>(Channel.BUFFERED) | |
val events = _eventChannel.receiveAsFlow() |
I will direct you to my previous article to explain why I chose this pattern to emit events.
Requirements for Events
Have the requirements for events changed with Google’s new guidance?
Presumably events are important, even critical. I don’t think the requirements for events have changed since my previous article. Here they are pasted verbatim:
- New events cannot overwrite unobserved events.
- If there is no observer, events must buffer until an observer starts to consume them.
- The view may have important lifecycle states during which it can only observe events safely. Thus the observer may not always be active or consuming the flow at a given moment in time.
Given the change to Google’s guidance I’ll call out a fourth implicit requirement that wasn’t present in my previous article:
4. Events may be observed once and once only.
Google’s New Guidance
With the above view model communication pattern and event requirements defined, how do we handle events that need to be observed once and once only following Google’s new, opinionated, guidance?
You can find Google’s new guidance here:
https://developer.android.com/jetpack/guide/ui-layer/events#handle-viewmodel-events
The guidance is surprisingly short and simple:
- Events must result in a UI update and thus must be emitted alongside all other UI updates. (That is events a part of the stream of UI updates, not a seperate stream.)
- Consuming the events can trigger UI state updates.
- The View is responsible for notifying the ViewModel that the event has been processed.
That’s it! So how does this work? Let’s take a look.
Let’s look at how Google is defining events in their guidance first.
UserMessage events
Job Offers
In the above example, the userMessages
property of the UI state is the collection of transient messages to be displayed on the UI as a toast or as a snackbar, for example. In Google’s sample, they are adding to that collection when they want a message (event) to be displayed in the UI and removing a value from that collection when the message (event) has been displayed.
The ViewModel emitting an event
As you can see, from their example, emitting an event is just adding it to a list that is part of the UI state. In the view (fragment or activity) they are observing the view state to receive those one-shot events during safe lifecycle states:
The View’s observer of events
Finally, to close the loop, the view (fragment or activity) is responsible for notifying the view model that the message has been processed. The view model then removes that event from the UI state.
The ViewModel function to remove an event from the UI state
This is a significant change from using channels and another reactive stream to emit events that an observer can consume! Let’s look to see how Google’s implementation satisfies the set of requirements for events I’ve defined.
The first requirement “New events cannot overwrite unobserved events” is satisfied by the use of a list (rather than say a set) to store the messages.
The second requirement “If there is no observer, events must buffer until an observer starts to consume them” is satisfied by the use of a the UI state being preserved by something like StateFlow
or LiveData
. The list of events will just grow if there’s no observers so there’s no issue here.
The third requirement “The view may have important lifecycle states during which it can only observe events safely” is also satisfied by the observer collecting UI states only between start and stop using the repeatOnLifecycle
helper.
Finally, the fourth requirement “Events may be observed once and once only” is satisfied by the view informing the view model that an event has been processed and the view model removing said event from the list of pending events.
Some Notes
So far so good? Just make events a list within the UI state, observe that list and inform the view model when those events have been processed. That seems straightforward, so what’s not called out explicitly that is also important?
Unique Identifiers
The first note is that Google’s method requires that all events have a unique identifier. This is needed because without it the view’s call to the view model to remove a processed event wouldn’t be able to determine which event needs removal from the collection. (Say you had a contrived case where you want to post the same event multiple times, but to ensure that each event is actually processed multiple times they must be truly unique from each other.)
Perhaps this can be made simpler with the use of a sealed class or something that automatically generates the identifier such as the following:
sealed class Event { val uniqueId: String = UUID.randomUUID().toString() data class MessageEvent(val message: String): Event() object MarkerEvent: Event() }
Busy Looping
The second note is the way the UI state is updated, and by extension the list of events to be processed. Google’s example uses a pretty straightforward method of updating the UI state using the update
method that MutableStateFlow
offers. (Shameless plug, I wrote about atomic updates with MutableStateFlow a little while ago.) If you’re not using MutableStateFlow
‘s update
function for your UI state you may need to use a mutex or some other sort to protected access to prevent the UI state and the list of events from mutating while you add or remove values.
Even with the use of the update
function you need to be aware of how it works internally. It would be very easy to make a mistake and remove unobserved events if you’re not careful to use it everywhere you update your UI state.
Mandatory Callback
The final note is implicit in the definition of an event that is to be observed once and once only. The view must inform the view model that an event has been processed. This places the entire responsibility of ensuring events are actually events with the view. Without it the entire flow falls apart and the events are no longer single observed events.
This is something that isn’t necessary when emitting events using a channel. When a value is observed it’s removed from the channel automatically. (But this pattern isn’t without it’s own hidden requirements.)
Is this better?
Conceptually, the idea of stuffing events into the UI state is simpler for the observer. You just observe the events portion of the UI state whenever you want and notify the view model that the event has been consumed. If you haven’t finished processing an event before something cancelled things just re-observe and start again.
The idea of notifying “upwards” is very much in line with the unidirectional data flow pattern that works very nicely with compose. I think this is actually a good thing. The same communication pattern is repeated which makes it easier on a developer to focus on more important issues.
The “bad” part of Google’s new guidance is the small hidden requirements needed to make it work and the absolute need for the view to notify the view model as. Without this callback to the view model the event really isn’t an event. It can be observed multiple times. I put “bad” in quotes because it’s not really that huge an issue but it could be a source of bugs if one isn’t careful.
Finally, Google has this note at the bottom of their guidance:
Note: In some apps, you might have seen ViewModel events being exposed to the UI using Kotlin Channels or other reactive streams. These solutions usually require workarounds such as event wrappers in order to guarantee that events are not lost and that they’re consumed only once.
Requiring workarounds is an indication that there’s a problem with these approaches. The problem with exposing events from the ViewModel is that it goes against the state-down-events-up principle of Unidirectional Data Flow.
I would argue that there are just as many work-arounds in Google’s solution (the required use of a unique ID for each event, the protection required around the collection of events, and the requirement that the view notify the view model when an event has been processed) as there are in other solutions, such as using channels or event wrappers or some combination of the two.
So over all is it better? On the whole I would say, yes, in the context of unidirectional data flow and Compose.
Google’s pattern is a little bit simpler to understand and process. There’s certainly less “magic” to it than using a channel or flow to determine if an event has been consumed. It isn’t without its own set of quirks.
It’s been interesting to see the evolution of how single one-shot type actions are communicated between the view model and its associated view. I bet we see another iteration of this a year from now. 🙂