How to Design MVI in Android
I know I posted an in depth series of blog posts about this, but it’s so long that it’s a bit hard to follow. I think I’ve been able to simplify the concept better here. So I’ve removed the 7 posts (!) I wrote on the subject, and replaced them with this one, consolidated post.
Here is a simplified breakdown of the MVI architecture.
Model
The state class. Make it a data class:
data class BrownNoiseState(
val isPlaying: Boolean = false
)
Intent
A sealed class:
sealed class BrownNoiseIntent {
object TogglePlayback : BrownNoiseIntent()
}
ViewModel
A standard class that extends ViewModel
:
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.launch
class BrownNoiseViewModel : ViewModel() {
}
The ViewModel will always track the mutable state:
private val _state = MutableStateFlow(BrownNoiseState())
val state: StateFlow<BrownNoiseState> get() = _state
Then you will have the public method to process your intents:
fun processIntent(intent: BrownNoiseIntent) {
when (intent) {
is BrownNoiseIntent.TogglePlayback -> togglePlayback()
}
}
Then the private methods where you do the work.
- These work methods are triggered by the public intent processing method.
- Their final effects are to change values in the state.
ViewModelScope::launch
The launch
function is used to start a new coroutine.
Coroutines launched in viewModelScope
are automatically canceled when the ViewModel is cleared, such as when the associated UI component (like an Activity or Fragment) is destroyed. This helps in preventing memory leaks and ensuring that ongoing operations do not continue when they are no longer needed.
By using viewModelScope.launch
, we can perform concurrent operations without blocking the main thread. This keeps us from freezing the UI while we do work.
Finally, viewModelScope
is lifecycle-aware, so it automatically cancels the coroutine if the ViewModel is cleared. This prevents potential issues like memory leaks and ensures that no unnecessary operations are performed when the UI component is no longer in use.
Example:
private fun togglePlayback() {
viewModelScope.launch {
val newState = !_state.value.isPlaying
_state.value = BrownNoiseState(isPlaying = newState)
if (newState) {
startBrownNoise()
} else {
stopBrownNoise()
}
}
}
View
Set up MainActivity
like so:
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import net.erickveil.calmsound.intent.BrownNoiseIntent
import net.erickveil.calmsound.model.BrownNoiseState
import net.erickveil.calmsound.ui.theme.CalmSoundTheme
import androidx.activity.viewModels
class MainActivity : ComponentActivity() {
private val viewModel: BrownNoiseViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
CalmSoundTheme {
val state by viewModel.state.collectAsStateWithLifecycle()
BrownNoisePlayerScreen(
state = state,
onTogglePlayback = {
viewModel.processIntent(BrownNoiseIntent.TogglePlayback)
}
)
}
}
}
@Composable
fun BrownNoisePlayerScreen(
state: BrownNoiseState,
onTogglePlayback: () -> Unit
) {
Button(onClick = onTogglePlayback) {
Text(if (state.isPlaying) "Stop Brown Noise" else "Play Brown Noise")
}
}
}
private val viewModel
This gets us the ViewModel in a lifecycle aware way.
state by viewModel.state.collectAsStateWithLifecycle()
This allows us to capture the current state in a lifecycle aware way. This lets us stop collecting the state when the composable is not active. This also allows any change in the state to be reflected in the Composable.
onTogglePlayback: () -> Unit
This function parameter is a lambda used as a callback.
Here, in the onCreate
we define what happens when the button is pressed on the backend, where we mess with intent and trigger the work to be done in the ViewModel:
onTogglePlayback = {
viewModel.processIntent(BrownNoiseIntent.TogglePlayback)
}
Here we connect this to the button click and define what gets done in the View:
Button(onClick = onTogglePlayback) {
Text(if (state.isPlaying) "Stop Brown Noise" else "Play Brown Noise")
}
So we have a separation of concerns. The View code is not mixed up with the ViewModel code.
For a full example of an MVI project, take a look at my fully functional CalmSound app’s sourcecode. You can also find this app on the Google Play Store.