| name | android-kotlin-coroutines |
| description | Use when writing, reviewing, or debugging asynchronous Kotlin code using Coroutines and Flow in Android apps. Covers suspend functions, CoroutineScope and Job, Dispatchers, structured concurrency, exception handling, Flow operators, StateFlow, SharedFlow, Channels, lifecycle-safe collection patterns, and testing with TestCoroutineDispatcher and Turbine. Applies to AAOS Android app development.
|
| argument-hint | <class-or-function> [write|review|debug|migrate] |
Kotlin Coroutines & Flow
Expert reference for asynchronous programming with Kotlin Coroutines and Flow in
Android applications, following Roman Elizarov's
Structured Concurrency principles
and the official Kotlin Coroutines Guide.
Standards baseline: Kotlin Coroutines 1.8+ · Android Lifecycle 2.8+.
When to Use This Skill
- Writing
suspend functions or Flow producers for data/network/disk operations.
- Reviewing coroutine lifecycle — scope leaks, cancelled jobs, unconsumed exceptions.
- Migrating callbacks or RxJava streams to coroutines / Flow.
- Designing complex async pipelines (combining, debouncing, retrying flows).
- Testing coroutine-based code with
runTest, advanceUntilIdle, or Turbine.
- Choosing between StateFlow, SharedFlow, and Channel.
Fundamentals
Suspend functions
viewModelScope.launch {
val result = fetchData()
updateUi(result)
}
suspend fun fetchData(): Data = withContext(Dispatchers.IO) {
apiService.getData()
}
Rules
- Mark a function
suspend only if it suspends; otherwise it is a normal function.
- Don't mark a function
suspend just to run it on a coroutine — use withContext.
withContext does NOT create a new coroutine — it switches dispatcher within the current one.
Coroutine Builders
| Builder | Behaviour | Use case |
|---|
launch | Fire-and-forget; returns Job | Side effects (update DB, emit event) |
async | Returns Deferred<T> | Parallel work with a result |
runBlocking | Blocks calling thread | Test glue code, main() entrypoint |
coroutineScope | Suspending scope; waits for children | Group parallel work; propagates cancel |
supervisorScope | Like coroutineScope but child failures don't cancel siblings | Parallel independent tasks |
suspend fun loadDashboard(): Dashboard = coroutineScope {
val music = async { musicRepository.getNowPlaying() }
val weather = async { weatherRepository.getCurrent() }
Dashboard(music = music.await(), weather = weather.await())
}
Dispatchers
| Dispatcher | Thread pool | Best for |
|---|
Dispatchers.Main | Main (UI) thread | UI updates, collect StateFlow in ViewModel |
Dispatchers.IO | Shared thread pool (64) | Network, disk, database |
Dispatchers.Default | Shared CPU-bound pool | CPU-intensive: sorting, parsing, JSON |
Dispatchers.Unconfined | Caller thread (then resumes anywhere) | Tests; avoid in production |
Rules
- Never call
Dispatchers.IO directly — inject as CoroutineDispatcher:
@Qualifier @Retention(AnnotationRetention.BINARY) annotation class IoDispatcher
@Qualifier @Retention(AnnotationRetention.BINARY) annotation class DefaultDispatcher
@Provides @IoDispatcher fun provideIo(): CoroutineDispatcher = Dispatchers.IO
@Provides @DefaultDispatcher fun provideDefault(): CoroutineDispatcher = Dispatchers.Default
CoroutineScope & Job
Structured Concurrency rule
Every coroutine must be launched in a scope whose lifetime matches where it is used.
A leaked scope (scope that outlives its owner) is a bug.
viewModelScope
lifecycleScope
Custom scope
class MediaSyncService @Inject constructor(
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
) {
private val scope = CoroutineScope(SupervisorJob() + ioDispatcher)
fun startSync() {
scope.launch { }
}
fun stopSync() {
scope.cancel()
}
}
Rules
- Use
SupervisorJob when child failures should not cancel sibling coroutines.
- Always cancel a custom scope in the appropriate lifecycle callback (
onDestroy, onCleared).
- Never use
GlobalScope — it lives for the entire process and cancels nothing.
Exception Handling
val handler = CoroutineExceptionHandler { _, throwable ->
Log.e(TAG, "Unhandled coroutine exception", throwable)
}
viewModelScope.launch(handler) {
riskyOperation()
}
val deferred = async { riskyOperation() }
try {
deferred.await()
} catch (e: Exception) {
}
supervisorScope {
launch { fetchA() }
launch { fetchB() }
}
Rules
CancellationException must never be caught and swallowed — it signals cancellation.
- Only catch
CancellationException to do cleanup; always re-throw it.
- Prefer
Result<T> return types over throwing unchecked exceptions from suspend functions.
try {
suspendOperation()
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
}
Flow
Cold Flow (producer runs per collector)
fun fetchItems(): Flow<List<Item>> = flow {
while (true) {
emit(repository.getAll())
delay(5_000)
}
}
Key operators
itemsFlow
.filter { it.isActive }
.map { it.toDomain() }
.debounce(300)
.distinctUntilChanged()
.catch { e -> Log.e(TAG, "Error", e) }
.onEach { analytics.track(it) }
.launchIn(viewModelScope)
Combining flows
combine(userFlow, settingsFlow) { user, settings ->
DashboardState(user, settings)
}.collect { render(it) }
searchQuery
.debounce(300)
.flatMapLatest { query -> searchRepository.search(query) }
.collect { showResults(it) }
flow1.zip(flow2) { a, b -> a + b }.collect { ... }
StateFlow & SharedFlow
StateFlow — UI state holder (hot, single value)
private val _uiState = MutableStateFlow<UiState>(UiState.Loading)
val uiState: StateFlow<UiState> = _uiState.asStateFlow()
_uiState.value = UiState.Success(items)
_uiState.update { current -> current.copy(...) }
val state by viewModel.uiState.collectAsStateWithLifecycle()
SharedFlow — one-time events (hot, no cached value default)
private val _events = MutableSharedFlow<UiEvent>()
val events: SharedFlow<UiEvent> = _events.asSharedFlow()
fun sendError(msg: String) {
viewModelScope.launch { _events.emit(UiEvent.ShowSnackbar(msg)) }
}
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.events.collect { event ->
when (event) {
is UiEvent.ShowSnackbar -> showSnackbar(event.message)
is UiEvent.Navigate -> findNavController().navigate(event.destination)
}
}
}
}
Converting cold to hot (sharing upstream)
val sharedItems: StateFlow<List<Item>> = repository.observeItems()
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = emptyList(),
)
Rules
- Use
SharingStarted.WhileSubscribed(5_000) — keeps flow alive 5 s after rotation to avoid restart.
- Never use
SharingStarted.Eagerly in production — it wastes resources when no one is subscribed.
- Never expose
MutableStateFlow or MutableSharedFlow to the UI layer.
Channels
Use channels only for fan-out / fan-in patterns, not as a replacement for SharedFlow
for events.
val channel = Channel<WorkItem>(capacity = Channel.UNLIMITED)
launch { channel.send(WorkItem("task1")) }
launch { for (item in channel) process(item) }
Lifecycle-Safe Collection
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.uiState.collect { render(it) }
}
}
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
Rules
- Never collect in
lifecycleScope.launch { flow.collect { } } without repeatOnLifecycle — it keeps collecting in the background.
- Never use
launchWhenStarted — deprecated; use repeatOnLifecycle(STARTED).
callbackFlow — Convert Callbacks to Flow
fun CarPropertyManager.observeTemperature(zone: Int): Flow<Float> = callbackFlow {
val callback = object : CarPropertyManager.CarPropertyEventCallback {
override fun onChangeEvent(event: CarPropertyValue<*>) {
trySend(event.value as Float)
}
override fun onErrorEvent(propId: Int, zone: Int) {
close(IOException("Property error: $propId"))
}
}
registerCallback(callback, VehiclePropertyIds.HVAC_TEMPERATURE_SET, SENSOR_RATE_ONCHANGE)
awaitClose { unregisterCallback(callback) }
}
Testing
runTest + advanceUntilIdle
@Test
fun `emits updated state after load`() = runTest {
val viewModel = MyViewModel(fakeRepository)
viewModel.uiState.test {
assertThat(awaitItem()).isEqualTo(UiState.Loading)
assertThat(awaitItem()).isEqualTo(UiState.Success(expectedData))
cancelAndIgnoreRemainingEvents()
}
}
Dispatcher injection in tests
class MainDispatcherRule(
val dispatcher: TestCoroutineDispatcher = StandardTestDispatcher(),
) : TestWatcher() {
override fun starting(d: Description) { Dispatchers.setMain(dispatcher) }
override fun finished(d: Description) { Dispatchers.resetMain() }
}
@get:Rule val mainDispatcherRule = MainDispatcherRule()
mainDispatcherRule.dispatcher.advanceTimeBy(5_000)
mainDispatcherRule.dispatcher.advanceUntilIdle()
RxJava / Callback Migration Reference
| RxJava | Coroutines / Flow |
|---|
Observable<T> | Flow<T> |
Single<T> | suspend fun: T |
Completable | suspend fun: Unit |
Maybe<T> | suspend fun: T? |
BehaviorSubject<T> | MutableStateFlow<T> |
PublishSubject<T> | MutableSharedFlow<T> |
subscribeOn(io) | withContext(Dispatchers.IO) |
observeOn(main) | withContext(Dispatchers.Main) |
switchMap | flatMapLatest |
merge | merge(flow1, flow2) |
zip | zip or combine |
Prerequisites
- Android Studio (Flamingo or newer) or AOSP build environment set up.
- Android SDK Platform-Tools installed (
adb on PATH).
- Target device or emulator running Android 11+ (API 30+).
- For AOSP modules:
repo tool, AOSP source synced, lunch target configured.
Step-by-Step Workflows
Step 1: Add coroutines dependencies
Add kotlinx-coroutines-android and kotlinx-coroutines-core to build.gradle.kts.
Step 2: Launch coroutines in the correct scope
Use viewModelScope in ViewModels; lifecycleScope in Fragments/Activities.
Step 3: Switch dispatchers for blocking work
Use Dispatchers.IO for I/O operations; Dispatchers.Default for CPU-intensive tasks.
Step 4: Collect Flows lifecycle-safely
Use repeatOnLifecycle(Lifecycle.State.STARTED) — never lifecycleScope.launch for UI collection.
Step 5: Test coroutines
Use TestCoroutineScheduler + runTest {} for deterministic tests; Turbine for Flow assertions.
Troubleshooting
CancellationException swallowing — never catch bare Exception in coroutines; always rethrow CancellationException.
- Flow not updating UI — collection must be inside
repeatOnLifecycle(STARTED); using lifecycleScope.launch directly does not stop on background.
SharedFlow dropped events — replay = 0 means new collectors miss past events; set replay = 1 or use StateFlow for last-value semantics.
- Tests are flaky with coroutines — use
TestCoroutineScheduler and advanceUntilIdle(); never use Thread.sleep() in coroutine tests.
Pre-Commit Checklist
References