Coroutines Advanced
🌊 Coroutines Advanced
🌀 Flow — Cold Asynchronous Streams
A Flow<T> represents a stream of values produced over time, asynchronously — the streaming counterpart to a single suspend function's one-shot result:
flow { } builds a Flow; emit() produces a value into it; collect { } is how a consumer receives each value as it arrives. Crucially, Flow is cold — the code inside flow { } doesn't run at all until collect is called, and it runs again from scratch for every separate collector. Nothing happens just by calling countdown() — it only produces a description of the stream, not the stream itself.
vs Chapter 1's Sequence
A Sequence (Course 1, Chapter 6) is also lazy, but entirely synchronous — no suspending, no delays. Flow is the asynchronous counterpart: its builder block and its operators can freely call suspend functions like delay().
Flow Operators Feel Familiar
Flow has map, filter, and similar operators with the exact same names as List/Sequence operators (Course 1, Chapter 6) — they just work value-by-value, over time, and are suspend-aware.
📌 StateFlow — Hot, Always Has a Value
StateFlow is a different shape entirely: it's hot (running independently of collectors) and always holds a current value, which every new collector receives immediately upon subscribing:
counter.value can be read or set directly at any time, from outside a coroutine even — StateFlow is the coroutine world's answer to "a variable that broadcasts its own changes." This is exactly the pattern used for UI state in modern Android apps with Jetpack Compose: a screen's entire visible state is typically one StateFlow, and the UI recomposes automatically whenever it changes.
📡 SharedFlow — Hot, Broadcast Events
SharedFlow is also hot, but has no current value — it broadcasts events to whoever happens to be collecting at the moment, and doesn't replay anything to a late subscriber by default:
StateFlow vs SharedFlow
| StateFlow | SharedFlow | |
|---|---|---|
| Has a current value? | Yes — always readable via .value | No |
| New collector gets... | The current value immediately | Nothing, until the next emit() |
| Best for | State (screen data, settings, counters) | One-off events (navigation, snackbar messages) |
| Duplicate values | Skipped if unchanged (by default) | Every emit() delivered |
A common bug: modeling a "navigate to screen X" event as a StateFlow. Because StateFlow always holds its last value, a screen that re-subscribes later (e.g. after rotation on Android) immediately re-receives that old "navigate" event and fires it again unintentionally. SharedFlow — which doesn't replay by default — is the correct tool for genuine one-off events.
🏗️ Structured Concurrency: coroutineScope vs supervisorScope
Chapter 1 introduced structured concurrency at a high level — child coroutines tied to their scope's lifetime. coroutineScope and supervisorScope both create a scope for launching children, but differ in how a child's failure affects its siblings:
Use plain coroutineScope (the default, and what most nested async work should use) when a group of tasks are all part of one logical operation and a failure in any of them should abort the whole thing. Use supervisorScope when children are genuinely independent — e.g. several unrelated background tasks where one failing shouldn't take down the others.
🛑 Cancellation Is Cooperative
Calling job.cancel() doesn't forcibly kill a coroutine — it requests cancellation, and the coroutine itself has to cooperate by checking for it:
Suspend functions like delay() already check for cancellation automatically and throw a CancellationException when it happens — that's why the loop above stops even without an explicit isActive check in simple cases. But a tight loop doing pure computation with no suspend calls inside it won't notice cancellation at all unless it explicitly checks isActive or calls ensureActive().
A finally block still runs on cancellation, but any suspend call inside that finally block will itself immediately throw, since the coroutine is already cancelled by that point. If cleanup genuinely needs to suspend (e.g. an async "close connection" call), wrap just that part in withContext(NonCancellable) { ... } to let it complete despite the surrounding cancellation.
💻 Coding Challenges
Challenge 1: A Flow with Operators
Write a function fun numberStream(): Flow<Int> using the flow { } builder that emits the numbers 1 through 5, delaying 200ms before each emit. In main(), collect it after chaining .filter { it % 2 == 0 } and .map { it * it }, printing each resulting value.
Goal: Build and collect a Flow, chaining familiar operators onto an asynchronous stream.
Challenge 2: A StateFlow Counter
Create a MutableStateFlow(0) named counter. In main() (via runBlocking), launch a coroutine that collects counter and prints every value it sees. Then, from the main coroutine, update counter.value three times with a short delay between each update, and make sure the program waits long enough to see every printed update before exiting.
Goal: Practice reading/writing StateFlow.value and observing it from a separate coroutine.
Challenge 3: Cancelling a Running Coroutine
Launch a coroutine containing a while (isActive) loop that prints an incrementing counter every 300ms. From main(), let it run for about 1.5 seconds, then call job.cancel() and print "Cancelled" afterward. Confirm in a comment how many times you'd expect it to print before being cancelled.
Goal: Practice cooperative cancellation with isActive and job.cancel().
It's easy to reach for the wrong one. Ask: is this a finite (or ongoing) sequence of values to process (Flow)? Is it "the current state of something" that always has a value (StateFlow)? Or is it "something just happened, once" (SharedFlow)? Picking based on that question avoids most of the common misuse bugs — like the StateFlow-for-navigation-events trap covered above.
🎯 What's Next
Next chapter: Generics & Variance — in/out/invariant type parameters, reified types, type erasure, and generic constraints.