Image from Codendot
It’s been a while since I have been working on MVI to improve the MVVM pattern to manage a single immutable view state in the reactive flow architecture. In that way, the features are explicit, seem more understandable, and are straightforward to maintain, debug, and test. I considered testing the view state changes, was partially testing the UI in a world without QA engineers, and we have less time to write UI tests.
It started with RxJava to move step by step to coroutines and Flow.
Now, designing Compose views makes more sense to keep MVI as the primary architectural pattern for state management.
Over time, we faced complexities in making apps scalable and maintainable.
I will share an option to reduce MVI complexity from basic implementation to new component delegation.
The Basics of MVI
Model View Intent (MVI) is an architectural presentation pattern inspired by the Redux circular pattern. It offers a reactive and predictable approach to manage state and handle user intents to interact with the app.
MVI unidirectional flow
The Model represents the single state and the business model. It manages the data and responds to user intents to produce a new state. The Model ensures that the state remains immutable and processes user interactions.
The View is responsible for rendering the user interface and displaying the current view state. In MVI, the View is passive and only observes the view state changes from the Model. It does not contain business logic and focuses solely on displaying information to the user.
The Intent represents user actions that trigger state changes. These could be actions like button clicks, text input, or any other user interaction. Intents are dispatched from the View to the Model, indicating the user’s desired action.
The MVI pattern promotes immutability, ensuring the application’s state remains consistent and predictable. Using MVI, developers can build apps that are easier to test, maintain, and scale.
It’s gaining popularity due to its clear separation of concerns and its ability to handle complex state management in a reactive and maintainable way.
In the Android context, migrating an existing MVVM to MVI is simple by grouping the states into one single. It fits nicely with Compose to manage a feature state. Moreover, debugging a single state helps monitor the property changes and understand the app’s behavior.
MVI concrete flow
Here is an example of MVI implementation into the UserListViewModel
to display a list with some item interaction on the screen.
The View
interacts with it to process an Action
. We assume using Action
rather than Intent
to avoid confusing Android-specific terms.
It exposes two flows. Internally, the first is a cold flow of ViewState
typed as StateFlow
to get the current state, and the second is a hot flow of Event
as Channel
. Sometimes, developers prefer using the side effect term instead.
class UserListViewModel(...): ViewModel() { | |
private val _viewStateFlow = MutableStateFlow<ViewState>() | |
private val _eventChannel: Channel<Event> = Channel(BUFFERED) | |
val viewStateFlow: Flow<ViewState> = _viewStateFlow | |
val eventFlow: Flow<Event> = _eventChannel.receiveAsFlow() | |
fun process(action: Action) { | |
is Action.CheckConnectivity -> checkConnectivity() | |
is Action.Load -> load(...) | |
is Action.Reload -> load(...) | |
is Action.EmailClick -> emailClick(uuid = action.uuid) | |
is Action.PhoneClick -> phoneClick(uuid = action.uuid) | |
} | |
private fun checkConnectivity() { ... } | |
private fun load(...) { ... } | |
private fun emailClick(uuid: String) { ... } | |
private fun phoneClick(uuid: String) { ... } | |
} |
UserListViewModel implementation
Sealed or flat state: How to define the view state?
A sealed state is a complex approach to representing the screen state. It allows you to create a closed set of subclasses holding different states.
sealed interface ViewState { | |
data object Loading : ViewState | |
data object Empty : ViewState | |
data class Content( | |
val users: List<UserState>, | |
) : ViewState | |
data class Error( | |
val message: String, | |
) : ViewState | |
} |
Sealed view state
A sealed interface can benefit features with more complex state structures, allowing for better organization and type safety when handling different states.
However, the sealed structure requires good substate switching by managing a cache to make the data more accessible and prevent over-fetching.
A flat state is a straightforward representation by using a data class. It contains all the necessary fields to represent the current state of the screen in a balanced and hierarchical structure.
data class ViewState( | |
val isLoaderVisible: Boolean = false, | |
val isEmptyVisible: Boolean = false, | |
val isUserListVisible: Boolean = false, | |
val users: List<UserState> = emptyList(), | |
val isErrorVisible: Boolean = false, | |
val errorMessage: String = "", | |
) |
Flat hierarchical view state
Job Offers
A flat state is easy to understand and update, especially for more minor features or those with relatively simple state requirements.
Unlike checking the sealed substates, you can manage flags manipulated by the view by changing the behavioral properties. Then, you keep the content data you could immediately display and fetch over.
ViewModel complexity & heaviness issues
The Model component, here the ViewModel, is responsible for processing the actions by calling the logic, the use cases or the repositories, and emitting state changes or events.
The snippet above shows a partial implementation for one of the actions; it already gets over a hundred lines of code. With much more action, the UserListViewModel
becomes challenging to manage and maintain.
The first documentation is the source code. The ViewModel is expected to be concise and explicit. It is the same for the test classes that could be heavier.
class UserListViewModel( | |
private val getUserListRepository: GetUserListRepository, | |
private val getUserContactRepository: GetUserContactRepository, | |
private val connectivityMonitor: ConnectivityMonitor, | |
private val resources: Resources, | |
private val tracker: Tracker, | |
private val logger: Logger, | |
): ViewModel() { | |
... | |
fun process(action: Action) { | |
when (action) { | |
is Action.Load -> load() | |
... | |
} | |
} | |
private fun load() { | |
viewModelScope.launch { | |
_viewState.update { currentState -> | |
currentState.copy( | |
isLoaderVisible = true, | |
isContentVisible = false, | |
isEmptyVisible = false, | |
isErrorVisible = false, | |
) | |
} | |
when (val result = GetUserListRepository()) { | |
is Success -> { | |
tracker.trackEvent(CatalogFetched) | |
_viewState.update { currentState -> | |
currentState.copy( | |
isLoaderVisible = false, | |
isEmptyVisible = false, | |
isErrorVisible = false, | |
isUserListVisible = true, | |
users = result.users.map { it.toUserState() } | |
) | |
} | |
} | |
is Empty -> { | |
tracker.trackEvent(EmptyCatalogFetched) | |
logger.error(catalogResources.emptyCatalogMessage) | |
_viewState.update { currentState -> | |
currentState.copy( | |
isLoaderVisible = false, | |
isUserListVisible = false, | |
isEmptyVisible = true, | |
isErrorVisible = false, | |
) | |
} | |
} | |
is Failure -> { | |
tracker.trackEvent(CatalogFetchFailed) | |
logger.error(catalogResources.failedCatalogMessage(result.errorType)) | |
_viewState.update { currentState -> | |
currentState.copy( | |
isLoaderVisible = false, | |
isUserListVisible = false, | |
isEmptyVisible = false, | |
isErrorVisible = true, | |
errorMessage = when(result.errorType) { | |
ErrorType.NoUser -> featureResources.noUserErrorMessage | |
ErrorType.ServerError -> featureResources.serverErrorMessage | |
else -> featureResources.genericErrorMessage | |
}, | |
) | |
} | |
} | |
} | |
} | |
} | |
... | |
private fun UserDataModel.toUserState() = ... | |
} |
UserListViewModel partial implementation
To solve the issues, the ViewModel has to be lightened by delegating the responsibilities to new components step by step.
Use reducers for UI mutations
Let’s first introduce the Reducer
pattern. It is initially a function that takes the current state and an action as input and returns a new state in the Redux architecture. The reducer updates the state based on the user’s actions and the model’s updates.
Here, the usage we expect is a bit different. The reducer is used for transformation, which takes the current state and a mutation to get the expected transformation.
interface Reducer<Mutation, ViewState> { | |
operator fun invoke(mutation: Mutation, currentState: ViewState): ViewState | |
} |
Reducer interface
Mutation
is an internal action that identifies the transformation. It could contain properties representing flags or models retrieved by the use case required for the transformation. It’s different from the partial state developers commonly used to update a part of the view state.
sealed interface Mutation { | |
data object ShowLostConnection : Mutation | |
data object DismissLostConnection : Mutation | |
data object ShowLoader : Mutation | |
data class ShowContent(val users: List<UserDataModel>) : Mutation | |
data class ShowError(val exception: Exception) : Mutation | |
} |
Sealed Mutation
DefaultReducer
transforms the current view state differently by mutation. It reduces the current state by copying into a new one and mapping the models from Mutation
. Here, the reducer takes the Android Resources
to format texts, to get drawables or colors.
class DefaultReducer( | |
private val resources: Resources, | |
) : Reducer<Mutation, ViewState> { | |
override fun invoke(mutation: Mutation, currentState: ViewState): ViewState = | |
when (mutation) { | |
Mutation.DismissLostConnection -> | |
currentState.mutateToDismissLostConnection() | |
is Mutation.ShowContent -> | |
currentState.mutateToShowContent(users = mutation.users) | |
is Mutation.ShowError -> | |
currentState.mutateToShowError(exception = mutation.exception) | |
Mutation.ShowLoader -> | |
currentState.mutateToShowLoader() | |
Mutation.ShowLostConnection -> | |
currentState.mutateToShowLostConnection() | |
} | |
private fun ViewState.mutateToShowContent(users: List<UserDataModel>) = | |
copy( | |
isLoaderVisible = false, | |
isUserListVisible = true, | |
userStates = userStates.toMutableList().apply { | |
addAll(users.map { it.toUserState() }) | |
}, | |
isErrorVisible = false, | |
) | |
private fun ViewState.mutateToShowError(exception: Exception) = | |
copy( | |
isLoaderVisible = false, | |
isUserListVisible = false, | |
isErrorVisible = true, | |
errorMessage = resources.getString(R.string.userlist_text_generic_error) | |
.format(exception.message), | |
) | |
private fun ItemModel.toItemState() = ... | |
... | |
} |
A default reducer implementation
Then, the ViewModel can manage one or many reducers. As a best practice, scoping the reducers is recommended.
The mutation has to be handled by one reducer, but there is no way to restrict it.
The ViewModel was doing the transformation, henceforth invoking the reducers with a mutation to update the current state.
class UserListViewModel( | |
private val getUserListRepository: GetUserListRepository, | |
private val getUserContactRepository: GetUserContactRepository, | |
private val connectivityMonitor: ConnectivityMonitor, | |
private val reducers: Collection<Reducer<Mutation, ViewState>>, | |
private val tracker: Tracker, | |
private val logger: Logger, | |
): ViewModel() { | |
... | |
fun process(action: Action) { | |
when (action) { | |
is Action.Load -> load() | |
... | |
} | |
} | |
private fun load() { | |
viewModelScope.launch { | |
handleMutation(Mutation.ShowLoader) | |
when (val result = getProductsUseCase()) { | |
is Success -> { | |
tracker.trackEvent(CatalogFetched) | |
handleMutation(Mutation.ShowContent(users = result.users)) | |
} | |
is Empty -> { | |
tracker.trackEvent(EmptyCatalogFetched) | |
logger.error(catalogResources.emptyCatalogMessage) | |
handleMutation(Mutation.ShowEmpty) | |
} | |
is Failure -> { | |
tracker.trackEvent(CatalogFetchFailed) | |
logger.error(catalogResources.failedCatalogMessage(result.errorType)) | |
handleMutation(Mutation.ShowError(result.errorType)) | |
} | |
} | |
} | |
} | |
... | |
private fun handleMutation(mutation: Mutation) { | |
reducers.asIterable() | |
.forEach { reducer -> | |
_viewStateFlow.update { currentState -> | |
reducer(mutation, currentState) | |
} | |
} | |
} | |
} |
UserListViewModel implementation with reducers
Having delegated the transformation, the next big step is moving the logic out.
Manage logic in action processors
ActionProcessor
is a new component to manage the actions to return a flow of pair of Mutation
& Event
.
Why a flow? The processor could emit one or many values. The value can be a mutation, an event, or both. This is why they are nullable.
interface ActionProcessor<Action, Mutation, Event> { | |
operator fun invoke(action: Action): Flow<Pair<Mutation?, Event?>> | |
} |
Action processor interface
The partial implementation above, DefaultActionProcessor
, gives an example of the Load
action to get the user list from the given repository.
Following the happy path, it emits ShowLoader
and then ShowContent
with the list of UserDataModel
.
Besides calling the use cases or the repositories, the action processor can manage other middle components like trackers, loggers, analytics, etc.
class DefaultActionProcessor( | |
private val getUserListRepository: GetUserListRepository, | |
private val connectivityMonitor: ConnectivityMonitor, | |
private val logger: Logger, | |
private val tracker: Tracker, | |
) : ActionProcessor<Action, Mutation, Event> { | |
override fun invoke(action: Action): Flow<Pair<Mutation?, Event?>> = | |
flow { | |
when (action) { | |
is Action.Load -> load() | |
... | |
} | |
} | |
private suspend fun FlowCollector<Pair<Mutation?, Event?>>.load() { | |
emit(Mutation.ShowLoader to null) | |
when (val result = getUserListRepository()) { | |
is Success -> { | |
tracker.trackEvent(CatalogFetched) | |
emit(Mutation.ShowContent(users = result.users) to null) | |
} | |
is Empty -> { | |
tracker.trackEvent(EmptyCatalogFetched) | |
logger.error("empty catalog") | |
emit(Mutation.ShowContent(users = result.users) to null) | |
} | |
is Failure -> { | |
tracker.trackEvent(CatalogFetchFailed) | |
logger.error("failed catalog ${result.errorType}") | |
emit(Mutation.ShowError(result.errorType)) to null) | |
} | |
} | |
} | |
... | |
} |
Default action processor implementation
Combine action processors and reducers
After moving the logic into action processors, the ViewModel now collects the mutations & the events emitted by the action processors. The reducers reduce the mutations, and the event is emitted again to the View.
Everything has been delegated to the new components. The ViewModel now has simple responsibilities, which are invoking action processors and reduce the mutations collected from them via the reducers.
class UserListViewModel( | |
private val actionProcessors: Collection<ActionProcessor<Action, Mutation, Event>>, | |
private val reducers: Collection<Reducer<Mutation, ViewState>>, | |
private val initialState: ViewState = ViewState(), | |
): ViewModel() { | |
... | |
fun process(action: Action) { | |
viewModelScope.launch { | |
actionProcessors | |
.map { actionProcessor -> actionProcessor(action) } | |
.merge() | |
.collect { value -> | |
mutation?.let(::handleMutation) | |
event?.let(eventChannel::trySend) | |
} | |
} | |
} | |
private fun handleMutation(mutation: Mutation) { | |
reducers | |
.asIterable() | |
.forEach { reducer -> | |
_viewStateFlow.update { currentState -> | |
reducer(mutation, currentState) | |
} | |
} | |
} | |
} |
UserListViewModel with action processors and reducers
In addition, it’s essential to realize that components can be used together or as individual parts.
It’s almost done. We need a reusable pattern to make these components easy to implement.
Using base classes was very common to reduce duplication and reuse shared code. The concern is that only one class inheritance forces us to create a generic base ViewModel that can also handle other things not related to this, then breaking the single responsibility principle.
Let’s create a new Model
generic class to export the action processing and view state reducing responsibilities from the ViewModel.
class Model<ViewState, Action, Mutation, Event>( | |
private val actionProcessors: Collection<ActionProcessor<Action, Mutation, Event>>, | |
private val reducers: Collection<Reducer<Mutation, ViewState>>, | |
private val coroutineScope: CoroutineScope, | |
private val viewMutableStateFlow: MutableStateFlow<ViewState>, | |
private val eventChannel: Channel<Event>, | |
) { | |
val viewStateFlow: Flow<ViewState> = viewMutableStateFlow | |
val eventFlow: Flow<ViewState> = eventChannel.receiveAsFlow() | |
fun process(action: Action) { | |
coroutineScope.launch { | |
actionProcessors | |
.map { actionProcessor -> actionProcessor(action) } | |
.merge() | |
.collect { value -> | |
mutation?.let(::handleMutation) | |
event?.let(eventChannel::trySend) | |
} | |
} | |
} | |
private fun handleMutation(mutation: Mutation) { | |
reducers | |
.asIterable() | |
.forEach { reducer -> | |
viewMutableStateFlow.update { currentState -> | |
reducer.invoke(mutation, currentState) | |
} | |
} | |
} | |
} |
Delegated Model class
The Model class requires a CoroutineScope
to launch coroutines calls, and
a MutableStateFlow
of ViewState
and the Event
Channel
.
Why inject those last two parameters? Why not have them as private properties like in the ViewModel?
We create ModelProperty
as ReadOnlyProperty
to get the instance by property delegation. It’s read only; we need to inject the properties we update.
class ModelProperty<ViewState, Action, Mutation, Event>( | |
private val viewModel: ViewModel, | |
private val actionProcessors: Collection<ActionProcessor<Action, Mutation, Event>>, | |
private val reducers: Collection<Reducer<Mutation, ViewState>>, | |
private val viewMutableStateFlow: MutableStateFlow<ViewState>, | |
private val eventChannel: Channel<Event>, | |
) : ReadOnlyProperty<Any, Model<ViewState, Action, Mutation, Event>> { | |
override fun getValue(thisRef: Any, property: KProperty<*>) = | |
Model( | |
actionProcessors = actionProcessors, | |
reducers = reducers, | |
coroutineScope = viewModel.viewModelScope, | |
viewMutableStateFlow = viewMutableStateFlow, | |
eventChannel = eventChannel, | |
) | |
} | |
inline fun <reified ViewState, reified Action, reified Mutation, reified Event> ViewModel.model( | |
private val actionProcessor: Collection<ActionProcessor<Action, Mutation, Event>>, | |
private val reducers: Collection<Reducer<Mutation, ViewState>>, | |
private val initialState: ViewState, | |
) = | |
ModelProperty( | |
viewModel = this, | |
actionProcessors = actionProcessors, | |
reducers = reducers, | |
viewMutableStateFlow = MutableStateFlow(initialState), | |
eventChannel = Channel(Channel.BUFFERED), | |
) |
Model read only property
Finally, UserListViewModel
new implementation is lighter through the delegate.
class UserListViewModel( | |
private val actionProcessors: Collection<ActionProcessor<Action, Mutation, Event>>, | |
private val reducers: Collection<Reducer<Mutation, ViewState>>, | |
private val initialState: ViewState = ViewState(), | |
): ViewModel() { | |
private val model by model(actionProcessors, reducers, initialState) | |
internal val viewStateFlow: Flow<ViewState> get() = model.viewStateFlow | |
internal val eventFlow: Flow<Event> get() = model.eventFlow | |
fun process(action: Action) = model.process(action) | |
} |
Final UserListViewModel implementation by delegate
Conclusion
It’s crucial to appreciate the approach suggested is just one of many options for extending and relaxing the MVI. The main goal is to lighten complex ViewModels.
Reducer and ActionProcessor components are not mandatory and can be implemented independently. Keep the ViewModel simple and extend it as much as you need.
This approach is open to discussion, and I would like your feedback.
You can find the project on Github here.
This article was previously published on proandroiddev.com