🏗️ Building Robust Android Apps with MVVM Architecture in Jetpack Compose
Modern Android development demands code that’s not only functional but also scalable, testable, and easy to maintain. That’s where MVVM (Model-View-ViewModel) architecture comes into play—especially when paired with Jetpack Compose, Android's declarative UI toolkit. This article walks you through the how and why of MVVM in a Compose-based Android app, with a clean example and best practices.
🚀 Why MVVM Matters in Jetpack Compose
Jetpack Compose introduces a declarative UI model—you describe what the UI should look like for a given state, and Compose figures out how to draw it. However, this also means your state management and business logic must be separated carefully. If UI and logic are tightly coupled, you’ll soon run into tangled code.
The MVVM pattern promotes:
- Cleaner architecture
- Better testability
- Responsiveness to state changes
Compose’s state-driven recomposition works hand-in-hand with MVVM’s one-way data flow.
📐 Understanding MVVM in Compose Context
Let’s first understand what each component does in the Compose world:
Layer | Responsibility |
---|---|
Model | Manages and represents the data (e.g., API, DB, or cache) |
ViewModel | Contains business logic and exposes UI data via state |
View | Composable functions that display UI and react to ViewModel state |
Unlike older XML-based UIs, Compose encourages using State, StateFlow, or LiveData in your ViewModel, and directly observing them in composables with no need for findViewById or observe() calls.
1. What is the role of a ViewModel in Jetpack Compose, and how does it interact with Composables?
In Jetpack Compose, the ViewModel serves as the bridge between the business logic and the UI layer. It is responsible for holding and managing UI state in a lifecycle-aware manner. The ViewModel exposes state to Composables, which observe and react to changes automatically through recomposition. This separation ensures that the UI remains stateless and reactive, while the ViewModel maintains the logic and data integrity.
2. How do you expose UI state from a ViewModel to a Composable in a lifecycle-aware way?
UI state should be exposed using observable data holders that are lifecycle-aware, such as StateFlow or LiveData. Composables then observe these using proper lifecycle-aware collectors (like collectAsStateWithLifecycle), which ensures that state changes are only observed when the Composable is active. This approach prevents memory leaks and stale updates.
📌 State Management
3. Explain how you would manage UI state using StateFlow, LiveData, or MutableState in ViewModel. Which one do you prefer, and why?
StateFlow is generally preferred in modern Compose apps due to its integration with Kotlin Coroutines and structured concurrency. It allows for predictable, flow-based state updates. LiveData is useful for legacy interoperability, but lacks coroutine flexibility. MutableState is good for local, UI-only state but not suitable for sharing state across components or maintaining logic-heavy states. StateFlow offers a clean, coroutine-native solution ideal for most real-world MVVM scenarios.
4. How do you handle one-time events like navigation or showing a toast/snackbar in MVVM with Compose?
One-time UI events (often called "transient events") should be handled using channels or shared flows to avoid repeating the same action during recomposition. These events are typically collected in the Composable and consumed once. This pattern ensures actions like navigation or showing toasts do not persist unnecessarily in the UI state.
📌 Best Practices
5. How do you separate concerns between UI logic and business logic in MVVM with Compose?
The ViewModel should handle all business and UI logic, while Composables should focus purely on rendering the UI based on the given state. Any computation, validation, or decision-making should reside in the ViewModel or use cases. This maintains a clean separation and promotes testability and maintainability.
6. What are some anti-patterns to avoid when using ViewModel with Compose?
Common anti-patterns include: directly modifying UI state in Composables, placing business logic in Composables, leaking references to Views or Contexts from the ViewModel, and overusing mutable state without proper encapsulation. It's important to ensure that Composables remain declarative and stateless.
7. In a multi-screen Compose app, how do you share a ViewModel between screens? When should you or shouldn't you?
You can share a ViewModel between screens when they belong to the same navigation graph scope or share common state. This is useful for flows like wizards or multi-step forms. However, ViewModels should not be shared if the screens are independent or if state sharing introduces tight coupling, which would reduce modularity.
📌 Dependency Injection
8. How do you inject dependencies (like repositories or use cases) into a ViewModel?
Dependency injection should be handled through a framework like Hilt or Dagger. This ensures that the ViewModel receives its dependencies at construction, promoting testability and modularity. The injected dependencies typically include repositories, use cases, or data sources needed to drive the UI logic.
9. How does Hilt work with ViewModels in a Jetpack Compose environment?
Hilt simplifies dependency injection by providing automatic ViewModel injection through annotations and integration with the navigation graph. In Compose, Hilt can be used in conjunction with hiltViewModel(), ensuring that the ViewModel is scoped to the correct Composable and receives all required dependencies without boilerplate.
📌 Real-World Scenarios
10. Your app has complex user input forms. How would you structure ViewModel and Composables to manage validation, user input, and UI feedback?
The ViewModel would manage the entire form state as a data class, encapsulating each field’s value and error state. It would handle validation logic on field updates or submission events. Composables would simply observe and display the current state, passing user inputs back to the ViewModel via events. This creates a clean unidirectional data flow.
11. In a paginated list (e.g., news feed), how would your ViewModel manage API calls, loading state, and error handling?
The ViewModel would maintain separate states for content, loading, and error. It would trigger data loading based on scroll position or pagination tokens, while handling retry logic and backpressure. The UI would reflect these states through appropriate visual indicators like spinners or retry prompts.
12. How do you persist ViewModel state across process death or configuration changes in a Compose app?
To survive process death, state must be stored in a persistent medium like SavedStateHandle or a local database. Compose handles configuration changes well, but for critical state persistence, combining ViewModel with SavedStateHandle ensures restoration even if the process is killed and restarted by the system.
📌 Testing MVVM in Compose
13. How would you test a ViewModel that exposes StateFlow to a Composable?
You test the ViewModel in isolation by collecting the StateFlow in test coroutines. Assertions are made on the emitted states to verify that the ViewModel responds correctly to actions and state changes. This ensures business logic behaves as expected, independent of the UI.
14. What strategies do you use to test ViewModel logic separately from the UI in Compose apps?
Unit testing the ViewModel directly is the primary approach. Use fake repositories and mock dependencies to control data input. Emphasize testing all logic—state transformations, validation, event emissions—without relying on Composables or Android framework components.
🧱 MVVM Architecture: Full Example
We’ll create a small app that displays a list of books using MVVM and Jetpack Compose.
📦 1. Data Layer (Model + Repository)
Book.kt
package com.example.mvvmapp
data class Book(
val id: Int,
val title: String,
val author: String
)
BookRepository.kt
package com.example.mvvmapp
class BookRepository {
fun fetchBooks(): List<Book> {
return listOf(
Book(1, "Atomic Habits", "James Clear"),
Book(2, "Clean Code", "Robert C. Martin"),
Book(3, "The Alchemist", "Paulo Coelho")
)
}
}
🔄 2. ViewModel Layer
Handles business logic and exposes state in a Compose-compatible form.
BookViewModel.kt
package com.example.mvvmapp
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
class BookViewModel : ViewModel() {
private val repository = BookRepository()
// Expose UI state as immutable
private val _books = mutableStateOf<List<Book>>(emptyList())
val books: State<List<Book>> = _books
init {
loadBooks()
}
private fun loadBooks() {
_books.value = repository.fetchBooks()
}
}
🎨 3. View Layer (Composable UI)
Reads the state and renders the UI accordingly.
BookListScreen.kt
package com.example.mvvmapp
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Divider
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun BookListScreen(viewModel: BookViewModel = viewModel()) {
val bookList by viewModel.books
Scaffold(
topBar = {
TopAppBar(title = { Text("Library") })
}
) { paddingValues ->
LazyColumn(contentPadding = paddingValues) {
items(bookList) { book ->
Column(modifier = Modifier.padding(16.dp)) {
Text(text = book.title, style = MaterialTheme.typography.titleMedium)
Text(text = "by ${book.author}", style = MaterialTheme.typography.bodySmall)
HorizontalDivider(modifier = Modifier.padding(top = 8.dp))
}
}
}
}
}
🏁 4. App Entry Point
MainActivity.kt
package com.example.mvvmapp
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import com.example.mvvmapp.ui.theme.MVVMAppTheme
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
MVVMAppTheme {
/* Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
Greeting(
name = "Android",
modifier = Modifier.padding(innerPadding)
)
}*/
BookListScreen()
}
}
}
}