Implementing MVI with Reducers in Android: A Step-by-Step Tutorial
If you are naive to MVI then first please check this blog.
Here I want to demonstrate how we can use reducers in MVI. Let's check this with an example.
We first implement a reducer for this very simple counter-example, without any asynchronous calls.
- Model: First, we define the data structure for the Model, which represents the state of the UI. In this example, we’ll use a simple counter that can be incremented or decremented.
data class CounterState(val count: Int = 0)
2. Intent: Next, we define the Intent, which represents the user’s intention to perform an action that affects the state of the UI.
In this example, we’re using a sealed class to define the possible CounterIntent
actions. We have two possible actions:
Increment
: This is an object that represents an intent to increment the counter value.Decrement
: This is an object that represents an intent to decrement the counter value.
By using a sealed class to define the possible intents, we can ensure that all possible actions are accounted for in our handleIntent()
function in the ViewModel, and we can also easily add new actions in the future.
sealed class CounterIntent {
object Increment : CounterIntent()
object Decrement : CounterIntent()
}
3. ViewModel: We’ll now define the ViewModel, which is responsible for handling the Intents and updating the Model accordingly. The ViewModel has two main functions:
handleIntent()
: This function receives an Intent and updates the Model based on the Intent.getState()
: This function returns the current state of the Model.
In this example, we’ll use a reducer function to update the Model based on the Intent. The reducer takes the current state of the Model and an Intent, and returns the new state of the Model.
class CounterViewModel : ViewModel() {
private val _state = MutableStateFlow(CounterState())
val state: StateFlow<CounterState>
get() = _state
fun handleIntent(intent: CounterIntent) {
val newState = reduce(_state.value, intent)
_state.value = newState
}
private fun reduce(state: CounterState, intent: CounterIntent): CounterState {
return when (intent) {
is CounterIntent.Increment -> state.copy(count = state.count + 1)
is CounterIntent.Decrement -> state.copy(count = state.count - 1)
}
}
}
4. View: Finally, we define the View, which is responsible for rendering the UI based on the state of the Model. In this example, we’ll use a Composable function to display the current count and two buttons to increment or decrement the count.
@Composable
fun CounterScreen(viewModel: CounterViewModel = viewModel()) {
val state by viewModel.state.collectAsState()
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text("Count: ${state.count}")
Button(onClick = { viewModel.handleIntent(CounterIntent.Increment) }) {
Text("Increment")
}
Button(onClick = { viewModel.handleIntent(CounterIntent.Decrement) }) {
Text("Decrement")
}
}
}
With this setup, the CounterScreen
Composable will display the current count and two buttons to increment or decrement the count. When the user clicks a button, the corresponding Intent is sent to the ViewModel, which updates the state of the Model using the reducer function. The Composable function then re-renders the UI based on the updated state of the Model.
To add complexity and test the implementation for asynchronous tasks, we can refactor the increment
and decrement
intents to include network calls that increment and decrement the counter value. While this is an unnecessary step for this simple counter-example, it will help demonstrate how to use asynchronous network calls.
For that first, we need to change CounterIntent
class, by adding SetCount
SetCount
: This is a data class that represents an intent to set the counter value to a specific integer value. It takes anInt
parameter that represents the new counter value.
sealed class CounterIntent {
object Increment : CounterIntent()
object Decrement : CounterIntent()
data class SetCount(val count: Int) : CounterIntent()
}
Then change reduce()
function of CounterViewModel
. Though we can create a new class as well, naming CounterReducer
and updating that with newly added intent SetCount
class CounterReducer : Reducer<CounterState, CounterIntent> {
override fun reduce(state: CounterState, intent: CounterIntent): CounterState {
return when (intent) {
is CounterIntent.Increment -> {
state.copy(count = state.count + 1)
}
is CounterIntent.Decrement -> {
state.copy(count = state.count - 1)
}
is CounterIntent.SetCount -> {
state.copy(count = intent.count)
}
}
}
}
Lastly, update CounterViewModel handleIntent function, this will now treat as middleware like in REDUX architecture.
class CounterViewModel(private val api: CounterApi) : ViewModel() {
private val _state = MutableStateFlow(CounterState())
val state: StateFlow<CounterState> = _state
fun handleIntent(intent: CounterIntent) {
viewModelScope.launch {
when (intent) {
is CounterIntent.Increment -> {
try {
val result = withContext(Dispatchers.IO) { api.incrementCounter() }
val newState = counterReducer(_state.value, CounterIntent.SetCount(result.count))
_state.emit(newState)
} catch (e: Exception) {
// Handle error
}
}
is CounterIntent.Decrement -> {
try {
val result = withContext(Dispatchers.IO) { api.decrementCounter() }
val newState = counterReducer(_state.value, CounterIntent.SetCount(result.count))
_state.emit(newState)
} catch (e: Exception) {
// Handle error
}
}
is CounterIntent.SetCount -> {
val newState = counterReducer(_state.value, intent)
_state.emit(newState)
}
}
}
}
}
In this example, we’ve injected an instance of CounterApi
into the ViewModel's constructor. We use this instance to make network calls to increment or decrement the counter value. I make it like that only to add a placeholder class for API calls, otherwise, you should give either usecase instance here or a repository instance.
We’ve also wrapped the API calls in withContext(Dispatchers.IO)
to ensure that they're executed on a background thread. If an exception is thrown during the API call, we catch it and handle it appropriately (such as by displaying an error message to the user).
In the processIntent()
function, we switch on the incoming CounterIntent
to determine what action to take. If the intent is an Increment
or Decrement
, we make the appropriate API call and dispatch a SetCount
intent to the reducer with the result of the call. If the intent is a SetCount
, we simply pass it along to the reducer.
The reducer is responsible for updating the CounterState
with the new count value and returning a new state object. The new state is then emitted to any observers of the state
property, such as a Compose UI.
In conclusion, MVI (Model-View-Intent) architecture pattern is a popular approach to designing user interfaces in Android apps. It is useful in managing state changes and handling user interactions with a clear separation of concerns.
MVI with Reducer functions is an extension of the MVI pattern that makes state management more predictable and manageable. By using reducers, developers can separate the logic that updates the state from the logic that reacts to user intents. This makes it easier to test and maintain the code.
MVI with middleware is another way to handle asynchronous operations like network calls. By using coroutines and channels, developers can manage asynchronous operations while keeping the code readable and maintainable. Overall, MVI with reducers and middleware is a powerful pattern that can help simplify complex UI logic in Android apps.
That concludes our discussion on the MVI architecture pattern and its potential benefits in structuring your new or existing Android app. We hope you found this article helpful and informative.