Android Jetpack Compose Using MVI Architecture

MaKB
5 min readDec 27, 2024

--

Photo by Max Duzij on Unsplash

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! 😊

--

--

MaKB
MaKB

Written by MaKB

Experienced software engineer with demonstrated history of working in telecommunications industry along with many other sectors like education, e-commerce etc.

No responses yet