Coroutines Advanced

Kotlin Intermediate — Coroutines Advanced
Kotlin Intermediate
Course 2 · Chapter 2 · Coroutines Advanced

🌊 Coroutines Advanced

Chapter 1 covered a single asynchronous value at a time — one suspend call, one result. This chapter covers streams of values over time (Flow), the two flavors of "hot" stream built on it (StateFlow and SharedFlow), and how structured concurrency and cancellation actually behave once coroutines are nested and things go wrong.

🌀 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:

fun countdown(): Flow<Int> = flow { for (i in 3 downTo 1) { delay(1000) emit(i) // produces one value into the stream } } fun main() = runBlocking { countdown().collect { value -> println(value) } } // Output (one per second): 3, 2, 1

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.

countdown() .map { it * 10 } .filter { it > 10 } .collect { println(it) } // Output: 30, 20 — (1*10 = 10 is filtered out)

📌 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:

val counter = MutableStateFlow(0) // requires an initial value fun main() = runBlocking { launch { counter.collect { value -> println("Counter is now: $value") } } delay(100) counter.value = 1 delay(100) counter.value = 2 } // Output: Counter is now: 0 // Counter is now: 1 // Counter is now: 2

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:

val events = MutableSharedFlow<String>() // no initial value required fun main() = runBlocking { launch { events.collect { event -> println("Received: $event") } } delay(100) // give the collector time to start events.emit("User logged in") events.emit("Item added to cart") }

StateFlow vs SharedFlow

StateFlowSharedFlow
Has a current value?Yes — always readable via .valueNo
New collector gets...The current value immediatelyNothing, until the next emit()
Best forState (screen data, settings, counters)One-off events (navigation, snackbar messages)
Duplicate valuesSkipped if unchanged (by default)Every emit() delivered
⚠ Don't Use StateFlow for One-Off Events

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:

// coroutineScope: one child failing cancels ALL siblings coroutineScope { launch { delay(100); throw RuntimeException("Failed!") } launch { delay(500); println("Never printed — cancelled by sibling's failure") } } // supervisorScope: siblings are independent supervisorScope { launch { delay(100); throw RuntimeException("Failed!") } launch { delay(500); println("Still printed — unaffected by sibling's failure") } }

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:

val job = launch { var i = 0 while (isActive) { // checking isActive makes this loop cancellable println("Working... ${i++}") delay(200) // delay() itself checks for cancellation and throws if cancelled } } delay(1000) job.cancel()

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().

⚠ Cleanup on Cancellation: try/finally + NonCancellable

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.

→ Solution

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.

→ Solution

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().

→ Solution

💡 Flow, StateFlow, and SharedFlow Cover Three Different Jobs

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 & Variancein/out/invariant type parameters, reified types, type erasure, and generic constraints.