mirror of https://github.com/tuskyapp/Tusky.git
2 changed files with 0 additions and 615 deletions
@ -1,615 +0,0 @@
|
||||
# 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 |
||||
``` |
||||
Loading…
Reference in new issue