MVI Pattern β Android Architecture
The Visla GPS Android app uses Model-View-Intent (MVI) across all 30+ ViewModels. Every user action flows through a single handle() entry point, making state changes predictable and testable.
Architecture Overviewβ
Data flow is unidirectional:
- User interacts with Compose UI
- UI dispatches an Intent via
viewModel.handle(intent) handle()logs the intent, then callsreduce()reduce()matches the intent and runs business logic- Business logic updates
MutableStateFlow<UiState> - Compose recomposes from the new state
Core Componentsβ
BaseIntent β Marker Interfaceβ
Every screen's intents must implement this marker interface:
// com.visla.vislagps.ui.base.BaseIntent
interface BaseIntent
IntentViewModel<I> β Base Classβ
All ViewModels extend IntentViewModel<I> where I is the screen's sealed intent class:
// com.visla.vislagps.ui.base.IntentViewModel
abstract class IntentViewModel<I : BaseIntent> : ViewModel() {
// Single entry point for all user actions.
// Logs the intent name, then delegates to reduce().
open fun handle(intent: I) {
Logger.info(
"${this.javaClass.simpleName}: Handling intent",
mapOf("intent" to intent.javaClass.simpleName)
)
reduce(intent)
}
// Process the intent and update state.
// Keep synchronous β use handleAsync() for coroutines.
protected abstract fun reduce(intent: I)
// Launch a coroutine for async work with automatic error logging.
protected fun handleAsync(intent: I, block: suspend () -> Unit) {
Logger.info(
"${this.javaClass.simpleName}: Handling async intent",
mapOf("intent" to intent.javaClass.simpleName)
)
viewModelScope.launch {
try {
block()
} catch (e: Exception) {
Logger.error(
"${this.javaClass.simpleName}: Intent failed",
mapOf(
"intent" to intent.javaClass.simpleName,
"error" to (e.message ?: "unknown")
)
)
}
}
}
}
Three key methods:
| Method | Purpose | When to use |
|---|---|---|
handle(intent) | Public entry point. Logs intent, calls reduce(). | Called from Compose UI |
reduce(intent) | Abstract. Routes intents to private methods via when. | Implement in every ViewModel |
handleAsync(intent, block) | Launches a coroutine in viewModelScope with try/catch logging. | Use for network calls, DB ops |
How to Define Intentsβ
Each screen defines a sealed class extending BaseIntent. Every possible user action becomes a subclass:
sealed class ChangePasswordIntent : BaseIntent {
// Field updates (carry data)
data class UpdateCurrentPassword(val password: String) : ChangePasswordIntent()
data class UpdateNewPassword(val password: String) : ChangePasswordIntent()
data class UpdateConfirmPassword(val password: String) : ChangePasswordIntent()
// Toggle actions (no data)
data object ToggleCurrentPasswordVisibility : ChangePasswordIntent()
data object ToggleNewPasswordVisibility : ChangePasswordIntent()
data object ToggleConfirmPasswordVisibility : ChangePasswordIntent()
// Async action
data object ChangePassword : ChangePasswordIntent()
// UI housekeeping
data object ClearError : ChangePasswordIntent()
}
Conventions:
- Use
data classwhen the intent carries parameters - Use
data objectfor parameterless intents - Name intents as verbs:
UpdateEmail,LoadDevices,ToggleVisibility - Group related intents: field updates, actions, UI housekeeping
How to Define UI Stateβ
Use a data class for simple screens and a sealed class when the screen has distinct visual states (loading, success, error).
Data class approach (simple screens)β
Best when the screen always shows the same layout with varying data:
data class ChangePasswordUiState(
val currentPassword: String = "",
val newPassword: String = "",
val confirmPassword: String = "",
val showCurrentPassword: Boolean = false,
val showNewPassword: Boolean = false,
val showConfirmPassword: Boolean = false,
val isLoading: Boolean = false,
val error: String? = null,
val success: Boolean = false
) {
// Derived properties β computed from state, not stored
val passwordsMatch: Boolean
get() = newPassword == confirmPassword
val isValid: Boolean
get() = currentPassword.isNotBlank() &&
newPassword.length >= 8 &&
passwordsMatch &&
!isLoading
val newPasswordError: String?
get() = if (newPassword.isNotEmpty() && newPassword.length < 8) {
"Password must be at least 8 characters"
} else null
val confirmPasswordError: String?
get() = if (confirmPassword.isNotEmpty() && !passwordsMatch) {
"Passwords don't match"
} else null
}
Sealed class approach (complex screens)β
Best when the screen has distinct visual modes:
sealed class DevicesUiState {
object Loading : DevicesUiState()
data class Success(
val devices: List<DeviceWithPosition>,
val isWebSocketConnected: Boolean,
val licenseStatus: License?
) : DevicesUiState()
data class Error(
val message: String,
val devices: List<DeviceWithPosition> = emptyList()
) : DevicesUiState()
data class Refreshing(
val devices: List<DeviceWithPosition>,
val isWebSocketConnected: Boolean
) : DevicesUiState()
}
When to use which:
| Approach | Use when | Example screens |
|---|---|---|
data class | Always same layout, toggle flags for loading/error | Login, ChangePassword, EditProfile |
sealed class | Distinct visual modes (loading skeleton vs content vs full-screen error) | Devices, History, Notifications |
Complete Example: ChangePasswordViewModelβ
This is a simple, representative ViewModel that shows the full pattern:
1. Intents + UI State + ViewModelβ
// All three live in the same file: ChangePasswordViewModel.kt
// --- Intents ---
sealed class ChangePasswordIntent : BaseIntent {
data class UpdateCurrentPassword(val password: String) : ChangePasswordIntent()
data class UpdateNewPassword(val password: String) : ChangePasswordIntent()
data class UpdateConfirmPassword(val password: String) : ChangePasswordIntent()
data object ToggleCurrentPasswordVisibility : ChangePasswordIntent()
data object ToggleNewPasswordVisibility : ChangePasswordIntent()
data object ToggleConfirmPasswordVisibility : ChangePasswordIntent()
data object ChangePassword : ChangePasswordIntent()
data object ClearError : ChangePasswordIntent()
}
// --- UI State ---
data class ChangePasswordUiState(
val currentPassword: String = "",
val newPassword: String = "",
val confirmPassword: String = "",
val showCurrentPassword: Boolean = false,
val showNewPassword: Boolean = false,
val showConfirmPassword: Boolean = false,
val isLoading: Boolean = false,
val error: String? = null,
val success: Boolean = false
) {
val passwordsMatch: Boolean
get() = newPassword == confirmPassword
val isValid: Boolean
get() = currentPassword.isNotBlank() &&
newPassword.length >= 8 &&
passwordsMatch &&
!isLoading
val newPasswordError: String?
get() = if (newPassword.isNotEmpty() && newPassword.length < 8) {
"Password must be at least 8 characters"
} else null
val confirmPasswordError: String?
get() = if (confirmPassword.isNotEmpty() && !passwordsMatch) {
"Passwords don't match"
} else null
}
// --- ViewModel ---
@HiltViewModel
class ChangePasswordViewModel @Inject constructor(
private val changePasswordUseCase: ChangePasswordUseCase
) : IntentViewModel<ChangePasswordIntent>() {
private val _uiState = MutableStateFlow(ChangePasswordUiState())
val uiState: StateFlow<ChangePasswordUiState> = _uiState.asStateFlow()
override fun reduce(intent: ChangePasswordIntent) {
when (intent) {
is ChangePasswordIntent.UpdateCurrentPassword ->
_uiState.value = _uiState.value.copy(currentPassword = intent.password, error = null)
is ChangePasswordIntent.UpdateNewPassword ->
_uiState.value = _uiState.value.copy(newPassword = intent.password, error = null)
is ChangePasswordIntent.UpdateConfirmPassword ->
_uiState.value = _uiState.value.copy(confirmPassword = intent.password, error = null)
is ChangePasswordIntent.ToggleCurrentPasswordVisibility ->
_uiState.value = _uiState.value.copy(showCurrentPassword = !_uiState.value.showCurrentPassword)
is ChangePasswordIntent.ToggleNewPasswordVisibility ->
_uiState.value = _uiState.value.copy(showNewPassword = !_uiState.value.showNewPassword)
is ChangePasswordIntent.ToggleConfirmPasswordVisibility ->
_uiState.value = _uiState.value.copy(showConfirmPassword = !_uiState.value.showConfirmPassword)
is ChangePasswordIntent.ChangePassword -> changePassword()
is ChangePasswordIntent.ClearError ->
_uiState.value = _uiState.value.copy(error = null)
}
}
private fun changePassword() {
if (!_uiState.value.isValid) return
viewModelScope.launch {
try {
_uiState.value = _uiState.value.copy(isLoading = true, error = null)
changePasswordUseCase(
currentPassword = _uiState.value.currentPassword,
newPassword = _uiState.value.newPassword
)
_uiState.value = _uiState.value.copy(isLoading = false, success = true)
} catch (e: Exception) {
_uiState.value = _uiState.value.copy(
isLoading = false,
error = e.message ?: "Failed to change password"
)
}
}
}
}
2. Compose Screenβ
@Composable
fun ChangePasswordScreen(
viewModel: ChangePasswordViewModel = hiltViewModel(),
onBack: () -> Unit
) {
val uiState by viewModel.uiState.collectAsState()
// React to one-shot events
LaunchedEffect(uiState.success) {
if (uiState.success) onBack()
}
Column {
// Each input dispatches an intent on change
TextInput(
value = uiState.currentPassword,
onValueChange = { viewModel.handle(ChangePasswordIntent.UpdateCurrentPassword(it)) },
label = "Current Password",
isPassword = true,
)
TextInput(
value = uiState.newPassword,
onValueChange = { viewModel.handle(ChangePasswordIntent.UpdateNewPassword(it)) },
label = "New Password",
isError = uiState.newPasswordError != null,
errorMessage = uiState.newPasswordError,
)
TextInput(
value = uiState.confirmPassword,
onValueChange = { viewModel.handle(ChangePasswordIntent.UpdateConfirmPassword(it)) },
label = "Confirm Password",
)
// Error display
uiState.error?.let { error ->
Text(text = error, color = colors.error)
}
// Submit button β enabled/loading driven by state
PrimaryButton(
text = "Change Password",
onClick = { viewModel.handle(ChangePasswordIntent.ChangePassword) },
enabled = uiState.isValid,
loading = uiState.isLoading,
)
}
}
Complex Example: DevicesViewModelβ
DevicesViewModel demonstrates the pattern at scale β multiple state flows, real-time WebSocket data, lifecycle-aware refresh, and derived state flows for backward compatibility.
Key patterns in the complex caseβ
Sealed class UI state with exhaustive when:
override fun reduce(intent: DeviceIntent) {
when (intent) {
is DeviceIntent.LoadDevices -> loadDevices()
is DeviceIntent.RefreshDevices -> refreshDevices()
is DeviceIntent.PullToRefresh -> refresh()
is DeviceIntent.SelectDevice -> selectDevice(intent.deviceId)
is DeviceIntent.CheckLicense -> checkLicenseStatus(intent.onCanAdd, intent.onNeedUpgrade)
is DeviceIntent.ReconnectWebSocket -> reconnectWebSocket()
is DeviceIntent.UnclaimDevice -> unclaimDevice(intent.deviceId)
is DeviceIntent.ClearOperationResult -> _operationResult.value = null
}
}
Self-dispatching intents from init:
init {
// Observe DataStore for real-time WebSocket updates
viewModelScope.launch {
devicesInteractor.devicesFlow.collect { devices ->
// Update UI state when data source changes
}
}
// Load immediately on creation
handle(DeviceIntent.LoadDevices)
}
Derived StateFlows for gradual migration:
// Backward-compatible flows derived from the main uiState
val devices: StateFlow<List<Device>> = _uiState.map { state ->
when (state) {
is DevicesUiState.Success -> state.devices.map { it.device }
is DevicesUiState.Refreshing -> state.devices.map { it.device }
is DevicesUiState.Error -> state.devices.map { it.device }
DevicesUiState.Loading -> emptyList()
}
}.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList())
DevicesViewModel also exposes additional backward-compatible derived StateFlows beyond devices: positions, isLoading, isRefreshing, errorMessage, licenseStatus, and devicesWithPositionsFlow. Each is mapped from the main _uiState sealed class using the same .map { ... }.stateIn(...) pattern.
Lifecycle-aware foreground refresh:
DevicesViewModel registers a DefaultLifecycleObserver on ProcessLifecycleOwner to automatically refresh devices when the app returns to the foreground. The observer skips the initial onStart (already handled by init) and debounces subsequent foreground events within 5 seconds.
One-shot events via separate StateFlow:
// In LoginViewModel β events that should be consumed once
private val _loginEvent = MutableStateFlow<LoginEvent?>(null)
val loginEvent: StateFlow<LoginEvent?> = _loginEvent.asStateFlow()
sealed class LoginEvent {
object Success : LoginEvent()
data class NeedVerification(val email: String) : LoginEvent()
data class NeedTermsAcceptance(val name: String) : LoginEvent()
}
// In the Compose screen:
LaunchedEffect(loginEvent) {
when (val event = loginEvent) {
is LoginEvent.Success -> {
viewModel.handle(LoginIntent.ClearEvent)
navigateToHome()
}
is LoginEvent.NeedVerification -> {
viewModel.handle(LoginIntent.ClearEvent)
navigateToVerification(event.email)
}
is LoginEvent.NeedTermsAcceptance -> {
viewModel.handle(LoginIntent.ClearEvent)
navigateToTermsAcceptance(event.name)
}
null -> {}
}
}
Error Handlingβ
In synchronous reduceβ
For validation or simple state updates, set the error directly:
private fun login() {
val email = _uiState.value.email.trim()
if (email.isBlank()) {
_uiState.value = _uiState.value.copy(errorMessage = "Please enter email")
return
}
// proceed...
}
In async operationsβ
Wrap the coroutine in try/catch, always reset loading state:
private fun changePassword() {
viewModelScope.launch {
try {
_uiState.value = _uiState.value.copy(isLoading = true, error = null)
changePasswordUseCase(currentPassword, newPassword)
_uiState.value = _uiState.value.copy(isLoading = false, success = true)
} catch (e: Exception) {
_uiState.value = _uiState.value.copy(
isLoading = false,
error = e.message ?: "Failed to change password"
)
}
}
}
Using handleAsync() (structured approach)β
For ViewModels that want automatic error logging without manual try/catch:
override fun reduce(intent: MyIntent) {
when (intent) {
is MyIntent.LoadData -> handleAsync(intent) {
_state.value = State.Loading
val result = loadDataUseCase()
_state.value = State.Success(result)
}
}
}
handleAsync catches exceptions and logs them with the intent name automatically.
The trade-off is you lose custom error handling β use viewModelScope.launch with manual try/catch when you need to update error state for the UI.
Testing ViewModelsβ
Tests use MockK for mocking, kotlinx-coroutines-test for coroutine control, and interact through handle() just like the UI does.
Setup patternβ
@OptIn(ExperimentalCoroutinesApi::class)
class ChangePasswordViewModelTest {
private lateinit var viewModel: ChangePasswordViewModel
private lateinit var changePasswordUseCase: ChangePasswordUseCase
private val testDispatcher = StandardTestDispatcher()
@Before
fun setup() {
Dispatchers.setMain(testDispatcher)
changePasswordUseCase = mockk(relaxed = true)
viewModel = ChangePasswordViewModel(changePasswordUseCase)
}
@After
fun tearDown() {
Dispatchers.resetMain()
}
}
Testing synchronous intentsβ
Dispatch intent, assert state immediately:
@Test
fun `UpdateCurrentPassword - updates current password`() {
viewModel.handle(ChangePasswordIntent.UpdateCurrentPassword("oldpass123"))
assertEquals("oldpass123", viewModel.uiState.value.currentPassword)
}
@Test
fun `ToggleCurrentPasswordVisibility - toggles visibility`() {
assertFalse(viewModel.uiState.value.showCurrentPassword)
viewModel.handle(ChangePasswordIntent.ToggleCurrentPasswordVisibility)
assertTrue(viewModel.uiState.value.showCurrentPassword)
viewModel.handle(ChangePasswordIntent.ToggleCurrentPasswordVisibility)
assertFalse(viewModel.uiState.value.showCurrentPassword)
}
Testing async intentsβ
Use runTest and advanceUntilIdle() to let coroutines complete:
@Test
fun `ChangePassword - calls use case with passwords`() = runTest {
viewModel.handle(ChangePasswordIntent.UpdateCurrentPassword("currentpass"))
viewModel.handle(ChangePasswordIntent.UpdateNewPassword("newpassword"))
viewModel.handle(ChangePasswordIntent.UpdateConfirmPassword("newpassword"))
viewModel.handle(ChangePasswordIntent.ChangePassword)
advanceUntilIdle()
coVerify { changePasswordUseCase("currentpass", "newpassword") }
}
@Test
fun `ChangePassword - failure sets error`() = runTest {
coEvery { changePasswordUseCase(any(), any()) } throws RuntimeException("Invalid current password")
viewModel.handle(ChangePasswordIntent.UpdateCurrentPassword("current"))
viewModel.handle(ChangePasswordIntent.UpdateNewPassword("newpassword"))
viewModel.handle(ChangePasswordIntent.UpdateConfirmPassword("newpassword"))
viewModel.handle(ChangePasswordIntent.ChangePassword)
advanceUntilIdle()
assertFalse(viewModel.uiState.value.success)
assertEquals("Invalid current password", viewModel.uiState.value.error)
}
Testing the base IntentViewModelβ
The project includes IntentViewModelTest that verifies the base class contract:
@Test
fun `handle calls reduce with the intent`() {
viewModel.handle(TestIntent.SimpleAction)
assertEquals(TestIntent.SimpleAction, viewModel.reducedIntents[0])
}
@Test
fun `handleAsync handles exceptions without crashing`() = runTest(testDispatcher) {
viewModel.testHandleAsync(TestIntent.AsyncAction) {
throw RuntimeException("Test error")
}
advanceUntilIdle()
// No crash β exception is caught and logged
}
Creating a New Screen β Step by Stepβ
1. Define the intentsβ
sealed class MyScreenIntent : BaseIntent {
data object Load : MyScreenIntent()
data class UpdateName(val name: String) : MyScreenIntent()
data object Save : MyScreenIntent()
data object ClearError : MyScreenIntent()
}
2. Define the UI stateβ
data class MyScreenUiState(
val name: String = "",
val isLoading: Boolean = false,
val error: String? = null,
val saved: Boolean = false,
)
3. Create the ViewModelβ
@HiltViewModel
class MyScreenViewModel @Inject constructor(
private val myUseCase: MyUseCase,
) : IntentViewModel<MyScreenIntent>() {
private val _uiState = MutableStateFlow(MyScreenUiState())
val uiState: StateFlow<MyScreenUiState> = _uiState.asStateFlow()
init {
handle(MyScreenIntent.Load)
}
override fun reduce(intent: MyScreenIntent) {
when (intent) {
is MyScreenIntent.Load -> load()
is MyScreenIntent.UpdateName -> _uiState.value = _uiState.value.copy(name = intent.name)
is MyScreenIntent.Save -> save()
is MyScreenIntent.ClearError -> _uiState.value = _uiState.value.copy(error = null)
}
}
private fun load() {
viewModelScope.launch {
_uiState.value = _uiState.value.copy(isLoading = true)
try {
val data = myUseCase.load()
_uiState.value = _uiState.value.copy(name = data.name, isLoading = false)
} catch (e: Exception) {
_uiState.value = _uiState.value.copy(
isLoading = false,
error = e.message ?: "Failed to load"
)
}
}
}
private fun save() {
viewModelScope.launch {
_uiState.value = _uiState.value.copy(isLoading = true, error = null)
try {
myUseCase.save(_uiState.value.name)
_uiState.value = _uiState.value.copy(isLoading = false, saved = true)
} catch (e: Exception) {
_uiState.value = _uiState.value.copy(
isLoading = false,
error = e.message ?: "Failed to save"
)
}
}
}
}
4. Build the Compose screenβ
@Composable
fun MyScreen(
viewModel: MyScreenViewModel = hiltViewModel(),
onBack: () -> Unit,
) {
val uiState by viewModel.uiState.collectAsState()
LaunchedEffect(uiState.saved) {
if (uiState.saved) onBack()
}
Column {
TextInput(
value = uiState.name,
onValueChange = { viewModel.handle(MyScreenIntent.UpdateName(it)) },
)
uiState.error?.let { Text(it, color = colors.error) }
PrimaryButton(
text = "Save",
onClick = { viewModel.handle(MyScreenIntent.Save) },
loading = uiState.isLoading,
)
}
}
5. Write testsβ
@OptIn(ExperimentalCoroutinesApi::class)
class MyScreenViewModelTest {
private val testDispatcher = StandardTestDispatcher()
private lateinit var myUseCase: MyUseCase
private lateinit var viewModel: MyScreenViewModel
@Before
fun setup() {
Dispatchers.setMain(testDispatcher)
myUseCase = mockk(relaxed = true)
viewModel = MyScreenViewModel(myUseCase)
}
@After
fun tearDown() { Dispatchers.resetMain() }
@Test
fun `UpdateName updates state`() {
viewModel.handle(MyScreenIntent.UpdateName("Test"))
assertEquals("Test", viewModel.uiState.value.name)
}
@Test
fun `Save calls use case`() = runTest {
viewModel.handle(MyScreenIntent.UpdateName("Test"))
viewModel.handle(MyScreenIntent.Save)
advanceUntilIdle()
coVerify { myUseCase.save("Test") }
assertTrue(viewModel.uiState.value.saved)
}
}
Design Decisionsβ
Why MVI over MVVM?β
Standard MVVM with mutable state properties allows multiple code paths to mutate state independently, making it hard to reason about state transitions. MVI enforces:
- Unidirectional data flow β state only changes through intents, never from the UI directly
- Single entry point β every action goes through
handle(), enabling centralized logging and debugging - Predictable state β given the same sequence of intents, you get the same state
- Easier testing β test by dispatching intents and asserting state, no need to mock UI interactions
Why sealed classes for intents?β
// Kotlin's exhaustive when ensures you handle every intent
override fun reduce(intent: ChangePasswordIntent) {
when (intent) { // Compiler error if you miss a case
is ChangePasswordIntent.UpdateCurrentPassword -> ...
is ChangePasswordIntent.UpdateNewPassword -> ...
// Forget one? Compiler tells you.
}
}
- Type safety β intents carry typed data, no stringly-typed event names
- Exhaustive
whenβ add a new intent, the compiler forces you to handle it everywhere - Self-documenting β the sealed class is the complete list of what a screen can do
- IDE support β auto-complete, rename refactoring, find usages all work
Why StateFlow over LiveData?β
- Coroutine-native β
StateFlowintegrates naturally withviewModelScopeandsuspendfunctions - Null safety β
StateFlowalways has a value (no initialnullsurprise) - Thread safety β
StateFlowis safe to update from any coroutine context - Compose integration β
collectAsState()is the idiomatic Compose way - Derived state β
map(),combine(),stateIn()compose cleanly
Why the handleAsync wrapper?β
- Structured concurrency β runs in
viewModelScope, auto-cancelled when ViewModel clears - Automatic error logging β catches exceptions and logs intent name + error message
- Consistency β every async intent gets the same logging format
- Safety net β uncaught exceptions in coroutines crash the app;
handleAsyncprevents this
Most ViewModels in practice use viewModelScope.launch directly with manual try/catch for more granular error handling (setting error messages in UI state). handleAsync is useful when you want fire-and-forget behavior with automatic logging.
File Locationsβ
| Component | Path |
|---|---|
BaseIntent | app/src/main/java/com/visla/vislagps/ui/base/BaseIntent.kt |
IntentViewModel | app/src/main/java/com/visla/vislagps/ui/base/IntentViewModel.kt |
| ViewModels | app/src/main/java/com/visla/vislagps/ui/screens/*ViewModel.kt |
| ViewModel Tests | app/src/test/java/com/visla/vislagps/ui/screens/*ViewModelTest.kt |
| Base class tests | app/src/test/java/com/visla/vislagps/ui/base/IntentViewModelTest.kt |