Android: Simple MVI implementation with Jetpack Compose

Volodymyr Shcherbyuk
6 min readOct 18, 2021

--

Image from Android Developers

Foreword:
There are different architectures for building a UI in Android development, but MVI is less known from them all. Studying the topic, I faced the problem that info about the MVI approach looks vague and too rough or too complex in the case of some libraries/implementations that you can find. For example, you can find implementations similar to the Redux, with Store/Actions/etc, but if you are using the clean architecture in your project, Redux-like MVI is overkill, because most of your logic will be on the domain layer, and if you don’t use the clean architecture, probably you don’t want to have a strict structure with a lot of boilerplate code anyway. This article will cover how MVI can be easily implemented without using any third-party libraries and how it can be used with the new library for building a UI — Jetpack Compose.

What is MVI?
MVI (Model-View-Intent) — is a UDF architecture from the MV* family. The core of the MVI is a state machine that transforms Intents (interactions) into a view state which will be rendered. Since it is a state machine, it must have only one source of data (Single source of truth or SSOT), unlike the regular MVVM pattern where a ViewModel can have multiple separate streams.

Key advantages of the MVI:
- Unidirectional and cyclic data flow — is a design pattern where state flows down and events flow up. With such an approach, we can decouple composables that display a state in the UI from the parts of your app that store and change the state.
- Single source of truth — the primary entity in this architecture, from which all other benefits emanate.
- Easiness of debugging. Since we have only one source of data, it’s easy to debug it.
- Easiness of testing. In most cases, you just need to check a new state after some event to validate the code, and it’s quite easy to do with the right implementation.
- Easiness of implementing a Time Machine (will be covered later in the topic).
- Agility. MVI doesn’t have a single, strict implementation. MVI can be built at the top of the MVP/MVVM and used along with them.

MVI stands for:
Model — is responsible for fetching and providing data from the local/remote source.
View — represents a UI layer, can be a fragment, activity, or anything else. In our particular case, it will be a pure compose function.
Intent — Intention to perform some action or event that happened. (Don’t be confused with Intent from Android SDK).

Concept
We should have a stream that is holding our immutable state, `reduce` function that takes an event and the old state as input, transforms it into the new state, and deliver it to the UI:

Implementation:
ViewModel

We will build our MVI over the top of MVVM, which means we will use a ViewModel for handling user actions. The VM provided by the Jetpack library has valuable features that we want to have in our application. It has synergy with coroutines and dagger-hilt that we are using in the sample project, it also can be shared and retained. We use the Kotlin Flow as Stream, but it can be LiveData, Publisher from RxJava, or any other stream that can deliver our state to the UI layer.

We will split responsibilities here. Reducer is responsible for holding and processing state, VM is responsible for fetching data from the data sources, presentation layer business-logic, and handling events from the UI layer transforming them into Intentions passed to the Reducer. It’s not a strict rule. You can have everything in the Reducer or make the “reduce” function directly in the ViewModel without creating an additional class. In the first case, you won’t have the benefits from the VM provided by the library. The second case is also a good approach, but I like to split responsibilities between entities. In my opinion, it’s more readable and easier to work with.

“UiEvent” — is our Intention, I prefer to use this naming, or there can be confusion with the Android “Intent” API, which has nothing in common with Intention from the MVI.
State — is a data representation of the UI. In Compose, the UI is immutable — there’s no way to update it after it’s been drawn, only create a new state and push changes to the compose. Whenever a state is changed, Compose recreates the parts of the UI tree that have changed. This process in the Compose is called recomposition.
Usually, a sealed class is used for such cases. For example, our UI has a progress bar, error view, and a list with data, we can express it as follows:

What about the case when we should show both an error and data? Then we need to merge these two.

And what if we have dozens of such cases? Then, you will come to the solution to use a single data class that contains everything, and this is the best approach:

Example:
I’ve made a simple example application that can fetch data from the local DB and manipulate it a little bit.

Let’s define our view data model. Previously, we were talking that sealed classes are bad for the data model for a state, but this is not the case for the Intention (which is UiEvent). For Intentions, it’s a perfect choice since they are one-time disposable events.

ViewModel with reducer:

On the UI, we have pure Composable functions, which are represents a simple screen with different states.

And this is how it looks like:

Dialogs
How to handle dialogs with such an approach is a pretty popular question. Everything on a UI is a state. Dialogs are no different, they are part of the state, and you control their appearance by changing a state. Our example shows that we manage a dialog by the boolean flag, which works perfectly fine. But if you need to have multiple dialogs on the screen, to not propagate boolean flags you can make an additional sealed class for controlling dialogs.

Time Machine
TimeMachine is an optional feature, but it is incredibly beneficial for debugging. TimeMachine saves all your states during a session (basically a stack trace of everything that happened with a particular reducer). You can do whatever you want with it. You can display it in the console log, save it to the log file and ask your QA/QC to convey the file to you. Display it in your debug menu or directly on the screen, where you can check all the steps that you have made previously. TimeMachine can be implemented with pure MVP or MVVM, but when you have more than one source of truth, it’s harder to do and requires more effort to maintain.
Let’s check our implementation for the TimeMachine in the example project:

Let's check how it can be used on the screen. I’ve implemented a simple AlertDialog that can be shown by long press on any view by adding a certain modifier to it, in the example project, it is added to the toolbar:

Testing:
Let’s cover VM/Reducer with unit tests. After some user action or event, we should check does the state on the screen has changed:

Summary:
MVI is a robust architecture with many benefits and perfectly fits for the development with Jetpack Compose. It has advantages over the most popular choices (MVVM/MVP), and you should try it if you are starting your journey with Compose.

You can find the complete code for the sample application in the following repo:
https://github.com/volodymyr-sch/Compose-Mvi-Example

--

--