Simple MVI Framework for Jetpack Compose
A simple way to manage state in your Jetpack Compose apps
State management is one of the most important features of any app. Choosing the right architecture from the get go is important as architectural changes become very expensive once the app has been built. Thefore, making sure the architecture we choose is able to support the app and scale as the app evolves is crucial.
Architecture patterns have evolved over the years, with the most prominent ones used in Android being MVP (Model-View-Presenter), MVVM (Model-View-VIewModel) and MVI (Model-View-Intent). MVP no longer enters the discussion nowadays as it's been superseded by MVVM and MVI, and the choice betwen MVVM and MVI is mostly a matter of personal preference. I prefer MVI because it enforces a more structured approach to state management, the state is only updated in the reducer, with all intents (user input or external actions) being funneled through a pipeline where they are processed in sequence. MVVM is less structured and I find that updating the state pretty much anywhere in the viewmodel makes understanding and debugging the flow harder.
A MVI premier
In MVI there are 3 architectural components:
Model: this represents the state of the app, or a particular screen, and the business logic to generate it.
View: the view represents the UI, what the user interacts with.
Intent: these are actions, either from the user or external, that trigger a new model.
It's important to note that in MVI the state is immutable, and that MVI adheres to unidirectional data flow. This can be visualized with this diagram:
The basic flow is as follows:
An initial model is generated, which is pushed to the view to be rendered.
The user, or an external factor (like a network load completing), triggers an action (the intent).
The intent is processed in the business logic generating a new model, which is published to the view.
The cycle repeats indefinitively. This architecture provides a clear separation of concerns: the view is responsible for rendering the UI, the intent is responsible for carrying the actions, and the model is responsible for the business logic.
The MVI scaffolding
We will start by creaiting the basic scaffolding for the MVI framework. The solution that will be described here is for Android and tailored to Jetpack Compose apps, but the principles can be applied to any app, mobile or otherwise.
We will base the model on Android's ViewModel, as this solution integrates well with the Android framework and is lifecycle aware, but again, this is not a requirement and other solutions are equally viable.
To create the scaffolding, we need the following pieces:
An immutable state (model) that the view will observe.
A pipeline to push the intents (which I'll call actions from here onwards, to avoid confusion with Android's Intent).
A reducer to generate a new state from the existing state and the current action.
As this solution is tailored to Jetpack Compose, we will use a MutableState for the model. For the pipeline, we will use a MutableSharedFlow that will feed the reducer. While not strictly necessary, I also like to define marker interfaces for the state and the actions. Let's see the code for our MVI scaffold:
We define a marker interface for the State.
We do likewise for the Actions.
The Reducer will be responsible for updating the state, it has a single function that takes the current state, the action and generates a new state. It is important to note that the reducer's reduce method must be a pure function - the generated state can only depend on the input state and the action, and the state must be generated synchronously.
The MviViewModel is the foundation of the MVI framework. The MviViewModel receives a reducer and the initial state to kickstart the MVI flow.
For the actions pipeline we use a MutableSharedFlow, with a certain buffer capacity.
The state is held on a MutableState object, and exposed as a read-only property. It gets initialized to the initial state provided in the MviViewModel constructor.
When the viewmodel starts we launch a coroutine to collect the actions from the MutableSharedFlow, and at each emission we run the reducer and update the state accordingly. I'll note that for this simple example I'm using the viewModelScope as the coroutine scope, but it's recommended to provide a dedicated scope for better testability. The finalized example linked at the end of this article shows that implementation.
Finally we need a means to push actions to the pipeline, and we do this with the dispatch method, which accepts an Action and pushes it to the MutableSharedFlow. If the buffer is full this would indicate some kind of issue, so we opt to throw in such an event.
With this scaffold we can now create a simple app, so we will do so, we will create the canonical architecture of your choice sample app, a counter with 2 buttons, one to increase and one to decrease the counter.
The basic sample app
For our sample app, we need the following pieces:
The State.
The Actions.
The Reducer.
The ViewModel.
The UI.
Let's start by defining our state. For this very simple example, our state just needs to hold a single property, the current counter value
We use a data class for the State, so we can leverage the generated functions, like the copy function which we will use to create a new state from the existing one.
Our state has a single property, the counter value.
The state inherits from our marker interface.
Finally we provide a default value that we can use as the starting point.
Next we will define the actions. Four this example, we will only have two, one to increment the state and one to decrement it:
We use a sealed interface for the counter actions, which inherits from our marker interface.
The actions we need do not carry any payload, so we create a data object for the Increment action. On most apps we'd have a mix of data objects and data classes for when a payload is required.
And we do the same for the Decrement action.
Now that we have the state and the actions, we can build our reducer, which is responsible for generating a new state based on the current state and the action, Let's see the code:
The CounterReducer implements the Reducer interface.
We override the reduce function, which is responsible for generating the state.
We have an exhaustive when on the actions and, for each, we generate a new state based on the current state.
We have only 2 pieces left, the ViewModel and the UI. Let's create our viewModel first:
The CounterviewModel receives the CounterReducer as a constructor argument. In this example we are instantiating the reducer in the constructor, but in a real app we would use dependency injection instead.
The CounterViewModel inherits from our base MviViewModel, providing the reducer and the initial state.
We define a method named onDecrement that will push the Decrement action to the MVI pipeline.
We do the same with the Increment action, defining a corresponding onIncrement method.
All we have left is the UI which I'll gloss over because the details on how we actually render the state into UI does not matter when it comes to the MVI framework. Here's a simple UI to show the counter and 2 buttons to increment/decrement it:
With this we have our basic MVI scaffold and a sample app to exercise it. What we are missing from the solution is handling asynchronous (or long running) operations, as our reducer is updating the state synchronously. Next we will see how we can augment our MVI framework to support asynchronous work.
Handling asynchronous work
To handle asynchronous work in the MVI framework we will add a new concept, a Middleware. The Middleware is a component that is inserted in the MVI pipeline and can execute actions asynchronously. The Middleware will usually emit actions of its own at the start, during and end of its work (for instance, if we have an action that requires a network call, the Middleware may emit an Action to indicate a network load has started, may emit additional actions to update a progress indicator on the network load, and may emit a final loaded action when the network load completes).
As we did for the other components, we will create a base class for the Middleware:
The Middleware needs to dispatch its own Actions, so we define an interface for the Dispatcher (we'll see later how we use this).
The Middleware class is parameterized on the State and the Action, similar to the reducer.
The Middleware will receive the Dispacher to push actions to the MVI pipeline.
The suspending process method is where the asynchronous work will take place.
This is a utility method to push actions onto the MVI framework, so that we can keep the dispatcher private.
And finally we have a method we use to initialize the dispatcher used in the Middleware.
Next let's see how we have to update our MviViewModel to insert the Middleware in the MVI flow:
The MviViewModel now receives a list of Middlewares, defaulting to an empty list, as we may not have asynchronous work on all screens. The VIewModel also implements the Dispatcher interface.
We define a wraper class that wraps the current state and the action, which we will push onto the pipeline, so that we have a copy of the state at the moment the action was received.
In our init block we loop over the Middlewares and set the dispatcher for each, which is the ViewModel itself.
Next we launch a coroutine that observes the emissions of the MutableSharedFlow, a pair of Action and State.
For each emission, we loop over all the Middlewares.
And for each, we call its process method to handle the action.
The idea with this approach is that we will have a set of Middlewares, each responsible for part of the business logic of the app; each Middleware will observe the Actions from the MVI pipeline and, when the one that it is responsible for is emitted, it will start its asynchronous operation. On a large app we could split the screen into sections, each section handled by a separate Middleware, or we could separate the Middlewares by the business logic they perform. The idea is to have small, tailored Middlewares that perform only one or a small set of actions each, instead of a single, large Middleware that handles all asynchronous work.
Updating the counter app
With the Middleware and updated MviViewModel the MVI framework is complete, but things become easier to understand with an example, so we will add a button to our Counter screen to generate a random value for the counter. We will pretend that generating this random value is a long running process that needs to run on a background thread, so we will create a Middleware for this operation. And because this is a long running operation, we will show a progress indicator while the work in being executed.
We'll start by updating our counter state, to include the loading indicator:
We add a loading flag to the state.
And update the initial value to set the flag to false.
Next we need a new action to generate the random counter value, so we will add it to the sealed hierarchy. Likewise, when the number is ready, we need to update the state, so we need another action to trigger the update. For this second action we have a payload, the randomly generated counter value, so we will use a data class. And finally we want to display a loading indicator while the background task is running, so we will add a third action to show the progress indicator:
Next we will create the Middleware, which will be responsible for generating the random number. We will emit a loading action when we start, and the CounterUpdated action at the end. We will simulate the long operation by using a delay:
The CounterMiddleware extends our Middleware base class.
We override the process method, responsible for the asynchronous work.
We check the actions, and handle only the GenerateRandom one.
When we receive the correct action we emit the Loading action, which will trigger a state update to show the progress indicator.
Next we start the work, simulated here by a delay operation.
And, when the work is completed, we emit the result via a new action.
This is all there is to the CounterMiddleware. Next we need to update the reducer to handle the additional actions that we defined earlier. The reducer does not have to handle all actions, the GenerateRandom action is only handled at the middleware, so that one will be a no-op. Let's see the code:
When the Loading action is received the state is updated to indicate a long running operation is in progress.
When the CounterUpdated action is received, we clear the loading flag and update the counter value with the action payload.
The GenerateRandom is not handled at the reducer, so we return the existing state.
Next we need to update the viewmodel to provide the middleware to the base class and add a new method to handle the generate random number action. Let's see the updates:
We provide the CounterMiddleware in the constructor. Like the reducer, this would normally be injected, but for the sake of simplicity here we instantiate in place.
We provide the Middlewares, in our case just one, to the base class to insert into the MVI flow.
Finally we have a new method to handle the generate random counter value.
This pretty much concludes the example. The last piece is to update the UI to offer a trigger to generate a random number, and to show a progress indicator when the app is busy with the long running operation. The code below shows one possible implementation:
This concludes this article. An implementation of this framework, with dependency injection and with a dedicated coroutine scope is available on this GitHub repo, with the MVI framework located here.