MVI Architecture Pattern in Android
Introduction
The MVI (Model-View-Intent) architecture, a popular approach in Android development, emphasizes clean code and a clear separation of concerns by dividing the application into three main components: Model, View, and Intent. This unidirectional data flow and distinct roles contribute to easier understanding, building, and maintenance of MVI apps. The structure also enhances predictability and testability, resulting in smoother development and fewer bugs. This makes MVI an effective choice for creating scalable and maintainable Android applications.
What is MVI?
MVI architecture in a nutshell.
What it is: An architecture pattern offers a well-structured approach for building scalable, robust, and testable Android applications. It promotes clear code and smooth development.
Key features:
- Unidirectional data flow: means data flows in a single direction — from the Model to the View and back as Intents. This ensures clarity, predictability, and ease of maintenance in the architecture.
- Separation of concerns: means distinct roles for Model, View, and Intent components. The Model manages the state, the View handles UI rendering, and the Intent captures and communicates user actions.
- Immutability: ensures that the Model’s state remains unchanged once set. This guarantees predictability, eliminates unexpected side effects, and promotes a stable and reliable application state.
Components:
- Model: Holds all the app’s data and logic, like a single source of truth. It’s never directly changed, but updated by creating new states.
- View: View is the UI renderer, displaying the app’s state to the user without handling business logic. It updates based on the Model’s state changes.
- Intent: Represents user actions or the app itself, like button clicks or text input, it’s all about what the user wants to do in the app. The View catches these intentions and sends them to the Model, which then takes actions (like updating the app’s status)
How does the MVI work?
User does an action which will be an Intent → Intent is a state which is an input to model → Model stores state and sends the requested state to the View → View Loads the state from Model → Displays to the user. If we observe, the data will always flow from the user and end with the user through intent. It cannot be the other way, Hence it's called Unidirectional architecture. If the user does one more action the same cycle is repeated, hence it is Cyclic.
This diagram illustrates the unidirectional data flow
Getting Started with MVI
Before we get started, it would be awesome if you’re familiar with some cool tools like Coroutine, Hilt, StateFlow, and ViewModel that make our MVI pattern journey smoother. In this article, I’ll be using Jetpack Compose. Don’t worry if you haven’t explored Jetpack Compose yet — you can easily apply the same ideas to an Activity or Fragment.
Now let’s set up the necessary components and establish the unidirectional data flow. Let’s kickstart the journey.
We are trying to achieve this in our Android project, which represent data flow between the layers
Project setup
For our ViewModel lifecycle, add the following dependencies to your app-level build.gradle
file.
dependencies {
... other dependencies...
implementation ("androidx.lifecycle:lifecycle-viewmodel-compose:2.6.2")
}
If you are using Hilt make sure to include these dependencies.
In app-level build.gradle
file.
plugins {
... other plugins...
id("com.google.dagger.hilt.android")
kotlin("kapt")
}
... other components ...
dependencies {
... other dependencies...
implementation("com.google.dagger:hilt-android:2.49")
kapt("com.google.dagger:hilt-android-compiler:2.44")
}
In project-level build.gradle
file.
plugins {
... other plugins...
id("com.google.dagger.hilt.android") version "2.44" apply false
}
If you’re not familiar with setting up Hilt, no problem! Check out this simple guide from the official Hilt doc. It’s simple!👍
Define Project Structure
Define the project structure based on the separation of concerns in the MVI architecture. Typically, you’ll have separate packages for the Components.
Here’s a glimpse of how I’ve organized my package structure.
Let’s start with the “data layer”
The data layer is considered as our model component.
data class Movie(
val id: Int,
val title: String,
val year: String,
)
This is our Movie class that holds the data info.
class MovieRepository @Inject constructor(){
suspend fun getMovies(): List<Movie> {
// Simulate fetching data from a remote server or database
delay(2000)
return listOf(
Movie(1, "Alita Battle Angel", "2019"),
Movie(2, "Mortal Engines", "2018"),
Movie(3, "Avatar The Way of Water", "2022"),
Movie(4, "Lost in Space", "2018")
)
}
}
“Here, we simulate fetching data from a server or local database just to simplify things and focus on the MVI concept.”
UI Layer
Intent Component, which represents the user’s intentions or actions within the application.
/** Represent the user interactions like
* . button clicks,
* . text input
* . or fetching data from the server or local DB, whether initiated by the user or the app itself.
*/
sealed class MovieIntent{
object LoadMovies : MovieIntent()
}
ViewState Component Implement the View component responsible for rendering the UI and observing the Model’s state changes.
/** Theis represent the view state on the screen,
* whether it's loading or success fetching the data or an error occur.
*/
data class MovieViewState(
val loading: Boolean = false,
val movies: List<Movie> = emptyList(),
val error: String? = null,
)
The ViewModel plays a crucial role as part of the MVI, where we handle intents and reduce the view state. The ViewModel acts as a middleman between the View and the Model.
@HiltViewModel
class MainViewModel @Inject constructor(
private val repository: MovieRepository,
) : ViewModel() {
private val _state = MutableStateFlow(MovieViewState())
val state: StateFlow<MovieViewState> = _state
fun handleIntent(intent: MovieIntent) {
viewModelScope.launch {
when (intent) {
is MovieIntent.LoadMovies -> fetchMovies()
// ... Other Intents goes here...
}
}
}
private suspend fun fetchMovies() {
_state.value = _state.value.copy(loading = true, error = null)
try {
val movies = repository.getMovies()
_state.value = MovieViewState(loading = false, movies = movies)
} catch (e: Exception) {
_state.value =
MovieViewState(loading = false, error = e.message ?: "Error fetching movies")
}
}
}
By incorporating a ViewModel in the MVI architecture, you create a separation of concerns, making the application more modular, testable, and easier to maintain.
In the MainActivity class, I’ve adopted Jetpack Compose, I put the UI design in a separate file MovieScreen
.
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
private val viewModel:MainViewModel by viewModels<MainViewModel>()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
MyMVIprojectTheme {
// A surface container using the 'background' color from the theme
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
MovieScreen(mainViewModel = viewModel)
}
}
}
}
}
MovieScreen file.
// Be careful to add this import for items.
import androidx.compose.foundation.lazy.items
@Composable
fun MovieScreen(mainViewModel: MainViewModel) {
val state by mainViewModel.state.collectAsState()
LaunchedEffect(mainViewModel) {
// Trigger the fetchMovies() when the composable is first launched.
mainViewModel.handleIntent(MovieIntent.LoadMovies)
}
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp)
) {
when {
state.loading -> {
// Display a loading indicator
CircularProgressIndicator(modifier = Modifier.align(CenterHorizontally))
}
state.error != null -> {
// Display an error message
Text(text = "Error: ${state.error}", color = Color.Red)
}
else -> {
// Display the list of movies
MoviesList(movies = state.movies)
}
}
}
}
@Composable
fun MoviesList(movies: List<Movie>) {
LazyColumn {
items(movies) { movie ->
// Display a movie item
Card(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp)
.shadow(4.dp, RoundedCornerShape(8.dp))
) {
Text(text = "Movie: ${movie.title}",
modifier = Modifier.padding(4.dp))
Text(text = "Date: ${movie.year}",
modifier = Modifier.padding(4.dp))
}
}
}
}
Here, we’re crafting the UI for MovieScreen
and where we trigger the intent and manage loading states, errors, and displaying the movie list.
If you're still using views it will look something like this:
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
private val viewModel:MainViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
lifecycleScope.launchWhenStarted {
viewModel.state.clolect { state ->
render(state)
}
// Trigger the initial intent to load movies
viewModel.handleIntent(MovieIntent.LoadMovies)
}
override fun render(state: MovieViewState) {
// Update UI based on the state
if (state.loading) {
// Show loading indicator
} else if (state.error != null) {
// Show error message
} else {
// Display the list of movies
val movieList = state.movies.map { it.title }
// Update UI with movieList
}
}
}
This is the result :
Congratulations 🎉🎉
Remember, the adoption of MVI is a journey, not a sprint. Start with small, manageable steps, and gradually integrate MVI into your projects. Enjoy the process of creating cleaner, more maintainable UIs!
“Stay curious, stay driven, and watch your skills soar. Happy coding!”