18 KiB
View model interface
Synopsis
This document explains how data flows between the view model and the UI it
is serving (either an Activity
or Fragment
).
Note: At the time of writing this is correct for
NotificationsViewModel
andNotificationsFragment
. Other components will be updated over time.
After reading this document you should understand:
- How user actions in the UI are communicated to the view model
- How changes in the view model are communicated to the UI
Before reading this document you should:
- Understand Kotlin flows
- Read Guide to app architecture / UI layer
Action and UiState flows
The basics
Every action between the user and application can be reduced to the following:
sequenceDiagram
actor user as User
participant ui as Fragment
participant vm as View Model
user->>+ui: Performs UI action
ui->>+vm: Sends action
vm->>-ui: Sends new UI state
ui->>ui: Updates visible UI
ui-->>-user: Observes changes
In this model, actions always flow from left to right. The user tells the fragment to do something, then te fragment tells the view model to do something.
The view model does not tell the fragment to do something.
State always flows from right to left. The view model tells the fragment "Here's the new state, it up to you how to display it."
Not shown on this diagram, but implicit, is these actions are asynchronous, and the view model may be making one or more requests to other components to gather the data to use for the new UI state.
Rather than modelling this transfer of data as function calls, and by passing callback functions from place to place they can be modelled as Kotlin flows between the Fragment and View Model.
For example:
- The View Model creates two flows and exposes them to the Fragment.
// In the View Model
data class UiAction(val action: String) { ... }
data class UiState(...) { ... }
val actionFlow = MutableSharedFlow<UiAction>()
val uiStateFlow = StateFlow<UiState>()
init {
// ...
viewModelScope.launch {
actionFlow
.collect {
// Do work
// ... work is complete
// Update UI state
uiStateFlow.emit(uiStatFlow.value.update { ... })
}
}
// ...
}
- The fragment collects from
uiStateFlow
, and updates the visible UI, and emits newUiAction
objects in toactionFlow
in response to the user interacting with the UI.
// In the Fragment
fun onViewCreated(...) {
// ...
binding.button.setOnClickListener {
// Won't work, see section "Accepting user actions from the UI" for why
viewModel.actionFlow.emit(UiAction(action = "buttonClick"))
}
lifecycleScope.launch {
viewModel.uiStateFlow.collectLatest { uiState ->
updateUiWithState(uiState)
}
}
// ...
}
This is a good start, but it can be me significantly improved.
Model actions with sealed classes
The prototypical example in the previous section suggested the
UiAction
could be modelled as
data class UiAction(val action: String) { ... }
This is not great.
- It's stringly-typed, with opportunity for run time errors
- Trying to store all possible UI actions in a single type will lead to a plethora of different properties, only some of which are valid for a given action.
These problems can be solved by making UiAction
a sealed class, and
defining subclasses, one per action.
In the case of NotificationsFragment
the actions the user can take in
the UI are:
- Apply a filter to the set of notifications
- Clear the current set of notifications
- Save the ID of the currently visible notification in the list
NOTE: The user can also interact with items in the list of the notifications.
That is handled a little differently because of how code outside
NotificationsFragment
is currently written. It will be adjusted at a later time.
That becomes:
// In the View Model
sealed class UiAction {
data class ApplyFilter(val filter: Set<Filter>) : UiAction()
object ClearNotifications : UiAction()
data class SaveVisibleId(val visibleId: String) : UiAction()
}
This has multiple benefits:
- The actions the view model can act on are defined in a single place
- Each action clearly describes the information it carries with it
- Each action is strongly typed; it is impossible to create an action of the wrong type
- As a sealed class, using the
when
statement to process actions gives us compile-time guarantees all actions are handled
In addition, the view model can spawn multiple coroutines to process the different actions, by filtering out actions dependent on their type, and using other convenience methods on flows. For example:
// In the View Model
val actionFlow = MutableSharedFlow<UiAction>() // As before
init {
// ...
handleApplyFilter()
handleClearNotifications()
handleSaveVisibleId()
// ...
}
fun handleApplyFilter() = viewModelScope.launch {
actionFlow
.filterIsInstance<UiAction.ApplyFilter>()
.distinctUntilChanged()
.collect { action ->
// Apply the filter, update state
}
}
fun handleClearNotifications() = viewModelScope.launch {
actionFlow
.filterIsInstance<UiAction.ClearNotifications>()
.distinctUntilChanged()
.collect { action ->
// Clear notifications, update state
}
}
fun handleSaveVisibleId() = viewModelScope.launch {
actionFlow
.filterIsInstance<UiAction.SaveVisibleId>()
.distinctUntilChanged()
.collect { action ->
// Save the ID, no need to update state
}
}
Each of those runs in separate coroutines and ignores duplicate events.
Accepting user actions from the UI
Example code earlier had this snippet, which does not work.
// In the Fragment
binding.button.setOnClickListener {
// Won't work, see section "Accepting user actions from the UI" for why
viewModel.actionFlow.emit(UiAction(action = "buttonClick"))
}
This fails because emit()
is a suspend fun
, so it must be called from a
coroutine scope.
To fix this, provide a function or property in the view model that accepts
UiAction
and emits them in actionFlow
under the view model's scope.
// In the View Model
val accept: (UiAction) -> Unit = { action ->
viewModelScope.launch { actionFlow.emit(action)}
}
When the Fragment wants to send a UiAction
to the view model it:
// In the Fragment
binding.button.setOnClickListener {
viewModel.accept(UiAction.ClearNotifications)
}
Model the difference between fallible and infallible actions
An infallible action either cannot fail, or, can fail but there are no user-visible changes to the UI.
Conversely, a fallible action can fail and the user should be notified.
I've found it helpful to distinguish between the two at the type level, as it simplifies error handling in the Fragment.
So the actions in NotificationFragment
are modelled as:
// In the View Model
sealed class UiAction
sealed class FallibleUiAction : UiAction() {
// Actions that can fail are modelled here
// ...
}
sealed class InfallibleUiAction : UiAction() {
// Actions that cannot fail are modelled here
// ...
}
Additional UiAction
subclasses
It can be useful to have a deeper UiAction
class hierarchy, as filtering
flows by the class of item in the flow is straightforward.
NotificationsViewModel
splits the fallible actions the user can take as
operating on three different parts of the UI:
- Everything not the list of notifications
- Notifications in the list of notifications
- Statuses in the list of notifications
Those last two are modelled as:
// In the View Model
sealed class NotificationAction : FallibleUiAction() {
// subclasses here
}
sealed class StatusAction(
open val statusViewData: StatusViewData.Concrete
) : FallibleUiAction() {
// subclasses here
}
Separate handling for actions on notifications and statuses is then achieved with code like:
viewModelScope.launch {
uiAction.filterIsInstance<NotificationAction>()
.collect { action ->
// Process notification actions here
}
}
viewModelScope.launch {
uiAction.filterIsInstance<StatusAction>()
.collect { action ->
// Process status actions where
}
}
At the time of writing the UI action hierarchy for NotificationsViewModel
is:
classDiagram
direction LR
UiAction <|-- InfallibleUiAction
InfallibleUiAction <|-- SaveVisibleId
InfallibleUiAction <|-- ApplyFilter
UiAction <|-- FallibleUiAction
FallibleUiAction <|-- ClearNotifications
FallibleUiAction <|-- NotificationAction
NotificationAction <|-- AcceptFollowRequest
NotificationAction <|-- RejectFollowRequest
FallibleUiAction <|-- StatusAction
StatusAction <|-- Bookmark
StatusAction <|-- Favourite
StatusAction <|-- Reblog
StatusAction <|-- VoteInPoll
Multiple output flows
So far the UI has been modelled as a single output flow of a single UiState
type.
For simple UIs that can be sufficient. As the UI gets more complex it can be helpful to separate these in to different flows.
In some cases the Android framework requires you to do this. For
example, the flow of PagingData
in to the adapter is provided and
managed by the PagingData
class. You should not attempt to reassign
it or update it during normal operation.
Similarly, RecyclerView.Adapter
provides its own loadStateFlow
, which
communicates information about the loading state of data in to the adapter.
For NotificationsViewModel
I have found it helpful to provide flows to
separate the following types
PagingData
in to the adapterUiState
, representing UI state outside the mainRecyclerView
StatusDisplayOptions
, representing the user's preferences for how all statuses should be displayedUiSuccess
, representing transient notifications about a fallible action succeedingUiError
, representing transient notifications about a fallible action failing
There are separated this way to roughly match how the Fragment will want to process them.
PagingData
is handed to the adapter and not modified by the FragmentUiState
is generally updated no matter what has changed.StatusDisplayOptions
is handled by rebinding all visible items in the list, without disturbing the rest of the UIUiSuccess
show a brief snackbar without disturbing the rest of the UIUiError
show a fixed snackbar with a "Retry" option
They also have different statefulness requirements, which makes separating them in to different flows a sensible approach.
PagingData
, UiState
, and StatusDisplayOptions
are stateful -- if the
Fragment disconnects from the flow and then reconnects (e.g., because of a
configuration change) the Fragment should receive the most recent state of
each of these.
UiSuccess
and UiError
are not stateful. The success and error messages are
transient; if one has been shown, and there is a subsequent configuration
change the user should not see the success or error message again.
Modelling success and failure for fallible actions
A fallible action should have models capturing success and failure information, and be communicated to the UI.
Note: Infallible actions, by definition, neither succeed or fail, so there is no need to model those states for them.
Suppose the user has clicked on the "bookmark" button on a status,
sending a UiAction.FallibleAction.StatusAction.Bookmark(...)
to the
view model.
The view model processes the action, and is successful.
To signal this back to the UI it emits a UiSuccess
subclass for the action's
type in to the uiSuccess
flow, and includes the original action request.
You can read this as the action
in the UiAction
is a message from the
Fragment saying "Here is the action I want to be performed" and the action
in UiSuccess
is the View Model saying "Here is the action that was carried
out."
Unsurprisingly, this is modelled with a UiSuccess
class, and per-action
subclasses.
Failures are modelled similarly, with a UiError
class. However, details
about the error are included, as well as the original action.
So each fallible action has three associated classes; one for the action, one to represent the action succeeding, and one to represent the action failing.
For the single "bookmark a status" action the code for its three classes looks like this:
// In the View Model
sealed class StatusAction(
open val statusViewData: StatusViewData.Concrete
) : FallibleUiAction() {
data class Bookmark(
val state: Boolean,
override val statusViewData: StatusViewData.Concrete
) : StatusAction(statusViewData)
// ... other actions here
}
sealed class StatusActionSuccess(open val action: StatusAction) : UiSuccess () {
data class Bookmark(override val action: StatusAction.Bookmark) :
StatusActionSuccess(action)
// ... other action successes here
companion object {
fun from (action: StatusAction) = when (action) {
is StatusAction.Bookmark -> Bookmark(action)
// ... other actions here
}
}
}
sealed class UiError(
open val exception: Exception,
@StringRes val message: Int,
open val action: UiAction? = null
) {
data class Bookmark(
override val exception: Exception,
override val action: StatusAction.Bookmark
) : UiError(exception, R.string.ui_error_bookmark, action)
// ... other action errors here
companion object {
fun make(exception: Exception, action: FallibleUiAction) = when (action) {
is StatusAction.Bookmark -> Bookmark(exception, action)
// other actions here
}
}
}
Note: I haven't found it necessary to create subclasses for
UiError
, as all fallible errors (so far) are handled identically. This may change in the future.
Receiving status actions in the view model (from the uiAction
flow) is then:
// In the View Model
viewModelScope.launch {
uiAction.filterIsInstance<StatusAction>()
.collect { action ->
try {
when (action) {
is StatusAction.Bookmark -> {
// Process the request
}
// Other action types handled here
}
uiSuccess.emit(StatusActionSuccess.from(action))
} catch (e: Exception) {
uiError.emit(UiError.make(e, action))
}
}
}
Basic success handling in the fragment would be:
// In the Fragment
lifecycleScope.launch {
// Show a generic message when an action succeeds
this.launch {
viewModel.uiSuccess.collect {
Snackbar.make(binding.root, "Success!", LENGTH_SHORT).show()
}
}
}
In practice it is more complicated, with different actions depending on the type of success.
Basic error handling in the fragment would be:
lifecycleScope.launch {
// Show a specific error when an action fails
this.launch {
viewModel.uiError.collect { error ->
SnackBar.make(
binding.root,
getString(error.message),
LENGTH_LONG
).show()
}
}
}
Supporting "retry" semantics
This approach has an extremely helpful benefit. By including the original
action in the UiError
response, implementing a "retry" function is as
simple as re-sending the original action (included in the error) back to
the view model.
lifecycleScope.launch {
// Show a specific error when an action fails. Provide a "Retry" option
// on the snackbar, and re-send the original action to retry.
this.launch {
viewModel.uiError.collect { error ->
val snackbar = SnackBar.make(
binding.root,
getString(error.message),
LENGTH_LONG
)
error.action?.let { action ->
snackbar.setAction("Retry") { viewModel.accept(action) }
}
snackbar.show()
}
}
}
Updated sequence diagram
sequenceDiagram
actor user as User
participant ui as Fragment
participant vm as View Model
user->>ui: Performs UI action
activate ui
ui->>+vm: viewModel.accept(UiAction.*())
deactivate ui
vm->>vm: Perform action
alt Update UI state?
vm->>vm: emit(UiState(...))
vm-->>ui: UiState(...)
activate ui
ui->>ui: collect UiState, update UI
deactivate ui
else Update StatusDisplayOptions?
vm->>vm: emit(StatusDisplayOptions(...))
vm-->>ui: StatusDisplayOption(...)
activate ui
ui->>ui: collect StatusDisplayOptions, rebind list items
deactivate ui
else Successful fallible action
vm->>vm: emit(UiSuccess(...))
vm-->>ui: UiSuccess(...)
activate ui
ui->>ui: collect UiSuccess, show snackbar
deactivate ui
else Failed fallible action
vm->>vm: emit(UiError(...))
vm-->>ui: UiError(...)
activate ui
deactivate vm
ui->>ui: collect UiError, show snackbar with retry
deactivate ui
user->>ui: Presses "Retry"
activate ui
ui->>vm: viewModel.accept(error.action)
deactivate ui
activate vm
vm->>vm: Perform action, emit response...
deactivate vm
end
note over ui,vm: Type of UI change depends on type of object emitted<br>UiState, StatusDisplayOptions, UiSuccess, UiError
ui-->>user: Observes changes