In the ever-changing world of Android development, architecture patterns are essential for building robust, maintainable, and scalable applications. While I’ve had experience with popular patterns like MVP and MVVM, Model-View-Intent (MVI) has become my personal favorite (and the most valuable addition) due to its unique code structure and its incredible potential for clarity and testability.
The arrival of Jetpack Compose has further amplified MVI’s usefulness, specifically for make easier to handling UI states. This synergy between MVI and Compose allows for a clean and efficient approach to managing complex UIs, making MVI an even more compelling choice for modern Android development.
But what is MVI?
MVI stands for Model-View-Intent. Unlike traditional patterns like MVP or MVVM, MVI emphasizes a unidirectional data flow. This means that data moves from the Model to the View, and user interactions trigger Intents that manipulate the Model, creating a closed-loop system.
The Principal Components:
- Model: This is the single source of truth for the application’s state. It encapsulates all the data required to process user actions and update the UI. The Model is immutable, meaning it cannot be directly modified, ensuring predictable behavior and simplifying debugging.
- View: This represents the user interface. Its sole responsibility is to render the current state of the Model and capture user interactions as Intents. It does not contain any logic or data, promoting a clean separation of concerns.
- Intent: These are user actions or triggers that represent a desire to change the application’s state. Intents can be button clicks, network requests, or any event that signifies a user’s intent to interact with the app.
Other Components
- Action: An action (not to be confused with user actions) is placed in the MVI pattern between the Intent and the Model. It is useful for apps that have many intents with a common use. For example, on a screen, there could be many buttons, and all of them send data to an Analytics server when pressed. Therefore, all those intents could trigger an action called “SendAnalyticsAction,” which handles that.
- Interpreter: An interpreter is a component responsible for converting Intents into Actions.
- Processor: A processor handles the actions and calls the Domain Layer (or Data Layer in some architectures). The obtained data is referred to as the Result.
- Result: As the name suggests, it contains the result of the processor, which could be data, an error, or just a successful status without data.
- Reducer: The reducer is the component responsible for converting the Result into a State that will be rendered in the View. Additionally, the Result could be converted into an Effect.
- Effects: Not all Results involve a permanent change in the View. An example of that could be a notification or a change between screens. For such purposes, effects exist.
Benefits of MVI:
- Unidirectional Data Flow: Makes the code more predictable and easier to reason about. Debugging becomes simpler as you can trace changes through a single path.
- Immutable State: Ensures data consistency and prevents accidental modifications. Makes testing the app easier as you can rely on the state being predictable.
- Separation of Concerns: Promotes cleaner code by clearly defining the responsibilities of each component. Makes the code base easier to understand and maintain.
- Testability: The unidirectional data flow and immutable state make testing components in isolation much easier. You can easily write unit tests for the Model and View without dependencies.
Ok but where is Jetpack Compose?
Easily, by handling the states of the View as StateFlow, you can collect them from a composable. Then, you only need to manage each state with Compose objects
val state by viewModel.uiState.collectAsStateWithLifecycle()
Column(
modifier = Modifier
.padding(innerPadding),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
when (state) {
is DisplayListUiState -> {
LazyColumn {
items((state as DisplayListUiState).beers) { beer ->
CardBeer(beer, onNavigationRequested)
}
}
}
is ErrorUiState -> {
Text(
modifier = Modifier.padding(8.dp),
text = "Error"
)
}
LoadingUiState -> {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.fillMaxSize()
) {
CircularProgressIndicator()
}
}
DefaultUiState -> {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.fillMaxSize()
) {
CircularProgressIndicator()
}
}
}
}
These are not sacred writings
Don’t worry about not following this pattern exactly in all your apps. App development is a world in itself. There could be an app that only shows information without buttons, so it’s okay not to have actions and just use intents directly to the processor. Or perhaps your app is so simple that the processor and reducer could be functions in a single class rather than separated classes.
That’s why in the same company, there could be projects with different architectures. It’s the duty of the developers to determine the approach to a pattern or architecture that could be useful for a project.
Small sample with MVI:
I have published a simple project with MVI and Compose. This is a small and straightforward app designed to display beers in a list, so many of the components of the MVI are functions in the ViewModel. There aren’t any Actions either. This could be useful for someone who needs to create a simple app, but I don’t recommend it for a complex app.