mirror of
https://github.com/accelforce/Yuito
synced 2024-12-09 14:57:35 +01:00
move Release.md to doc folder, remove ViewModelInterface.md (#4034)
This commit is contained in:
parent
5764c903e1
commit
fbd99717c0
@ -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
Block a user