Android Coroutines and Flow: A Detailed Guide
Android Coroutines and Flow: A Detailed Guide
Introduction
Asynchronous programming is unavoidable on Android: network calls, database queries, file I/O, and animations all happen off the main thread. Historically, Android developers wrestled with callbacks, AsyncTask, RxJava, and thread handlers. Kotlin Coroutines and Flow provide a modern, lightweight, and structured alternative that is now the de facto standard in Android development.
This article covers coroutines fundamentals, structured concurrency, dispatchers, cancellation, error handling, Flow for cold streams, StateFlow/SharedFlow for hot streams, integration with Room and Retrofit, and best practices for production code.
1. What Are Coroutines?
1.1 Definition
A coroutine is a suspendable computation. Unlike a thread, it does not block the underlying thread while suspended — it yields control, allowing other coroutines to run on the same thread. Coroutines are cheap: you can launch hundreds of thousands of them concurrently without exhausting memory.
1.2 Suspending Functions
A function marked suspend can pause and resume without blocking the thread.
suspend fun fetchUser(id: String): User {
delay(1000) // non-blocking suspension
return User(id, "Alice")
}
delay is a suspending function — it does not sleep the thread. The thread is free to do other work during the delay.
1.3 Why Not Threads?
- Creating a thread costs ~1 MB of stack memory.
- Context switching between threads is expensive.
- Threads cannot be suspended mid-execution and resumed later without complex synchronization.
Coroutines run on a thread pool and multiplex many coroutines onto few threads, achieving concurrency with minimal overhead.
2. Coroutine Builders and Scopes
2.1 CoroutineScope
Every coroutine runs inside a CoroutineScope. The scope controls the lifetime of all coroutines launched within it. When the scope is cancelled, all child coroutines are cancelled too.
2.2 launch
launch starts a fire-and-forget coroutine that returns a Job. Use it when you do not need a result.
scope.launch {
val data = fetchData()
updateUi(data)
}
2.3 async
async starts a coroutine that produces a result, accessed via await(). Use it for parallel decomposition.
suspend fun loadDashboard(): Dashboard = coroutineScope {
val profile = async { fetchProfile() }
val feed = async { fetchFeed() }
val notifications = async { fetchNotifications() }
Dashboard(profile.await(), feed.await(), notifications.await())
}
All three requests run concurrently. await() suspends until each result is ready.
2.4 runBlocking
runBlocking bridges the blocking and suspending worlds. It blocks the current thread until the coroutine completes. Use it in main functions and unit tests — never in production Android code.
fun main() = runBlocking {
val result = fetchUser("1")
println(result)
}
2.5 withContext
withContext switches the coroutine to a different dispatcher and returns a result. It is the idiomatic way to hop between threads.
suspend fun processImage(bitmap: Bitmap): Bitmap {
return withContext(Dispatchers.Default) {
heavyFilter(bitmap)
}
}
3. Dispatchers
3.1 The Built-in Dispatchers
| Dispatcher | Use Case |
|---|---|
Dispatchers.Main |
UI work on the main thread (Android UI toolkit access). |
Dispatchers.Default |
CPU-intensive work (parsing, sorting, image processing). |
Dispatchers.IO |
Blocking I/O (network, database, file system). |
Dispatchers.Unconfined |
Advanced; runs the coroutine on the current thread until first suspension. Rarely needed. |
3.2 Choosing a Dispatcher
- UI updates:
Dispatchers.Main - Network/DB:
Dispatchers.IO(Room and Retrofit with suspend functions handle this automatically, but explicit use is safe.) - Math/heavy computation:
Dispatchers.Default
3.3 Custom Dispatchers
For fine-grained control, create a dispatcher backed by a named thread pool:
val singleThreadDispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher()
Remember to shut it down when no longer needed to avoid thread leaks.
4. Structured Concurrency
4.1 The Principle
Structured concurrency means coroutines form a parent-child hierarchy. A parent cannot complete until all its children complete. Cancelling a parent cancels all children. This prevents coroutine leaks — a common bug where coroutines outlive the component that launched them.
4.2 coroutineScope vs supervisorScope
coroutineScope { }: If any child fails, the entire scope fails and all siblings are cancelled.supervisorScope { }: A child failure does not cancel siblings. Use when children are independent.
suspend fun syncAll() = supervisorScope {
val a = async { syncContacts() }
val b = async { syncCalendar() }
// if syncContacts throws, syncCalendar still completes
a.await(); b.await()
}
4.3 Android Lifecycle Scopes
The lifecycle-runtime-ktx library provides two ready-made scopes:
lifecycleScope(on aLifecycleOwner): cancelled when the lifecycle reachesDESTROYED.viewModelScope(on aViewModel): cancelled when the ViewModel is cleared.
class MyViewModel : ViewModel() {
fun load() {
viewModelScope.launch {
val data = repository.fetch()
_uiState.value = data
}
}
}
viewModelScope is the most common scope in Android UI-layer code. It guarantees that in-flight work is cancelled when the user navigates away, preventing UI updates to a destroyed screen.
5. Cancellation
5.1 Cooperative Cancellation
Coroutines are cancelled cooperatively. A suspension point checks for cancellation and throws CancellationException. CPU-bound loops that do not suspend must check manually:
suspend fun processLargeList(items: List<Item>) {
for (item in items) {
ensureActive() // throws CancellationException if cancelled
process(item)
}
}
Alternatively, use yield() which both checks cancellation and yields control to other coroutines.
5.2 Cleanup with finally
Use try/finally to release resources on cancellation. finally blocks run even on CancellationException.
suspend fun downloadFile(url: String): File {
val connection = openConnection(url)
try {
return writeToFile(connection.inputStream)
} finally {
connection.close()
}
}
5.3 NonCancellable
If you must perform a suspending operation during cleanup that should not itself be cancelled, wrap it in withContext(NonCancellable):
try {
doWork()
} finally {
withContext(NonCancellable) {
rollbackTransaction()
}
}
6. Error Handling
6.1 try/catch
Suspending functions propagate exceptions normally. Wrap calls in try/catch:
viewModelScope.launch {
try {
val user = repository.fetchUser()
_state.value = Success(user)
} catch (e: IOException) {
_state.value = Error("Network failure")
} catch (e: HttpException) {
_state.value = Error("Server error: ${e.code()}")
}
}
6.2 CoroutineExceptionHandler
For fire-and-forget coroutines where you want a global handler:
val handler = CoroutineExceptionHandler { _, throwable ->
Log.e("Coroutine", "Uncaught", throwable)
crashReporter.report(throwable)
}
scope.launch(handler) {
riskyOperation()
}
This only works for uncaught exceptions in launch (not async, since async defers exception delivery to await()).
6.3 Result and Either Wrappers
For explicit error modeling without exceptions, use kotlin.Result or a sealed Either type:
sealed class ApiResult<out T> {
data class Success<T>(val data: T) : ApiResult<T>()
data class Failure(val message: String) : ApiResult<Nothing>()
}
suspend fun fetchUser(): ApiResult<User> = try {
ApiResult.Success(api.getUser())
} catch (e: Exception) {
ApiResult.Failure(e.message ?: "Unknown error")
}
This forces the caller to handle both branches, eliminating unhandled-exception bugs.
7. Flow: Cold Async Streams
7.1 What Is Flow?
Flow<T> is a cold asynchronous stream of values. Cold means the producer code does not run until a collector collects. Each collection runs the producer independently.
fun numbers(): Flow<Int> = flow {
for (i in 1..3) {
delay(500)
emit(i)
}
}
// Usage
numbers().collect { println(it) } // prints 1, 2, 3 with 500ms gaps
7.2 Flow Builders
flow { emit(...) }: Manual emission.flowOf(1, 2, 3): Fixed values.asFlow(): Convert a collection or range.channelFlow { send(...) }: For flows that need concurrency between producer and consumer.
7.3 Flow Operators
fun observePrices(): Flow<Double> = flow {
var price = 100.0
while (true) {
delay(1000)
price += (Math.random() - 0.5) * 5
emit(price)
}
}
observePrices()
.filter { it > 0 }
.map { "%.2f".format(it) }
.distinctUntilChanged()
.take(10)
.collect { println("Price: $it") }
Key operators:
map,filter,flatMapLatest,flatMapConcat— transformation.debounce,distinctUntilChanged— deduplication.take,drop— slicing.combine,zip— combining flows.catch,retry,retryWhen— error handling.flowOn— dispatcher for upstream.
7.4 flowOn
flowOn shifts the upstream execution to a different dispatcher:
flow {
emit(readFromDisk()) // runs on IO
}.flowOn(Dispatchers.IO).collect { value ->
updateUi(value) // runs on the collector's dispatcher
}
7.5 Terminal Operators
collect is the terminal operator that starts the flow. Others:
toList()/toSet()— collect into a collection.first()/last()— take a single value.single()— expect exactly one value.count()— count emissions.fold()— reduce.
8. StateFlow and SharedFlow: Hot Streams
8.1 Hot vs Cold
A hot stream emits values regardless of whether anyone is collecting. Late collectors receive only future emissions (or the current state, for StateFlow).
8.2 StateFlow
StateFlow<T> always has a current value and conflates emissions — if multiple values are set rapidly, only the latest is delivered. It is the standard way to expose UI state from a ViewModel.
class UserViewModel : ViewModel() {
private val _uiState = MutableStateFlow<UiState>(UiState.Loading)
val uiState: StateFlow<UiState> = _uiState.asStateFlow()
fun load() {
viewModelScope.launch {
_uiState.value = UiState.Loading
try {
val user = repository.fetch()
_uiState.value = UiState.Success(user)
} catch (e: Exception) {
_uiState.value = UiState.Error(e.message)
}
}
}
}
8.3 Converting Flow to StateFlow
val users: StateFlow<List<User>> = repository.observeUsers()
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = emptyList()
)
WhileSubscribed(5000) keeps the upstream active while there is at least one collector, and keeps it alive for 5 seconds after the last collector disappears (to tolerate configuration changes).
8.4 SharedFlow
SharedFlow<T> is a more general hot stream — it has no initial value and supports replay buffers and buffering strategies.
private val _events = MutableSharedFlow<UiEvent>()
val events: SharedFlow<UiEvent> = _events.asSharedFlow()
fun showSnackbar(message: String) {
_events.tryEmit(UiEvent.Snackbar(message))
}
Use SharedFlow for one-shot events (snackbars, navigation, toasts) that should not be conflated or replayed to new collectors.
8.5 Replay Buffer
MutableSharedFlow<Int>(replay = 3)
A replay of 3 means new collectors immediately receive the last 3 emitted values, then continue receiving new ones.
9. Collecting Flows on Android
9.1 collectAsState in Compose
In Jetpack Compose:
@Composable
fun UserScreen(viewModel: UserViewModel = viewModel()) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
when (uiState) {
is UiState.Loading -> CircularProgressIndicator()
is UiState.Success -> UserContent((uiState as UiState.Success).user)
is UiState.Error -> ErrorText((uiState as UiState.Error).message)
}
}
collectAsStateWithLifecycle automatically stops collecting when the lifecycle drops below STARTED, saving resources when the app is backgrounded.
9.2 collect in Fragments
viewLifecycleOwner.lifecycleScope.launch {
viewModel.uiState
.flowWithLifecycle(viewLifecycleOwner.lifecycle, Lifecycle.State.STARTED)
.collect { state -> render(state) }
}
flowWithLifecycle pauses collection when the lifecycle drops below the given state, preventing UI updates to a non-visible fragment.
10. Integration with Room
Room’s @Query methods can return Flow, enabling reactive database observation:
@Dao
interface UserDao {
@Query("SELECT * FROM users ORDER BY name")
fun observeAll(): Flow<List<User>>
}
Every write to the users table causes the flow to re-emit with fresh data. Combine this with stateIn in the ViewModel for a fully reactive UI that updates automatically on data changes — no manual refresh logic.
11. Integration with Retrofit
Retrofit supports suspend functions natively:
interface UserApi {
@GET("users/{id}")
suspend fun getUser(@Path("id") id: String): User
}
Call from a coroutine scope; exceptions (IOException, HttpException) propagate normally and can be caught with try/catch.
For streaming responses, return Flow:
@GET("events")
fun events(): Flow<ServerEvent>
12. Testing Coroutines and Flow
12.1 The Test Dispatcher
Use kotlinx-coroutines-test to control virtual time:
@OptIn(ExperimentalCoroutinesApi::class)
class UserViewModelTest {
@get:Rule
val mainDispatcherRule = MainDispatcherRule()
@Test
fun load_success_emitsSuccessState() = runTest {
val viewModel = UserViewModel(FakeRepository())
viewModel.load()
advanceUntilIdle()
val state = viewModel.uiState.value
assertTrue(state is UiState.Success)
}
}
runTest replaces real dispatchers with a StandardTestDispatcher and provides virtual time — delay(1000) completes instantly.
12.2 A Main Dispatcher Rule
@OptIn(ExperimentalCoroutinesApi::class)
class MainDispatcherRule : TestWatcher() {
private val dispatcher = UnconfinedTestDispatcher()
override fun starting(description: Description) {
Dispatchers.setMain(dispatcher)
}
override fun finished(description: Description) {
Dispatchers.resetMain()
}
}
This replaces Dispatchers.Main with a test dispatcher so viewModelScope works in tests.
12.3 Testing Flow
Use Turbine (a Flow testing library) for concise assertions:
@Test
fun observeUsers_emitsInOrder() = runTest {
repository.observeUsers().test {
assertEquals(listOf<User>(), awaitItem())
repository.insert(User("1", "Alice"))
assertEquals(listOf(User("1", "Alice")), awaitItem())
cancelAndIgnoreRemainingEvents()
}
}
13. Common Pitfalls
- Using
GlobalScope: It is never tied to a lifecycle and causes leaks. Always useviewModelScope,lifecycleScope, or a custom scopedCoroutineScope. - Calling
runBlockingin production: Blocks the thread. Uselaunch/asyncwith a proper scope. - Forgetting
ensureActive()in tight loops: Long CPU loops that never suspend ignore cancellation. - Catching
CancellationExceptionand not rethrowing: Breaks structured concurrency. If you catch it, rethrow it, or let it propagate. - Using
MutableStateFlowpublicly: ExposeStateFlow(read-only) and keepMutableStateFlowprivate. - Collecting in
onCreatewithout lifecycle awareness: Updates can crash when the view is destroyed. UseflowWithLifecycleorcollectAsStateWithLifecycle. - Mixing
asyncwithoutawait: An exception in an unawaitedasyncis silently dropped until the scope completes — easy to miss. - Using
Dispatchers.Defaultfor I/O: Saturates the CPU pool with blocking calls. UseDispatchers.IO. - Creating a new
CoroutineScopeper operation: Loses structured concurrency. Reuse a single scope tied to a lifecycle. - Treating
Flowas hot: A coldFlowruns the producer per collector. If you need shared state, convert toStateFlow/SharedFlow.
14. Best Practices Summary
- Always use a structured scope —
viewModelScopefor ViewModels,lifecycleScopefor lifecycle-bound work. - Use
suspendfunctions for one-shot operations andFlowfor streams. - Expose
StateFlowfor UI state andSharedFlowfor one-shot events. - Collect with lifecycle awareness —
collectAsStateWithLifecyclein Compose,flowWithLifecyclein fragments. - Pick the right dispatcher — IO for blocking, Default for CPU, Main for UI.
- Handle errors explicitly — either
try/catch,Resultwrappers, orCoroutineExceptionHandler. - Make cancellation cooperative — call
ensureActive()oryield()in long loops. - Test with
runTestand a main dispatcher rule — never real delays or real dispatchers in unit tests. - Never use
GlobalScopeorrunBlockingin production code. - Prefer
coroutineScope/supervisorScopeover ad-hoclaunchfor parallel decomposition with proper error propagation.
Conclusion
Coroutines and Flow replace the tangle of callbacks, threads, and reactive frameworks that Android developers once endured. Their power comes not from being yet another async primitive, but from structured concurrency — a model where every coroutine has a well-defined parent, lifetime, and cancellation path. Master the scopes (viewModelScope, lifecycleScope), the dispatchers (IO, Default, Main), the stream types (Flow cold, StateFlow/SharedFlow hot), and the testing utilities (runTest, Turbine), and your asynchronous code will be both concise and robust.
The investment in learning coroutines pays off across the entire stack: Room returns Flow, Retrofit supports suspend, Compose consumes StateFlow, and lifecycle libraries integrate natively. There is no part of modern Android development where coroutines are not the recommended tool.
Written as a technical reference for Android developers adopting coroutines and Flow in production code.
- 点赞
- 收藏
- 关注作者
评论(0)