Senior
Coroutines on Android: Compose and Testing
Coroutine ownership, lifecycle-aware Flow collection, Compose effects, virtual-time testing, and interview reasoning on Android.
On this page
Apply coroutine semantics to Android ownership, Compose effects, lifecycle observation, and deterministic tests. All supplied snippets remain read-only Android or coroutine/test fragments; none was compiled or executed during preparation. Imports, runtime dependencies, and experimental opt-ins belong to the project where a fragment is used.
Learning objectives:
- trace ownership and main safety across UI and data layers;
- choose lifecycle-aware collection and correctly scoped effects;
- distinguish state, transient events, and durable work;
- control coroutine tests and explain interview cases causally.
Theory
Architecture and data flow
Compose sends events to a ViewModel, which calls use cases or repositories backed by network, database, and cache. Observable data returns through repository Flow and ViewModel StateFlow to Compose. Trace work ownership in both directions.
Compose
│ events
▼
ViewModel
│ suspend / Flow
▼
UseCase
│
▼
Repository
│
├── Network
├── Database
└── Cache
Repository
│
Flow
▼
ViewModel
│
StateFlow<UiState>
▼
Compose
Who creates the coroutine?
Launch at the layer that owns the operation’s lifecycle. A repository commonly exposes suspend functions or Flow; its caller chooses when and where to launch the operation.
suspend fun save(data: Data)
viewModelScope.launch {
repository.save(data)
}
Official reference: Android coroutine best practices.
Why GlobalScope in a repository is problematic
The supplied detached save is a deliberately bad example. The caller loses natural control over awaiting, canceling, handling failure, and binding work to its lifecycle.
fun save(data: Data) {
GlobalScope.launch {
api.save(data)
}
}
A main-safe repository
Hide genuinely blocking implementation work behind the data layer’s dispatcher boundary. A ViewModel should not micromanage file/socket implementation details. Inject the dispatcher so tests can replace it.
class Repository(
private val ioDispatcher: CoroutineDispatcher,
) {
suspend fun readFile(): Data =
withContext(ioDispatcher) {
blockingFileRead()
}
}
Not every suspend API needs IO
An already asynchronous non-blocking HTTP API normally suspends while waiting. Adding withContext(IO) merely because a function is suspend or performs networking is not automatically useful.
@GET("venue")
suspend fun venue(): VenueDto
ViewModel and StateFlow
Keep a mutable backing StateFlow private, expose its read-only view, and update state atomically. UiState can represent loading, data, and error; the ViewModel owns the screen’s state pipeline.
data class UiState(
val loading: Boolean = false,
val data: Data? = null,
val error: Throwable? = null,
)
private val _state =
MutableStateFlow(UiState())
val state =
_state.asStateFlow()
_state.update {
it.copy(loading = true)
}
Official reference: StateFlow API.
Collecting state in Compose
collectAsStateWithLifecycle bridges a Flow or StateFlow into Compose State with lifecycle-aware collection. A state read during Composition can then register a dependency and lead to recomposition.
val state by viewModel.state.collectAsStateWithLifecycle()
StateFlow
↓
collectAsStateWithLifecycle
↓
Compose State
↓
Recomposition
Official reference: Compose state collection.
repeatOnLifecycle
The block starts at the requested Lifecycle state, is canceled below it, and starts again on return. Bind UI observation to this repeated lifecycle boundary instead of leaving unnecessary collection running.
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.state.collect {
render(it)
}
}
}
Official reference: Lifecycle-aware coroutines.
Several flows in one lifecycle block
Two direct infinite collect calls are sequential: the first prevents the second from starting. The first snippet is deliberately incorrect for parallel observation. Launch one child for each independent collection inside the lifecycle block.
repeatOnLifecycle(STARTED) {
flowA.collect { ... }
flowB.collect { ... }
}
repeatOnLifecycle(STARTED) {
launch {
flowA.collect { ... }
}
launch {
flowB.collect { ... }
}
}
LaunchedEffect
LaunchedEffect starts a composition-bound coroutine. A changed key cancels the old effect and starts a new one. A user ID key expresses a declarative dependency, but business operations still need a clear owner.
LaunchedEffect(key) {
...
}
LaunchedEffect(userId) {
viewModel.load(userId)
}
Official reference: Compose side effects.
rememberCoroutineScope
Use a remembered composition-bound scope for imperative callback work such as hiding a sheet. It complements a declarative LaunchedEffect rather than being an interchangeable trigger mechanism.
val scope = rememberCoroutineScope()
Button(
onClick = {
scope.launch {
sheetState.hide()
}
}
)
LaunchedEffect
→ declarative side effect
rememberCoroutineScope
→ imperative launch from a callback
Official reference: Compose side effects.
Do not launch directly from Composition
The supplied direct launch in a composable body is deliberately bad. Recomposition can create repeated launches. Use an appropriate effect API, an event callback scope, or ViewModel ownership.
@Composable
fun Screen() {
scope.launch {
load()
}
}
UI work and business work
Composition-bound scopes fit animation, drawers, snackbars, scrolling, and sheets. Longer-lived screen business work commonly belongs to the ViewModel, according to its actual required lifetime.
Events require delivery semantics
Ask what happens if an event is emitted while UI is stopped. Loss may or may not be acceptable. SharedFlow is not inherently a durable queue; replay and buffering need explicit requirements.
private val _events =
MutableSharedFlow<UiEvent>()
val events =
_events.asSharedFlow()
Official reference: SharedFlow API.
When state is better than an event
If information describes durable current state, represent it as state. For example, a success state can survive recreation more reliably than a transient navigation request that must be observed at the exact moment.
Racing requests
Loading A and then B does not ensure B wins. B can finish first and a late A can overwrite the newer state. Identify stale-result handling before adding concurrent launches.
fun load(id: String) {
viewModelScope.launch {
val user = repository.load(id)
_state.update {
it.copy(user = user)
}
}
}
load(A)
load(B)
Latest-result semantics
Imperatively cancel the previous Job or model selection declaratively with flatMapLatest. A changed selected ID then cancels the old observation. Cancellation must cooperate with the underlying work.
private var loadJob: Job? = null
fun load(id: String) {
loadJob?.cancel()
loadJob = viewModelScope.launch {
...
}
}
selectedId
.flatMapLatest { id ->
repository.observe(id)
}
Official reference: flatMapLatest API.
A declarative search pipeline
Debounce, adjacent-duplicate suppression, mapLatest, and sharing express search semantics without manually storing and canceling Jobs. The timeout and delivery policy remain product decisions.
query
.debounce(300)
.distinctUntilChanged()
.mapLatest { query ->
repository.search(query)
}
.stateIn(...)
Official reference: mapLatest API.
runTest
runTest supplies a coroutine test environment with scheduler-managed virtual time. It is designed for suspending tests rather than using a blocking bridge as a substitute for deterministic test scheduling.
@Test
fun loadsUser() = runTest {
val user = repository.load()
assertEquals(expected, user)
}
Official reference: runTest API.
Virtual time
A delay scheduled on the test scheduler need not wait the same wall-clock duration. Delays on unrelated real dispatchers are not automatically skipped; inject test dispatchers to make scheduling predictable.
@Test
fun test() = runTest {
delay(10_000)
}
Official reference: Testing coroutines.
Test dispatchers
StandardTestDispatcher queues work for controlled scheduler execution. UnconfinedTestDispatcher enters some new coroutines eagerly, but does not guarantee eager completion after suspension. Choose intentionally and share one scheduler across the test’s dispatchers.
StandardTestDispatcher
UnconfinedTestDispatcher
Official reference: StandardTestDispatcher API.
advanceUntilIdle
Run scheduled work until the scheduler is idle before asserting resulting state. A deliberately endless producer still needs an explicit lifecycle or finite test strategy.
viewModel.load()
advanceUntilIdle()
assertEquals(
expected,
viewModel.state.value,
)
Official reference: advanceUntilIdle API.
advanceTimeBy
Move virtual time for debounce, timeout, retry, and delayed-event assertions. Tasks exactly at the destination time are not executed by this operation alone; use runCurrent when the assertion requires those boundary tasks.
advanceTimeBy(1000)
Official reference: advanceTimeBy API.
Injecting dispatchers in tests
Construct StandardTestDispatcher with the shared testScheduler and inject it into production objects. This makes their dispatched operations controlled by the same test scheduler.
val dispatcher =
StandardTestDispatcher(testScheduler)
val repository =
Repository(dispatcher)
Main in plain JVM tests
A plain JVM test has no Android Main Looper. A MainDispatcherRule can replace Dispatchers.Main with a TestDispatcher for the test and reset it afterward.
Official reference: Replacing Main in tests.
Testing Flow
For a finite observation, take the required values and collect them into a list. Infinite or hot-stream collectors need careful ownership and cancellation so a test does not wait forever.
@Test
fun flowEmitsValues() = runTest {
val values =
repository.observe()
.take(3)
.toList()
assertEquals(
listOf(1, 2, 3),
values,
)
}
Official reference: Testing flows.
Testing StateFlow
If only final state is the contract, assert value after scheduled work settles. If Loading to Content itself is the contract, collect the emissions with appropriate scheduler control and account for conflation.
viewModel.load()
advanceUntilIdle()
assertEquals(
UiState.Content(expected),
viewModel.state.value,
)
Test semantics, not implementation calls
Assert what the user operation means: given a repository result and a load request, the state becomes Loading then Content. Private launch calls or incidental dispatcher switches are usually weaker contracts.
Given repository returns X
When user requests load
Then state becomes Loading → Content(X)
Practice
Analyze each supplied case, state its intended behavior, and identify the ownership or delivery invariant before experimenting.
Senior case: dispatcher management
Purpose: review the two loading implementations. Prefer a main-safe repository over forcing the ViewModel to know data-layer dispatchers and manually switch back for each result. Explain which layer owns blocking implementation details.
fun load() {
viewModelScope.launch(Dispatchers.IO) {
val user = api.user()
withContext(Dispatchers.Main) {
_state.value = UiState.User(user)
}
}
}
viewModelScope.launch {
val user = repository.user()
_state.value = UiState.User(user)
}
Interview: execution order
Purpose: trace scheduling. For the supplied ordinary runBlocking event-loop example, A and D execute before the child prints B, followed by C after its delay. Explain why; the shown sequence is not a universal promise for arbitrary dispatchers or launch options.
runBlocking {
println("A")
launch {
println("B")
delay(100)
println("C")
}
println("D")
}
A
D
B
C
coroutineScope waits for children
Purpose: trace completion. In the supplied example D follows B because coroutineScope cannot return while its child remains active. Explain the Job relationship as well as the printed sequence.
runBlocking {
println("A")
coroutineScope {
launch {
delay(100)
println("B")
}
println("C")
}
println("D")
}
A
C
B
D
Failure inside coroutineScope
Purpose: trace failure. The first child fails, the scope cancels the second child, and the exception leaves coroutineScope to its caller’s catch. Finished is not reached in this scenario. The supplied broad catch demonstrates the boundary; production code should rethrow CancellationException when handling other failures.
viewModelScope.launch {
try {
coroutineScope {
launch {
delay(100)
error("Boom")
}
launch {
delay(1000)
println("Finished")
}
}
} catch (e: Exception) {
println("Caught")
}
}
Child 1 fails
↓
scope cancelled
↓
Child 2 cancelled
↓
failure leaves coroutineScope
↓
catch
The supervisor variant
Purpose: compare the sibling boundary. The first child’s failure does not cancel the second sibling, so it can finish. The uncaught exception still needs a handling or reporting boundary; supervision alone does not guarantee an Android process continues after an uncaught root failure.
supervisorScope {
launch {
error("Boom")
}
launch {
delay(1000)
println("Finished")
}
}
The runCatching case
Purpose: find swallowed cancellation. runCatching catches Throwable and can convert cancellation into Result.failure. Explicitly rethrow CancellationException when mapping suspend-operation failures.
suspend fun load(): Result<Data> =
runCatching {
api.load()
}
An ad-hoc scope
Purpose: review ownership. A newly created CoroutineScope in a method can detach work from its caller. Ask who owns and cancels it, where failure is observed, and whether the caller should await a result.
fun load() {
CoroutineScope(Dispatchers.IO).launch {
repository.load()
}
}
Sequential async
Purpose: compare two shapes. Starting and immediately awaiting A before starting B is sequential. Independent operations can overlap if both are started before awaiting either result.
val a = async { requestA() }.await()
val b = async { requestB() }.await()
val a = async { requestA() }
val b = async { requestB() }
val resultA = a.await()
val resultB = b.await()
Sometimes sequential calls are correct
Purpose: recognize a real dependency. Loading permissions with a user ID depends on loading the user first. Sequential code accurately expresses that dependency; async is unnecessary.
val user = loadUser()
val permissions =
loadPermissions(user.id)
A slow Flow consumer
Purpose: choose delivery requirements. Keep every value with collect, overlap producer/consumer with buffer, skip intermediate values with conflate, or cancel obsolete handling with collectLatest. A slow collector is a semantic choice before it is a performance issue.
flow.collect {
delay(5000)
render(it)
}
every value matters → collect
latest work matters → collectLatest
intermediate values may be skipped → conflate
decouple the producer → buffer
Search Flow
Purpose: review a query collector. Debounce, distinctUntilChanged, and mapLatest can avoid redundant searches and cancel obsolete transformations. Explain the tradeoff from the intended latest-query behavior.
query.collect { query ->
repository.search(query)
}
query
.debounce(300)
.distinctUntilChanged()
.mapLatest { query ->
repository.search(query)
}
StateFlow and SharedFlow
StateFlow represents a current value that a new subscriber receives. SharedFlow is hot broadcast with configurable replay and buffering. This is more precise than simply assigning StateFlow to UI and SharedFlow to events.
Official reference: StateFlow and SharedFlow.
Flow and suspend functions
Use a suspend function for one asynchronous result and Flow for ongoing changes. Refreshing bookings and observing bookings are different contracts and can coexist in one repository.
one asynchronous result
↓
suspend
stream of changes
↓
Flow
suspend fun refreshBookings()
fun observeBookings(): Flow<List<Booking>>
Flow and Channel
Channel is a communication primitive with send/receive and queue or rendezvous semantics. Flow is a stream abstraction for transformation, combination, and collection. Choose Channel or SharedFlow for events from delivery requirements.
Channel
→ communication primitive
→ send / receive
→ queue / rendezvous
Flow
→ stream abstraction
→ transform / combine / collect
Official reference: Channel API.
Who owns this work?
Ask this first in code review. UI animation can belong to a composable, screen work to a ViewModel, session observation to an application scope, and a short repository operation to its caller. Work that must resume after process death needs durable scheduling.
UI animation
→ Composable
Screen operation
→ ViewModel
App-wide session observation
→ application-level scope
Short repository operation
→ caller
Guaranteed work across process death
→ durable scheduler, for example WorkManager
Coroutines do not survive process death
Even an applicationScope upload stops when its Android process is killed. Work that must be persisted and resumed needs a durable scheduling mechanism such as WorkManager, rather than merely a longer-lived in-process scope.
applicationScope.launch {
uploadCriticalData()
}
Official reference: WorkManager overview.
Final comparison checklist
Explain every pair, including lifecycle, failure, delivery, and scheduling implications.
| A | B |
|---|---|
| Coroutine | Thread |
| suspension | blocking |
launch |
async |
coroutineScope |
supervisorScope |
Job |
SupervisorJob |
| failure | cancellation |
delay |
Thread.sleep |
Dispatchers.IO |
Dispatchers.Default |
collect |
collectLatest |
buffer |
conflate |
combine |
zip |
map |
mapLatest |
flatMapConcat |
flatMapMerge |
flatMapMerge |
flatMapLatest |
| cold Flow | hot Flow |
StateFlow |
SharedFlow |
stateIn |
shareIn |
| Flow | suspend |
| Flow | Channel |
lifecycleScope |
viewModelScope |
LaunchedEffect |
rememberCoroutineScope |
runBlocking |
runTest |
Coroutine analysis algorithm
- Draw the Job hierarchy.
- Identify the CoroutineContext and dispatcher.
- Mark suspension and cancellation points.
- Trace failure upward.
- Trace cancellation downward.
- Find supervision boundaries.
- Locate the actual exception-handling boundary.
Flow analysis algorithm
- Is it cold or hot?
- Who starts upstream?
- Who owns collection?
- What happens to a slow consumer?
- Does every value matter or only the latest?
- What does a new subscriber see?
- Is replay required?
- Can expensive cold upstream work start more than once?
- What happens on lifecycle stop/start?
- What happens on cancellation?
The combined mental map
Coroutine
│
┌────────────────┼────────────────┐
│ │ │
Lifecycle Execution Data
│ │ │
Job Dispatcher Flow
│ │ │
Parent/Child Main/IO/ Cold / Hot
│ Default │
Cancellation ┌───┴───┐
│ │ │
Exceptions StateFlow SharedFlow
│
Supervision
Underneath these concepts:
suspend
↓
Continuation
↓
state machine
Derive behavior from Job hierarchy, structured concurrency, cancellation semantics, and Flow semantics instead of memorizing isolated calls.