1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176
|
---
layout: page
title: Introduce a Store for UI components
permalink: /rfc/0012-introduce-ui-store
---
* Start date: 2024-01-29
* RFC PR: [#5353](https://github.com/mozilla-mobile/firefox-android/pull/5353)
## Summary
In most applications of the `Store`, it is preferable to have reducers perform work on the main thread. Having actions reduced immediately at the point of dispatch, simplifies the reasoning a developer would need to go through for most UI-based work that happens on the main thread.
## Motivation
Android embedders use the main thread for UI, user-facing, or gesture handling work. For example, notifying UI components when IO from storage layers have completed, an engine's task that can happen on a separate thread, or global-level state updates for different components to observe.
When components dispatch actions, they are performed on an independant single thread dispatcher in the `Store` to avoid overloading the main thread with heavy work that might be performed during the `reduce` or in a `Middleware`. In practice, these actions have been short and fast so they do not cause overhead (most of these actions have been [data class copying][0]). In addition, side-effects done in a `Middleware` which can be slow, like I/O, are put onto separate Dispatchers. The performance optimization to switch to a `Store` thread, requires that components which are always run on the main thread, to ensure synchronisation is now kept between the main thread and the store thread for observers of the `State`.
There are some advantages to this change:
* Simplicity for `Store`s that are meant for UI facing work.
* Unit testing can now occur on the test framework's thread.
* Fewer resources needed for context shifting between threads[^1].
For an example of thread simplicity, an `Engine` typically has its own 'engine thread' to perform async work and post/request results to the main thread (these APIs are identified with the `@UiThread` annotation). Once we get the callback for those results, we then need to dispatch an action to the store that will then happen on a `Store` thread. Feature components then observe for state changes and then make UI changes on the main thread. A simplified form of this thread context switching can be seen in the example below:
```kotlin
// engine thread
engineView.requestApiResult { result ->
// received on the main thread.
store.dispatch(UpdateResultAction(result))
}
// store thread
fun reduce(state: State, action: Action) {
is UpdateResultAction -> {
// do things here.
}
}
// store thread
Middleware {
override fun invoke(
context: MiddlewareContext<State, Action>,
next: (Action) -> Unit,
action: Action,
) {
// perform side-effects that also happen on the store thread.
}
}
// main thread
store.flowScoped { flow ->
flow.collect {
// perform work on the main thread.
}
}
```
With the changes in this RFC, this switching of threads can be reduced (notable comments marked with 📝):
```kotlin
// engine thread
engineView.requestApiResult { result ->
// received on the main thread.
store.dispatch(UpdateResultAction(result))
}
// 📝 main thread - now on the same thread, processed immediately.
fun reduce(state: State, action: Action) {
is UpdateResultAction -> {
// do things here.
}
}
// 📝 main thread - now on the same thread, processed immediately.
Middleware {
override fun invoke(
context: MiddlewareContext<State, Action>,
next: (Action) -> Unit,
action: Action,
) {
// 📝 perform side-effects that now happen on the main thread.
}
}
// main thread
store.flowScoped { flow ->
flow.collect {
// perform work on the main thread.
}
}
}
```
Additionally, from [performance investigations already done][2], we know that Fenix creates over a hundred threads within a few seconds of startup. Reducing the number of threads for Stores that do not have a strong requirement to run on a separate thread will lower the applications memory footprint.
## Guide-level explanation
Extending the existing `Store` class to use the `Dispatchers.Main.immediate` will ensure that UI stores will stay on the same UI thread and have that work done immediately. Using a distinct class named `UiStore` also makes it clear to the developer that this is work that will be done on the UI thread and its implications will be made a bit more clear when it's used.
```kotlin
@MainThread
open class UiStore<S : State, A : Action>(
initialState: S,
reducer: Reducer<S, A>,
middleware: List<Middleware<S, A>> = emptyList(),
) : Store<S, A>(
initialState,
reducer,
middleware,
UiStoreDispatcher(),
)
open class Store<S : State, A : Action> internal constructor(
initialState: S,
reducer: Reducer<S, A>,
middleware: List<Middleware<S, A>>,
dispatcher: StoreDispatcher,
) {
constructor(
initialState: S,
reducer: Reducer<S, A>,
middleware: List<Middleware<S, A>> = emptyList(),
threadNamePrefix: String? = null,
) : this(
initialState = initialState,
reducer = reducer,
middleware = middleware,
dispatcher = DefaultStoreDispatcher(threadNamePrefix),
)
}
interface StoreDispatcher {
val dispatcher: CoroutineDispatcher
val scope: CoroutineScope
val coroutineContext: CoroutineContext
// Each Store has it's own `assertOnThread` because in the Thread owner is different in both context.
fun assertOnThread()
}
```
Applications can use this similar to any other store then. An "AppStore" example below can switch :
```kotlin
// changing the one line below from `UiStore` to `Store` gives the developer the ability to switch existing Stores between the different Store types.
class AppStore(
initialState: AppState = AppState(),
) : UiStore<AppState, AppAction>(
initialState = initialState,
reducer = AppStoreReducer::reduce,
)
```
## Drawbacks
* Mistakenly doing work on the main thread - we could end up performing large amounts of work on the main thread unintentionally if we are not careful. This could be because of a large number of small tasks, a single large task, a blocking task, or a combination. As the developer is choosing to use a `UiStore`, they will be expected to ensure that heavy work they do, as is with mobile UI development done today, is not done on the main thread.
## Rationale and alternatives
Not introducing this new Store type would not change current development where the developer needs to ensure understanding that dispatched actions will be processed at a later time.
## Future work
We have opportunities to iterate from here and consider if/how we want to pass a CoroutineScope in. This can be part of future RFC proposals however.
## Unresolved questions
* While performance gains are not an explicit intent, there is a theoretical advantage, but not one we will pursue as part of this RFC. How much would we save, if any?
* Some additional changes need to be done to allow the `Store` to override the default `StoreThreadFactory` that will allow assertions against a thread (`MainThread`) not created by the `StoreThreadFactory` itself. This should be possible, but will this add to additional complexity?
[0]: https://kotlinlang.org/docs/data-classes.html#copying
[^1]: https://github.com/mozilla-mobile/android-components/issues/9424
[2]: https://github.com/mozilla-mobile/android-components/issues/9424#issue-787013588
|