Refactoring from MVVM to MVI: A Practical Guide

Introduction

mojtaba shirkhani
4 min readAug 17, 2024

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 MainViewEffectRepresents 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 the ViewModel.
  • 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

--

--