name: android-coroutines
description: "Implement and review Kotlin Coroutines on Android: configure CoroutineScopes, manage Dispatchers, expose StateFlow/SharedFlow from ViewModels, collect with repeatOnLifecycle, and convert callback APIs to callbackFlow. Use when writing suspend functions, wiring up Flow or StateFlow, fixing async bugs, replacing GlobalScope, or integrating coroutines with Android lifecycle components."
Android Coroutines Expert Skill
Workflow
- Identify scope — Determine the correct CoroutineScope (
viewModelScope, lifecycleScope, or injected applicationScope).
- Wire data layer — Expose data as
suspend functions (one-shot) or Flow (streams) with injected Dispatchers.
- Connect UI — Collect flows using
repeatOnLifecycle(Lifecycle.State.STARTED) and expose read-only StateFlow.
- Verify — Run
./gradlew test and confirm coroutine behavior. If tests fail, check: uncaught CancellationException in generic catch blocks, GlobalScope usage causing leaked coroutines, missing awaitClose in callbackFlow, or hardcoded Dispatchers breaking test determinism. Run ./gradlew detektDebug to catch structural issues.
Critical Rules
1. Dispatcher Injection (Testability)
- NEVER hardcode Dispatchers (e.g.,
Dispatchers.IO, Dispatchers.Default) inside classes.
- ALWAYS inject a
CoroutineDispatcher via the constructor.
- DEFAULT to
Dispatchers.IO in the constructor argument for convenience, but allow it to be overridden.
// CORRECT
class UserRepository(
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO
) { ... }
// INCORRECT
class UserRepository {
fun getData() = withContext(Dispatchers.IO) { ... }
}
2. Main-Safety
- All suspend functions in Data/Domain layers must be main-safe — use
withContext(dispatcher) internally.
- One-shot calls: expose as
suspend functions. Data streams: expose as Flow.
3. Lifecycle-Aware Collection
- NEVER collect a flow directly in
lifecycleScope.launch or launchWhenStarted (deprecated/unsafe).
- ALWAYS use
repeatOnLifecycle(Lifecycle.State.STARTED) for collecting flows in Activities or Fragments.
// CORRECT
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.uiState.collect { ... }
}
}
4. ViewModel Scope Usage
- Use
viewModelScope for initiating coroutines in ViewModels.
- Do not expose suspend functions from the ViewModel to the View. The ViewModel should expose
StateFlow or SharedFlow that the View observes.
5. Mutable State Encapsulation
- NEVER expose
MutableStateFlow or MutableSharedFlow publicly.
- Expose them as read-only
StateFlow or Flow using .asStateFlow() or upcasting.
6. GlobalScope Prohibition
- NEVER use
GlobalScope. It breaks structured concurrency and leads to leaks.
- If a task must survive the current scope, use an injected
applicationScope (a custom scope tied to the Application lifecycle).
7. Exception Handling
- NEVER catch
CancellationException in a generic catch (e: Exception) block without rethrowing it.
- Use
runCatching only if you explicitly rethrow CancellationException.
- Use
CoroutineExceptionHandler only for top-level coroutines (inside launch). It has no effect inside async or child coroutines.
8. Cancellability
- ALWAYS call
ensureActive() or yield() in tight loops (e.g., processing a large list, reading files).
delay() and withContext() are already cancellable — no extra checks needed there.
9. Callback Conversion
- Use
callbackFlow to convert callback-based APIs to Flow.
- ALWAYS use
awaitClose at the end of the callbackFlow block to unregister listeners.
Code Patterns
Repository Pattern with Flow
class NewsRepository(
private val remoteDataSource: NewsRemoteDataSource,
private val externalScope: CoroutineScope, // For app-wide events
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO
) {
val newsUpdates: Flow<List<News>> = flow {
val news = remoteDataSource.fetchLatestNews()
emit(news)
}.flowOn(ioDispatcher) // Upstream executes on IO
}
Parallel Execution
suspend fun loadDashboardData() = coroutineScope {
val userDeferred = async { userRepo.getUser() }
val feedDeferred = async { feedRepo.getFeed() }
// Wait for both
DashboardData(
user = userDeferred.await(),
feed = feedDeferred.await()
)
}
Testing with runTest
@Test
fun testViewModel() = runTest {
val testDispatcher = StandardTestDispatcher(testScheduler)
val viewModel = MyViewModel(testDispatcher)
viewModel.loadData()
advanceUntilIdle() // Process coroutines
assertEquals(expectedState, viewModel.uiState.value)
}