Refactoring from MVVM to MVI: A Practical Guide
Introduction
When building Android applications, many developers start with the MVVM (Model-View-ViewModel) architecture due to its simplicity and the way it separates concerns. However, as applications grow in complexity, MVVM can sometimes become challenging to maintain, especially when dealing with multiple states and actions that need to be managed in the UI. This is where the MVI (Model-View-Intent) architecture comes in, offering a more predictable and testable approach to handling state and UI interactions.
In this article, we’ll walk through the process of refactoring an Android application from MVVM to MVI using Jetpack Compose and Hilt. We’ll use a sample project with features like adding a person, adding food, and managing a list of persons with their favorite foods.
1. Understanding MVVM in the Original Code
In the MVVM architecture, the ViewModel
acts as a bridge between the UI (View) and the business logic (Model). The ViewModel
contains live data streams that the UI observes, and the UI updates based on changes in these streams. Actions like adding a person or food are directly handled by the ViewModel
.
Here’s a quick look at what the MainViewModel
looked like in the original MVVM implementation:
@HiltViewModel
class MainViewModel @Inject constructor(
private val addPersonUseCase: AddPersonUseCase,
private val addFoodUseCase: AddFoodUseCase,
private val getAllPersonsWithFoodsUseCase: GetAllPersonsWithFoodsUseCase,
private val getAllFoodsUseCase: GetAllFoodsUseCase
) : ViewModel() {
private val _personsWithFoods = MutableStateFlow<List<PersonWithFoods>>(emptyList())
val personsWithFoods: StateFlow<List<PersonWithFoods>> = _personsWithFoods
private val _allFoods = MutableStateFlow<List<FoodEntity>>(emptyList())
val allFoods: StateFlow<List<FoodEntity>> = _allFoods
init {
viewModelScope.launch {
getAllPersonsWithFoodsUseCase().collect {
_personsWithFoods.value = it
}
}
viewModelScope.launch {
getAllFoodsUseCase().collect {
_allFoods.value = it
}
}
}
fun addPerson(name: String) {
viewModelScope.launch {
addPersonUseCase(PersonEntity(name = name))
refreshPersonsWithFoods()
}
}
// Other methods for adding food, updating favorites, etc.
}
The MainViewModel
is responsible for:
- Managing state using
MutableStateFlow
. - Fetching data from use cases and updating the state.
- Exposing the state to the UI via
StateFlow
.
While this is straightforward, as the application grows, handling multiple states and complex interactions can lead to a cluttered ViewModel
.
2. Moving to MVI: The Case for Predictability
MVI architecture emphasizes unidirectional data flow, which simplifies state management by ensuring that every action results in a new state. This predictability makes it easier to reason about how the UI will behave in response to user interactions.
In MVI:
- Model represents the state.
- View renders the UI based on the state.
- Intent captures user actions and translates them into events that modify the state.
3. Refactoring the ViewModel
Let’s start by refactoring the MainViewModel
to follow the MVI architecture. The key changes include:
- Introducing a single
MainViewState
to hold the state of the entire screen.
data class MainViewState(
val personsWithFoods: List<PersonWithFoods> = emptyList(),
val allFoods: List<FoodEntity> = emptyList(),
val isLoading: Boolean = false,
val error: String? = null
)
- Using a sealed class
MainViewIntent
to capture all possible user actions.
sealed class MainViewIntent {
object LoadPersonsWithFoods : MainViewIntent()
object LoadAllFoods : MainViewIntent()
data class AddPerson(val name: String) : MainViewIntent()
data class AddFood(val name: String) : MainViewIntent()
data class AddFavoriteFood(val personId: Long, val foodId: Long) : MainViewIntent()
data class RemoveFavoriteFood(val personId: Long, val foodId: Long) : MainViewIntent()
data class UpdateFavoriteFoods(val personId: Long, val selectedFoods: List<FoodEntity>) : MainViewIntent()
}
- Using a sealed class
MainViewEffect
Represents side effects such as navigation or showing toasts.
sealed class MainViewEffect {
data class ShowError(val message: String) : MainViewEffect()
object NavigateBack : MainViewEffect()
}
- Handling state transitions within the
ViewModel
by processing intents.
Here’s the refactored MainViewModel
:
@HiltViewModel
class MainViewModel @Inject constructor(
private val addPersonUseCase: AddPersonUseCase,
private val addFoodUseCase: AddFoodUseCase,
private val getAllPersonsWithFoodsUseCase: GetAllPersonsWithFoodsUseCase,
private val getAllFoodsUseCase: GetAllFoodsUseCase
) : ViewModel() {
private val _viewState = MutableStateFlow(MainViewState())
val viewState: StateFlow<MainViewState> = _viewState
private val _viewEffect = Channel<MainViewEffect>()
val viewEffect = _viewEffect.receiveAsFlow()
fun processIntent(intent: MainViewIntent) {
when (intent) {
is MainViewIntent.LoadPersonsWithFoods -> loadPersonsWithFoods()
is MainViewIntent.LoadAllFoods -> loadAllFoods()
is MainViewIntent.AddPerson -> addPerson(intent.name)
is MainViewIntent.AddFood -> addFood(intent.name)
// Other intent cases...
}
}
// Private methods for handling each action
}
4. Refactoring the Composables
In the original MVVM setup, the Composables observed the ViewModel
state and triggered ViewModel
methods directly. In the MVI approach, Composables:
- Dispatch intents to the
ViewModel
. - Observe the
ViewState
and render the UI accordingly.
Here’s how the AddPersonScreen
Composable looks after refactoring:
@Composable
fun AddPersonScreen(mainViewModel: MainViewModel = hiltViewModel()) {
val viewState by mainViewModel.viewState.collectAsState()
var name by remember { mutableStateOf("") }
Column(modifier = Modifier.padding(16.dp)) {
TextField(
value = name,
onValueChange = { name = it },
label = { Text("Person Name") }
)
Button(onClick = {
mainViewModel.processIntent(MainViewIntent.AddPerson(name))
name = ""
}) {
Text("Add Person")
}
viewState.error?.let {
Text(text = it, color = MaterialTheme.colorScheme.error)
}
}
}
The key changes here are:
- The
Composable
triggers an intent (e.g.,MainViewIntent.AddPerson
) instead of directly calling a method on theViewModel
. - The UI is rendered based on the state observed from the
viewState
.
5. Managing State and Intent
In MVI, the state is often more centralized, and every user action leads to a state transition. For example, in PersonListScreen
, loading data is now handled through intents:
@Composable
fun PersonListScreen(mainViewModel: MainViewModel = hiltViewModel()) {
val viewState by mainViewModel.viewState.collectAsState()
LaunchedEffect(Unit) {
mainViewModel.processIntent(MainViewIntent.LoadPersonsWithFoods)
mainViewModel.processIntent(MainViewIntent.LoadAllFoods)
}
LazyColumn {
items(viewState.personsWithFoods) { personWithFoods ->
PersonItem(personWithFoods, mainViewModel)
}
}
}
6. Benefits of MVI Over MVVM
By refactoring to MVI, you gain several benefits:
- Predictable State Management: The entire state is managed in a single
ViewState
object, making it easier to understand the current UI state. - Testability: Since each intent leads to a specific state transition, testing becomes more straightforward.
- Unidirectional Data Flow: MVI enforces a unidirectional flow of data, reducing the likelihood of unexpected UI states.
7. Conclusion
Refactoring from MVVM to MVI can be a significant shift in how you structure your Android applications. However, the benefits in terms of predictability, testability, and maintainability often outweigh the initial effort required. By following the steps outlined in this article, you can transition your app to MVI and enjoy a more robust architecture that scales with your application’s complexity.
Here is the full project that you can check out on GitHub