Modern Android development has seen significant shifts in how we build and manage user interfaces. Jetpack Compose, with its declarative approach, has made UI development faster and more intuitive. However, managing state and business logic in a Compose application requires a robust architectural pattern. One such pattern is Model-View-Intent (MVI).
In this blog, we’ll explore the MVI architecture, its benefits, and how it integrates seamlessly with Jetpack Compose. To illustrate, we’ll build a simple login screen using MVI principles.
What is MVI Architecture?
Model-View-Intent (MVI) is a unidirectional data flow architecture designed for reactive systems. It ensures that the state transitions in your application are predictable and easy to debug. Let’s break down its components:
1. Model:
The model represents the state of the application. In MVI, the state is immutable, meaning every state change produces a new state rather than modifying the existing one. This immutability ensures a single source of truth for the UI, making state management predictable.
2. View:
The view is the UI layer that observes the state and renders the interface accordingly. It also emits user actions as Intents to the business logic.
3. Intent:
Intents represent user interactions (e.g., button clicks, text input) or system events. These intents are processed by the business logic layer to update the state.
The core principle of MVI is unidirectional data flow, where:
1. The View emits an Intent.
2. The Intent is processed to update the Model.
3. The updated Model is rendered back in the View.
Why Choose MVI for Jetpack Compose?
Jetpack Compose, with its declarative nature, encourages immutable state management and unidirectional data flow. This makes MVI a natural fit for Compose applications. Here’s why:
1. Predictable State
MVI ensures that every UI state is derived from a single source of truth. This predictability makes debugging easier and eliminates edge cases caused by inconsistent state.
2. Enhanced Testability
With clear separation of concerns, the business logic and state management in MVI can be easily unit-tested without relying on the UI.
3. Simplified State Management
In Compose, UI recompositions are triggered automatically when the state changes. MVI leverages this feature to keep the UI in sync with the application state effortlessly.
4. Scalability
For larger applications with complex state transitions, MVI provides a structured approach that scales well with feature growth
MVI Architecture in Action: Building a Login Screen
Let’s implement a login screen in Jetpack Compose using MVI architecture.
Step 1: Define the State (Model)
The state represents the current UI data. For a login screen, the state includes fields like email, password, loading status, and error messages.
data class LoginState(
val email: String = "",
val password: String = "",
val isLoading: Boolean = false,
val errorMessage: String? = null
)
This immutable state will be updated for every user interaction.
Step 2: Define the Intent
Intents encapsulate user actions such as typing in a text field or clicking a button.
sealed class LoginIntent {
data class EmailChanged(val email: String) : LoginIntent()
data class PasswordChanged(val password: String) : LoginIntent()
object Submit : LoginIntent()
}
By encapsulating actions into these sealed classes, the application logic becomes explicit and easier to maintain.
Step 3: ViewModel — The Heart of MVI
The ViewModel handles intents and updates the state. It acts as a bridge between the View and the Model.
import androidx.lifecycle.ViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
class LoginViewModel : ViewModel() {
private val _state = MutableStateFlow(LoginState())
val state: StateFlow<LoginState> = _state
fun processIntent(intent: LoginIntent) {
when (intent) {
is LoginIntent.EmailChanged -> {
_state.update { it.copy(email = intent.email) }
}
is LoginIntent.PasswordChanged -> {
_state.update { it.copy(password = intent.password) }
}
is LoginIntent.Submit -> {
performLogin()
}
}
}
private fun performLogin() {
_state.update { it.copy(isLoading = true, errorMessage = null) }
// Simulate network request
viewModelScope.launch {
kotlinx.coroutines.delay(2000) // Simulate network delay
val isSuccess = _state.value.email == "user@example.com" && _state.value.password == "password123"
_state.update {
if (isSuccess) {
it.copy(isLoading = false)
} else {
it.copy(isLoading = false, errorMessage = "Invalid credentials")
}
}
}
}
}
Step 4: Build the UI (View)
The composable observes the state exposed by the ViewModel and renders the UI accordingly.
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
@Composable
fun LoginScreen(viewModel: LoginViewModel) {
val state by viewModel.state.collectAsState()
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
if (state.isLoading) {
CircularProgressIndicator()
} else {
TextField(
value = state.email,
onValueChange = { viewModel.processIntent(LoginIntent.EmailChanged(it)) },
label = { Text("Email") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email)
)
Spacer(modifier = Modifier.height(8.dp))
TextField(
value = state.password,
onValueChange = { viewModel.processIntent(LoginIntent.PasswordChanged(it)) },
label = { Text("Password") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password)
)
Spacer(modifier = Modifier.height(16.dp))
Button(onClick = { viewModel.processIntent(LoginIntent.Submit) }) {
Text("Login")
}
state.errorMessage?.let { error ->
Spacer(modifier = Modifier.height(8.dp))
Text(text = error, color = MaterialTheme.colorScheme.error)
}
}
}
}
Step 5: Integrate Everything in MainActivity
Finally, connect all components in your MainActivity.
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.lifecycle.viewmodel.compose.viewModel
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
val viewModel: LoginViewModel = viewModel()
LoginScreen(viewModel = viewModel)
}
}
}
Advantages of MVI with Jetpack Compose
1. Single Source of Truth:
All UI states are derived from the StateFlow, making debugging and testing more straightforward.
2. Declarative UI:
Compose’s reactive nature works seamlessly with MVI’s state-based updates.
3. Scalability:
MVI makes it easy to manage complex state transitions, even in large applications.
4. Testability:
Business logic in the ViewModel can be tested independently of the UI.
Final Thoughts
The MVI architecture aligns perfectly with Jetpack Compose’s declarative programming model, providing a robust solution for managing state and user interactions. By adopting MVI, you can build predictable, testable, and scalable Android apps.
Start small by implementing MVI for simple screens like the login screen we built here. Over time, you’ll appreciate how it simplifies complex applications.
Happy coding! 🚀
- —
Stay Updated with the Latest Posts: Don’t Miss Out!
If you found this post helpful, show your support by giving multiple claps 👏
Thank you for your support and appreciation! 😊