mirror of
https://github.com/accelforce/Yuito
synced 2024-12-22 21:28:08 +01:00
615 lines
18 KiB
Markdown
615 lines
18 KiB
Markdown
|
# 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`
|
||
|
> and `NotificationsFragment`. 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](https://developer.android.com/topic/architecture/ui-layer)
|
||
|
|
||
|
## Action and UiState flows
|
||
|
|
||
|
### The basics
|
||
|
|
||
|
Every action between the user and application can be reduced to the following:
|
||
|
|
||
|
```mermaid
|
||
|
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:
|
||
|
|
||
|
1. The View Model creates two flows and exposes them to the Fragment.
|
||
|
|
||
|
```kotlin
|
||
|
// 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 { ... })
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// ...
|
||
|
}
|
||
|
```
|
||
|
|
||
|
2. The fragment collects from `uiStateFlow`, and updates the visible UI,
|
||
|
and emits new `UiAction` objects in to `actionFlow` in response to the
|
||
|
user interacting with the UI.
|
||
|
|
||
|
```kotlin
|
||
|
// 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
|
||
|
|
||
|
```kotlin
|
||
|
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:
|
||
|
|
||
|
```kotlin
|
||
|
// 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:
|
||
|
|
||
|
```kotlin
|
||
|
// 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.
|
||
|
|
||
|
```kotlin
|
||
|
// 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.
|
||
|
|
||
|
```kotlin
|
||
|
// 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:
|
||
|
|
||
|
```kotlin
|
||
|
// 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:
|
||
|
|
||
|
```kotlin
|
||
|
// 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:
|
||
|
|
||
|
```kotlin
|
||
|
// 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:
|
||
|
|
||
|
```kotlin
|
||
|
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:
|
||
|
|
||
|
```mermaid
|
||
|
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 adapter
|
||
|
- `UiState`, representing UI state *outside* the main `RecyclerView`
|
||
|
- `StatusDisplayOptions`, representing the user's preferences for how
|
||
|
all statuses should be displayed
|
||
|
- `UiSuccess`, representing transient notifications about a
|
||
|
fallible action succeeding
|
||
|
- `UiError`, 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 Fragment
|
||
|
- `UiState` 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 UI
|
||
|
- `UiSuccess` show a brief snackbar without disturbing the rest
|
||
|
of the UI
|
||
|
- `UiError` 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:
|
||
|
|
||
|
```kotlin
|
||
|
// 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:
|
||
|
|
||
|
```kotlin
|
||
|
// 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:
|
||
|
|
||
|
```kotlin
|
||
|
// 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:
|
||
|
|
||
|
```kotlin
|
||
|
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.
|
||
|
|
||
|
```kotlin
|
||
|
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
|
||
|
|
||
|
```mermaid
|
||
|
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
|
||
|
```
|