Android MVVM that stays easy to change
By Vivek Patel · Updated
MVVM separates screen rendering from screen state. On Android, a ViewModel holds state across configuration changes and delegates persistence to repositories. Compose and XML can use the same state owner, which makes UI migration less disruptive.
How the pieces connect
An illustrative folder structure
This structure explains the architecture. Exact generated paths are shown in the source walkthrough below.
app/src/main/java/com/example/app/
presentation/home/
HomeScreen.kt
HomeViewModel.kt
HomeUiState.kt
domain/repository/CounterRepository.kt
data/repository/MemoryCounterRepository.kt
di/AppContainer.ktExpose immutable screen state
Keep MutableStateFlow private and expose StateFlow. Model what the screen needs in a data class instead of exposing several unrelated booleans. A list screen may need content, refresh status, and a recoverable error together. Do not store an Activity or View in the ViewModel.
Collect with the screen lifecycle
Compose screens can collect with collectAsStateWithLifecycle. XML fragments should launch collection with viewLifecycleOwner and repeatOnLifecycle. The view lifecycle is shorter than the fragment lifecycle; binding references must be cleared when the view is destroyed.
Inject repositories, not concrete transports
The ViewModel should receive a repository interface rather than construct Retrofit or Room. Hilt, Koin, or a small manual container can supply that interface. This supports tests that control timing and failure conditions without relying on a server.
Use cases are optional coordination points
Introduce a use case when several repositories or business rules form an operation. Avoid a class per trivial getter. MVVM alone does not enforce clean dependency direction: keep data implementation imports away from presentation if that boundary matters to your team.
Inside the generated starter
Follow a Compose counter through HomeViewModel to the repository. Then replace the in-memory implementation while preserving the screen contract. The same boundary is useful for settings, saved items, or a local draft.
These source files are produced from the same templates used by the generator, with the configuration below. They are starter examples; the observations identify work still needed for a real feature.
app/src/main/java/com/example/myandroidapp/domain/repository/CounterRepository.kt
package com.example.myandroidapp.domain.repository
interface CounterRepository { suspend fun load(): Int; suspend fun save(value: Int) }
app/src/main/java/com/example/myandroidapp/presentation/home/HomeViewModel.kt
package com.example.myandroidapp.presentation.home
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import com.example.myandroidapp.domain.repository.CounterRepository
class HomeViewModel(private val repository: CounterRepository) : ViewModel() {
private val _state = MutableStateFlow(HomeUiState())
val state = _state.asStateFlow()
fun increment() { viewModelScope.launch {
try {
val next = repository.load() + 1
repository.save(next)
_state.value = HomeUiState(next)
} catch (error: CancellationException) { throw error } catch (error: Exception) { _state.value = _state.value.copy(error = "Unable to update counter") }
} }
class Factory(private val repository: CounterRepository) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T { require(modelClass.isAssignableFrom(HomeViewModel::class.java)); return HomeViewModel(repository) as T }
}
}
What to notice
HomeViewModel keeps MutableStateFlow private and exposes a read-only StateFlow. It launches repository work in viewModelScope. CancellationException is rethrown so cancellation is not converted into an ordinary user-facing failure.
The factory receives a repository explicitly. Selecting Hilt adds integration scaffolding, but this ViewModel still uses the generated factory; it is not an @HiltViewModel. Keep this path or deliberately migrate the screen and ViewModel together.
State initially contains zero and the increment operation loads from the repository. A restored screen can therefore display zero until an action runs. Add a separate initial-load path when replacing memory with persistent storage, and preserve content during recoverable errors.
Extend it and verify the result
Implement a repository that fails its first save and succeeds on retry. Make the screen show the error without discarding the count, then clear that error after a successful retry. For coroutine tests, install a test Main dispatcher and control scheduling so assertions do not race the launched operation.
- A successful action updates count and clears the old error.
- A failed save preserves the previous count.
- Cancelling the ViewModel does not become a failure message.
- Recreating the screen restores the intended repository value.
These are exercises to add to your application tests, not claims that the starter already handles every case.
Try this configuration
MVVM · Jetpack Compose · Hilt · Retrofit · StateFlow
The generator includes a working counter example with a repository contract, selected dependencies, navigation, and local setup instructions. Extend the example around your own domain before shipping.
Configure your Android project →For prerequisites, exact setup commands, expected behavior, and build troubleshooting, follow the android download-to-first-run walkthrough. Check the validation record for the configurations actually checked.
Continue reading
Primary reference
Android UI layer guidance. The source walkthrough above describes AppScaffold’s implementation; platform documentation describes the broader APIs and recommendations.