Android development / Intermediate: state, data, and inclusive features / Lesson 7
ViewModel, structured coroutines, and Flow
Create a screen state pipeline whose ownership, cancellation, and error behavior remain understandable during asynchronous updates.
On this page
Lesson goals
- Expose immutable screen state from a ViewModel.
- Distinguish suspension, dispatching, collection, and durable work.
Give business state an owner
A ViewModel owns screen-level state and operations for a specific lifecycle owner. It survives configuration changes for that owner and is cleared when the owner is permanently removed. It should not retain an activity, composable, or view reference. A repository supplies data operations; the ViewModel transforms them into information a screen can render. Expose read-only StateFlow while keeping mutation private. This makes the possible states visible and prevents a UI element from changing shared state without passing through an intentional operation.
Scope work and preserve cancellation
A coroutine runs within a scope whose cancellation controls its children. viewModelScope is useful for screen operations that should outlive a particular composition but stop when the ViewModel is cleared. A suspend function can suspend without blocking its caller, but the suspend keyword does not move blocking code to another thread. Make repository operations main-safe by using suitable dispatchers around blocking work. Catch expected failures narrowly, and rethrow CancellationException when a broad boundary must catch exceptions; cancellation is control flow rather than a user-visible server error.
data class ShelfState(val titles: List<String> = emptyList(), val failed: Boolean = false)
interface ShelfRepository { suspend fun loadTitles(): List<String> }
class ShelfViewModel(private val repository: ShelfRepository) : ViewModel() {
private val _state = MutableStateFlow(ShelfState())
val state: StateFlow<ShelfState> = _state.asStateFlow()
fun refresh() {
viewModelScope.launch {
try { _state.value = ShelfState(repository.loadTitles()) }
catch (e: CancellationException) { throw e }
catch (e: IOException) { _state.update { it.copy(failed = true) } }
}
}
}
Import lifecycle ViewModel/viewModelScope, kotlinx.coroutines and flow APIs, and java.io.IOException. Supply the repository through a ViewModel factory or DI. This minimal example preserves previous content on I/O failure; production state should also model loading and overlapping-refresh policy.
Separate streams from current state
A cold Flow generally starts its producer for each collection. StateFlow is hot and holds a current value, so a new collector receives the latest state. stateIn can share an upstream flow in a chosen scope with an explicit start policy. Understand that stopping collection can stop upstream work under a while-subscribed policy. Do not assume every emission is an event that must be delivered exactly once: state can be conflated. A command such as confirming a purchase needs a durable business contract beyond a transient UI stream.
Observe with the screen lifecycle
Collect StateFlow using the Android lifecycle-aware Compose adapter rather than launching an unbounded collector in the body. The collected value becomes Compose state and drives a stateless screen. Place one-time initialization in the state owner or a deliberately keyed effect, rather than invoking refresh during every render. When a user changes a search query rapidly, operators such as debounce and flatMapLatest can cancel obsolete searches, but cancellation does not replace server-side consistency or prevent a write already accepted by a backend.
@Composable
fun ShelfRoute(viewModel: ShelfViewModel) {
val state by viewModel.state.collectAsStateWithLifecycle()
ShelfScreen(
titles = state.titles,
showError = state.failed,
onRetry = viewModel::refresh
)
}
Add a compatible lifecycle-runtime-compose dependency and import collectAsStateWithLifecycle and the Compose getValue delegate. ShelfScreen is the appβs stateless renderer. This route does not start a refresh automatically; its owner decides initial loading policy.
Decide what overlapping operations mean
Two refreshes can finish in the opposite order from their start times. Choose whether to cancel the previous request, serialize requests, or discard stale results by generation. Atomic StateFlow update prevents a local read-modify-write race, but cannot alone resolve request ordering. Inject dispatchers and dependencies so tests can control time and outcomes. Finally, classify work by durability: saving a confirmed operation that must continue after the process exits belongs in durable storage and an appropriate scheduler, not a longer-lived global coroutine invented to escape lifecycle cancellation.
Common errors and fixes
A suspend function still freezes scrolling
- Why it happens
- Blocking work runs on the main dispatcher.
- How to fix it
- Move that blocking implementation to an injected suitable dispatcher.
- Prevent it next time
- Review main-safety at repository boundaries.
Canceled searches appear as failures
- Why it happens
- A broad catch swallowed CancellationException.
- How to fix it
- Rethrow cancellation and map only relevant failures.
- Prevent it next time
- Test cancellation independently from network errors.
Older results overwrite a newer query
- Why it happens
- Concurrent requests have no ordering policy.
- How to fix it
- Cancel obsolete reads or reject stale generations.
- Prevent it next time
- Test deliberately reversed completion order.
Check your understanding
1. A repository returns a cold Flow performing a database query, and two UI collectors subscribe. Explain the likely producer behavior and a sharing decision.
Show worked solution
Each collection can start its own producer. If the screen needs one shared current state, use stateIn in the ViewModel with an intentional start policy and initial state. Keep the scope aligned with screen ownership, and test what happens when subscribers disappear and return.
2. A user confirms an upload and immediately closes the app. Is viewModelScope sufficient? Design the ownership handoff.
Show worked solution
No. Persist the operation and its stable identity before claiming it is queued, then schedule suitable durable work. The UI observes status from storage. A ViewModel coroutine may initiate that handoff, but its in-memory job is not the durable record and cannot guarantee progress after process death.
Key takeaways
- Suspension and thread switching are different decisions.
- State streams need explicit scope and restart semantics.
- Cancellation, ordering, and durability require separate policies.