Android MVVM architecture: practical tips and trade-offs
When you're building Android apps that scale beyond a single screen, how do you keep UI logic separate from business logic without ending up with a tangled mess? Android MVVM architecture: practical tips and trade-offs provides a structured answer—one we've applied across e-commerce, CRM, and SaaS products on Android. This pattern isn't just theoretical; it directly impacts testability, maintainability, and how smoothly your team hands off features to one another.
Understanding the MVVM layers and unidirectional data flow
MVVM divides responsibility into three layers: Model, View, and ViewModel. The Model holds your business logic and data access layer. The View is your UI (typically Compose these days). The ViewModel sits between them, exposing state and accepting user intent.
Data flows in one direction: the UI observes state from the ViewModel, which coordinates data from a Repository, which pulls from data sources like Room (local database) or Retrofit (network API). When the user taps a button, that's an event sent to the ViewModel, which updates state, which the UI re-renders.
Here's a practical example. Suppose you're building a product listing screen in an e-commerce app:
// Data layer: Room entity
@Entity(tableName = "products")
data class ProductEntity(
@PrimaryKey val id: String,
val name: String,
val price: Double,
val imageUrl: String
)
// Repository: coordinates data sources
class ProductRepository(
private val productDao: ProductDao,
private val productService: ProductService
) {
fun getProducts(): Flow<List<Product>> = flow {
try {
val networkProducts = productService.fetchProducts()
productDao.insertAll(networkProducts.map { it.toEntity() })
emit(productDao.getAllProducts().map { it.toDomain() })
} catch (e: Exception) {
emit(productDao.getAllProducts().map { it.toDomain() })
}
}
}
// ViewModel: exposes state and accepts events
class ProductListViewModel(
private val repository: ProductRepository
) : ViewModel() {
private val _uiState = MutableStateFlow<ProductListUiState>(ProductListUiState.Loading)
val uiState: StateFlow<ProductListUiState> = _uiState.asStateFlow()
init {
loadProducts()
}
private fun loadProducts() {
viewModelScope.launch {
repository.getProducts()
.catch { _uiState.value = ProductListUiState.Error(it.message ?: "Unknown error") }
.collect { products ->
_uiState.value = ProductListUiState.Success(products)
}
}
}
fun onRetryClicked() {
loadProducts()
}
}
// UI State
sealed class ProductListUiState {
object Loading : ProductListUiState()
data class Success(val products: List<Product>) : ProductListUiState()
data class Error(val message: String) : ProductListUiState()
}
// Compose UI: observes state
@Composable
fun ProductListScreen(viewModel: ProductListViewModel = hiltViewModel()) {
val uiState by viewModel.uiState.collectAsState()
when (uiState) {
is ProductListUiState.Loading -> CircularProgressIndicator()
is ProductListUiState.Success -> {
val products = (uiState as ProductListUiState.Success).products
LazyColumn {
items(products) { product ->
ProductCard(product)
}
}
}
is ProductListUiState.Error -> {
val message = (uiState as ProductListUiState.Error).message
ErrorView(message) { viewModel.onRetryClicked() }
}
}
}Notice how the UI doesn't know about the Repository or the network. The ViewModel doesn't import Compose. Each layer has a single job, and data always flows the same direction.
Why ViewModels survive configuration changes
One of MVVM's biggest wins is that ViewModels persist across device rotation. When the user rotates their phone, the Activity is destroyed and recreated, but the ViewModel instance stays alive thanks to the Jetpack ViewModel library.
This means:
- You don't lose your UI state (scroll position, form input, partially loaded data).
- You don't re-fetch data from the network unnecessarily.
- The user experience feels seamless.
Without MVVM, you'd need to manually save and restore state using onSaveInstanceState and onRestoreInstanceState, which is error-prone and clutters your Activity code.
The ViewModel is scoped to the Activity or Fragment lifecycle. When the Activity is finally destroyed (user back press, not just rotation), the ViewModel is cleared and onCleared() is called, giving you a chance to cancel coroutines or cleanup resources:
class ProductDetailViewModel(
private val repository: ProductRepository
) : ViewModel() {
private val _uiState = MutableStateFlow<ProductDetailUiState>(ProductDetailUiState.Loading)
val uiState: StateFlow<ProductDetailUiState> = _uiState.asStateFlow()
override fun onCleared() {
super.onCleared()
// Any cleanup, like cancelling analytics tracking
println("ProductDetailViewModel cleared")
}
}Enforcing unidirectional data flow: events from UI, state to UI
Unidirectional data flow is the backbone of MVVM. The UI sends events (user taps, text input, lifecycle changes), and the ViewModel responds by updating state. The UI observes that state and re-renders.
This directional clarity makes debugging much easier. If something is wrong on screen, you trace the event backward: what state caused it? What ViewModel method set that state? What event triggered that method?
In a ride-hailing app, imagine a "request ride" flow:
// Events the UI can trigger
sealed class RideRequestEvent {
data class OnPickupLocationChanged(val location: Location) : RideRequestEvent()
data class OnDropoffLocationChanged(val location: Location) : RideRequestEvent()
object OnRequestRideClicked : RideRequestEvent()
}
// ViewModel receives events and updates state
class RideRequestViewModel(
private val rideService: RideService
) : ViewModel() {
private val _uiState = MutableStateFlow<RideRequestUiState>(RideRequestUiState.Idle)
val uiState: StateFlow<RideRequestUiState> = _uiState.asStateFlow()
fun onEvent(event: RideRequestEvent) {
when (event) {
is RideRequestEvent.OnPickupLocationChanged -> {
val currentState = _uiState.value as? RideRequestUiState.FormFilled ?: return
_uiState.value = currentState.copy(pickupLocation = event.location)
}
is RideRequestEvent.OnDropoffLocationChanged -> {
val currentState = _uiState.value as? RideRequestUiState.FormFilled ?: return
_uiState.value = currentState.copy(dropoffLocation = event.location)
}
is RideRequestEvent.OnRequestRideClicked -> {
viewModelScope.launch {
_uiState.value = RideRequestUiState.Submitting
val result = rideService.requestRide(/* params */)
_uiState.value = RideRequestUiState.Success(result)
}
}
}
}
}
// UI sends events, observes state
@Composable
fun RideRequestScreen(viewModel: RideRequestViewModel = hiltViewModel()) {
val uiState by viewModel.uiState.collectAsState()
Column {
LocationPickerField(
onLocationSelected = { location ->
viewModel.onEvent(RideRequestEvent.OnPickupLocationChanged(location))
}
)
Button(
onClick = { viewModel.onEvent(RideRequestEvent.OnRequestRideClicked) }
) {
Text("Request Ride")
}
}
}This pattern scales. Whether you're adding analytics, A/B testing, or recovery logic, you modify the ViewModel and the UI automatically picks it up through state changes.
MVVM makes ViewModels testable without an Android device
One of the clearest wins of MVVM is testability. Because the ViewModel contains no Android dependencies (no Context, no View references), you can test it with plain JUnit on your development machine in seconds, not on an emulator or physical device.
Here's what a ViewModel test looks like in a travel booking app:
@RunWith(JUnit4::class)
class FlightSearchViewModelTest {
private lateinit var flightService: FakeFlightService
private lateinit var viewModel: FlightSearchViewModel
@get:Rule
val instantExecutorRule = InstantTaskExecutorRule()
@Before
fun setup() {
flightService = FakeFlightService()
viewModel = FlightSearchViewModel(flightService)
}
@Test
fun searchFlights_emitsLoadingThenSuccess() = runTest {
// Arrange
flightService.setFlights(listOf(Flight("AA100", "NYC", "LAX", 299.0)))
// Act
viewModel.onEvent(FlightSearchEvent.OnSearchClicked("NYC", "LAX", "2025-01-15"))
// Assert
val state1 = viewModel.uiState.value
assert(state1 is FlightSearchUiState.Searching)
advanceUntilIdle()
val state2 = viewModel.uiState.value
assert(state2 is FlightSearchUiState.Results)
val results = (state2 as FlightSearchUiState.Results).flights
assert(results.size == 1)
assert(results[0].flightNumber == "AA100")
}
@Test
fun searchFlights_emitsErrorOnFailure() = runTest {
// Arrange
flightService.shouldFail = true
// Act
viewModel.onEvent(FlightSearchEvent.OnSearchClicked("NYC", "LAX", "2025-01-15"))
advanceUntilIdle()
// Assert
val state = viewModel.uiState.value
assert(state is FlightSearchUiState.Error)
}
}These tests run in milliseconds. You can run the entire test suite in under a second, which keeps feedback tight during development and in CI/CD pipelines.
Common mistakes we've learned to avoid
Leaking UI references into the ViewModel: Never pass a Context, Activity, or View reference into a ViewModel. They live longer than the UI and cause memory leaks. If you need a Context for something like reading SharedPreferences, wrap it in a use-case or repository.
Putting too much into state: MVVM state should be immutable and represent only what the UI needs to render. Don't store temporary variables or intermediate computation results directly in StateFlow.
Ignoring coroutine scope: Always use viewModelScope.launch for background work, not GlobalScope. When the ViewModel is cleared, viewModelScope automatically cancels pending coroutines, preventing crashes and wasted resources.
Treating UI state as a dumping ground: Each state should be a coherent snapshot. Avoid nullable fields in state objects when you can use sealed classes instead. sealed class UiState with object Loading, data class Success(data), and data class Error(message) is clearer and safer than a monolithic class with five nullable fields.
Forgetting to test error paths: It's easy to test the happy path. Test your repositories' error handling, network timeouts, and database failures to ensure the ViewModel handles them gracefully.
Key takeaways
- MVVM separates UI, logic, and data into layers; data flows from Repository through ViewModel to Compose UI.
- ViewModels survive configuration changes like rotation, preserving UI state and avoiding unnecessary network calls.
- Unidirectional data flow—events in, state out—makes debugging straightforward and scaling easier.
- ViewModels are testable without Android instrumentation because they're just Kotlin classes with no framework dependencies.
- Use sealed classes for UI state, coroutine scopes for async work, and avoid leaking UI references into ViewModels.
- MVVM is not overhead on small projects, but it pays dividends the moment you have multiple screens, deep feature work, or a team of developers handing off features.






Comments
Loading comments…