Blog Infos
Author
Published
Topics
,
Author
Published

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
}
view raw ViewState.kt hosted with ❤ by GitHub

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 = "",
)
view raw ViewState.kt hosted with ❤ by GitHub

Flat hierarchical view state

Job Offers

Job Offers

There are currently no vacancies.

OUR VIDEO RECOMMENDATION

Jobs

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
}
view raw Reducer.kt hosted with ❤ by GitHub

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
}
view raw Mutation.kt hosted with ❤ by GitHub

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)
}
}
}
}
view raw Model.kt hosted with ❤ by GitHub

Delegated Model class

The Model class requires a CoroutineScope to launch coroutines calls, and
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

YOU MAY BE INTERESTED IN

YOU MAY BE INTERESTED IN

blog
In the first two articles, we explored the challenges of implementing traditional Clean Architecture…
READ MORE
blog
Today I aim to cover the Domain layer. It is a layer that sits…
READ MORE
blog
This article is a follow-up to one I published in April 2019. You can…
READ MORE
blog
Designing an effective architecture for your Android project is crucial, especially when you aim…
READ MORE
Menu