Implementing MVI on Android with Coroutines
Update Sep 2023: this article has been superseded by this one.
Background
Android has been out for over a dozen years now, and how we write applications for Android has changed over the years. From the host of architectural solutions that have been proposed over the years, MVP (Model View Presenter), MVVM (Model View, ViewModel) and more recently MVI (Model View Intent) are the most notable ones. With the release of the Android Architecture components at Google IO 2017 the spotlight was on MVVM and that’s been the preferred architecture until recently.
With the recently release of JetPack Compose Beta and the required mental shift on how we write apps for compose, especially state management, a new architecture solution seems to be making the rounds these days, MVI. Before we delve into how to implement MVI on Android with coroutines, let’s quickly overview how MVVM and MVI work and how they differ from one another. Both MVVM and MVI share the Model and View parts, so here we will only be focusing on the ViewModel/Intent part.
MVVM
On MVVM the ViewModel exposes a set of variables, usually backed by LiveData, that represent a concrete piece of the view state. The view then observes these variables and changes to these variables drive the UI. It is not uncommon to have a fairly large number of observable variables for complex screens, which makes managing them fairly challenging, especially when there are dependencies between variables. Furthermore, to hide implementation details and expose only immutable variables, we usually duplicate state variables by having a private mutable version and a public immutable one. Further compounding the complexity is the fact that in MVVM there isn’t a centralized place to handle the state, making this harder to maintain and test, as variables could be changed in different methods of the ViewModel.
An example of a ViewModel for a city search screen would look something like this:
While this only contains 3 state variables, it is easy to see how this number can grow and become rather unwieldy.
MVI
The idea behind MVI is that the the ViewModel exposes the state of the screen as a whole, as opposed to as a set of individual variables. There is also a single place where the state is updated, making it easy to read and maintain the code. The code is also more testable as we can write test functions that ensure the state is always valid (say, the error message is only provided if we are in an error state, not in a loaded state). MVI shares some commonality with the Redux framework, the reducer is the method responsible for creating a new state based on the current state and the user actions, called Intent
s.
Following from the example above, the city search ViewModel could be rewritten as shown below
This already looks a lot more manageable and concise. We can see it will be easy to validate the state as we have a single variable to represent the screen, so we could write unit tests that ensure the state is always valid.
So, back to the original point of this article, how would we go about implementing MVI using Kotlin coroutines? For MVI we need to expose a method that the View will call for user actions, the Intent
s; we will call this onIntent
. The View needs to observe the State
from the ViewModel, and the ViewModel needs to generate new state from each Intent
it receives from the View. The state will be exposed from the ViewModel using a StateFlow
.
Using Kotlin flows, we can create a base ViewModel class as shown below
The state is exposed as a StateFlow
that the view can observe. When the user triggers and action this calls into the onIntent
method, where we pass as argument the MviIntent
. MviIntent
is just a marker class that will be implemented as sealed class
es representing each of the user Intent
s. The OnIntent
method is a suspend
function, and this may trigger any number of ReduceAction
s, which will trigger the generation of a new state. For instance, as a result of a user action we may emit the Loading
action while we are executing the Intent
, and once that intent completes we may emit a Loaded
or Error
action as a result. Each of the emissions during onIntent
will update the state. The flow below shows how the states are generated as a result of Intent
s:
With this base class, we can rewrite our cities search viewmodel as shown below
The corresponding fragment is shown below
We can see that:
- whenever the user updates the city search query, we trigger a new
Intent
that carries the query string - when this
Intent
is received in the ViewModel, if the length is 3 or greater, we trigger a Load reducer action, which is handled in thehandle
method and will update the state to show a loading spinner. After emitting this initial action we execute the search query, and when that completes we emit either a a success or failure reducer action which will trigger a new state where we stop the loading spinner and either show the cities result or an error message.
This approach works very well with Jetpack Compose as we have a State
exposed from the ViewModel that composables can collect using the collectAsState
extension function.
A full MVI implementation is available on this repo, it includes both an implementation for the view system and one for Jetpack Compose.