Lectures

Senior

Coroutines on Android: Compose and Testing

Coroutine ownership, lifecycle-aware Flow collection, Compose effects, virtual-time testing, and interview reasoning on Android.

androidcomposetestingcoroutinesflow
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

  1. Draw the Job hierarchy.
  2. Identify the CoroutineContext and dispatcher.
  3. Mark suspension and cancellation points.
  4. Trace failure upward.
  5. Trace cancellation downward.
  6. Find supervision boundaries.
  7. Locate the actual exception-handling boundary.

Flow analysis algorithm

  1. Is it cold or hot?
  2. Who starts upstream?
  3. Who owns collection?
  4. What happens to a slow consumer?
  5. Does every value matter or only the latest?
  6. What does a new subscriber see?
  7. Is replay required?
  8. Can expensive cold upstream work start more than once?
  9. What happens on lifecycle stop/start?
  10. 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.

Further reading