Part 4 · Flow — Asynchronous Streams
What Is a Flow?
A Flow is an asynchronous data stream that emits values over time. Think of it as a pipe through which data flows; you observe what comes out. Where a suspend function returns a single value, a Flow can emit many values, asynchronously, as they become available.
A basic flow
1
2
3
4
5
6
7
8
9
flow {
emit(1)
delay(100)
emit(2)
delay(100)
emit(3)
}.collect { value ->
println(value) // prints 1, 2, 3
}
The Three Flow Types
| Type | Hot / Cold | Holds a value? | Use case |
|---|---|---|---|
| Flow | Cold | No | API calls, DB queries, one-time operations |
| StateFlow | Hot | Yes (always) | UI state, current data, configuration |
| SharedFlow | Hot | No (configurable replay) | One-time events: navigation, toasts |
Flow (Cold) — from the manuscript
Cold means the flow does not start running until someone collects it. Each collector gets a new, independent stream — collecting twice runs the producer twice. A cold flow stops when its work is done, which makes it ideal for database queries, API calls, and any one-time operation.
Cold flow — each collection is a fresh execution
1
2
3
4
5
6
7
val apiFlow = flow {
println("Starting")
emit(api.fetchData()) // the API is called here
}
// Nothing happens until collected:
apiFlow.collect { } // API call #1
apiFlow.collect { } // API call #2 -- a brand-new execution
StateFlow (Hot) — from the manuscript
A StateFlow is always active and always holds a value. It requires an initial state, exposes a synchronous .value, and is conflated — it skips intermediate values, delivering only the latest to new collectors. Multiple collectors share the same state. It is the perfect tool for UI state, or anything with a ‘current value’ that behaves like Observable or LiveData.
StateFlow in a ViewModel
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class MyViewModel : ViewModel() {
private val _uiState = MutableStateFlow<UiState>(UiState.Loading)
val uiState: StateFlow<UiState> = _uiState.asStateFlow()
fun loadData() {
viewModelScope.launch {
_uiState.value = UiState.Loading
try {
_uiState.value = UiState.Success(repository.getData())
} catch (e: Exception) {
_uiState.value = UiState.Error(e.message)
}
}
}
}
SharedFlow (Hot) — from the manuscript
A SharedFlow is always active but holds no single ‘current’ value. It can replay a configurable number of past emissions, and unlike StateFlow it does not skip values — every emission matters. Multiple collectors share emissions, and importantly, actions do not repeat on configuration changes (rotation). This makes it the right choice for one-time events: navigation, toasts, snackbars, broadcasts, and notifications.
SharedFlow for one-time events
1
2
3
4
5
6
7
8
9
10
11
12
13
14
private val _events = MutableSharedFlow<Event>()
val events: SharedFlow<Event> = _events.asSharedFlow()
fun saveData() {
viewModelScope.launch {
try {
repository.save()
_events.emit(Event.ShowToast("Saved"))
_events.emit(Event.NavigateBack)
} catch (e: Exception) {
_events.emit(Event.ShowError(e.message))
}
}
}
Interview Tip:
Use StateFlow for state that should survive and replay (the screen’s current data), and SharedFlow for events that should fire exactly once (a navigation command). Using StateFlow for events causes the event to re-fire on rotation — a navigation loop or a duplicate toast.
Flow Operators
Transformation
map, filter, transform
1
2
3
4
5
6
7
8
flowOf(1, 2, 3).map { it * 2 }.collect { println(it) } // 2, 4, 6
flowOf(1, 2, 3, 4).filter { it % 2 == 0 }.collect { } // 2, 4
// transform -- emit multiple (or zero) values per input
flowOf(1, 2).transform { value ->
emit(value)
emit(value * 2)
}.collect { println(it) } // 1, 2, 2, 4
Combining and flattening
combine, zip, flatMapConcat, flatMapLatest
1
2
3
4
5
6
7
8
9
// combine -- latest value from each flow
combine(flow1, flow2) { a, b -> "$a$b" }.collect { }
// zip -- pair corresponding emissions
flowOf(1,2,3).zip(flowOf("A","B","C")) { n, l -> "$n$l" } // 1A, 2B, 3C
// flatMapLatest -- cancel the previous inner flow when a new value arrives
// (ideal for search-as-you-type)
queryFlow.flatMapLatest { query -> repository.search(query) }
Side effects and context
onEach, catch, flowOn
1
2
3
4
5
flow { emit(1); throw Exception("Error!") }
.onEach { println("emitting $it") }
.catch { e -> emit(-1) } // handle upstream errors, emit fallback
.flowOn(Dispatchers.IO) // upstream runs on IO
.collect { println(it) } // collection runs on the caller's dispatcher
Flow Builders (from the manuscript)
| Builder | Use case |
|---|---|
| flow { } | Sequential emissions — the standard cold flow |
| callbackFlow { } | Convert a callback-based API into a Flow; awaitClose is mandatory |
| channelFlow { } | Concurrent emissions from multiple coroutines (uses send) |
| flowOf(…) | A fixed set of known values |
| asFlow() | Convert a collection or range into a flow |
| emptyFlow() | A flow that emits nothing |
callbackFlow — converting callbacks
callbackFlow bridges callback-based APIs (location updates, sensor listeners) into the Flow world. Key functions: trySend(value) emits from inside the callback (non-blocking); close(exception) ends the flow; and awaitClose { } registers the cleanup that runs when the flow is cancelled — which is mandatory, or you leak the listener.
callbackFlow with mandatory awaitClose
1
2
3
4
5
6
7
8
9
10
11
12
13
fun observeLocation(): Flow<Location> = callbackFlow {
val listener = object : LocationListener {
override fun onLocationChanged(location: Location) {
trySend(location) // emit from the callback
}
override fun onProviderDisabled(p: String) {
close(Exception("Location disabled"))
}
}
locationManager.requestLocationUpdates(GPS_PROVIDER, 1000L, 10f, listener)
awaitClose { locationManager.removeUpdates(listener) } // MANDATORY cleanup
}
channelFlow — concurrent emissions
A plain flow { } is sequential — you cannot emit from a launched child coroutine inside it. channelFlow lifts that restriction: it allows concurrent emissions from multiple coroutines via send, and completes automatically once all its launched blocks finish.
channelFlow — parallel search across sources
1
2
3
4
5
6
fun searchEverywhere(query: String): Flow<SearchResult> = channelFlow {
launch { localDb.search(query).forEach { send(SearchResult.Local(it)) } }
launch { remoteApi.search(query).forEach { send(SearchResult.Remote(it)) } }
launch { cache.search(query).forEach { send(SearchResult.Cached(it)) } }
// completes automatically when all three launch blocks finish
}
Flow Lifecycle & Memory Leaks
Cold flows complete on their own after emitting all values — no leak risk. Hot flows (StateFlow, SharedFlow) are eternal: they never complete until their scope is cancelled. That is exactly where leaks creep in. If a Fragment collects a StateFlow in a raw lifecycleScope.launch, the collection never ends — on navigation away the Fragment is destroyed but the collector keeps running, and on return a second collector starts.
| Flow type | Completes when | Lifecycle |
|---|---|---|
| Cold Flow | After emitting all values | Finite — self-completing |
| StateFlow | Never (until scope cancelled) | Eternal — needs lifecycle management |
| SharedFlow | Never (until scope cancelled) | Eternal — needs lifecycle management |
Android Best Practices
repeatOnLifecycle — the correct way to collect in UI
repeatOnLifecycle ties collection to a lifecycle state. It starts collecting when the lifecycle reaches the given state (typically STARTED) and automatically cancels collection when it drops below — then restarts on return. This is the standard, leak-free pattern for collecting hot flows in an Activity or Fragment.
repeatOnLifecycle — no leaks
1
2
3
4
5
6
7
8
9
10
11
12
class MyFragment : Fragment() {
override fun onViewCreated(view: View, state: Bundle?) {
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
// collects when STARTED+, cancels when below STARTED,
// restarts when STARTED again
viewModel.state.collect { updateUI(it) }
}
}
}
}
// In Compose, use collectAsStateWithLifecycle() instead.
viewModelScope vs lifecycleScope
| viewModelScope | lifecycleScope |
|---|---|
| Survives configuration changes (rotation) | Tied to Activity/Fragment lifecycle |
| Cancelled when the ViewModel is cleared | Cancelled when destroyed |
| Use for business logic and data | Use for UI updates and flow collection |
Common Pitfalls
- GlobalScope creates untracked coroutines that are hard to cancel and leak memory — use viewModelScope, lifecycleScope, or a managed custom scope.
- runBlocking in production blocks the thread and can cause ANRs — it is for tests and main() only.
- Long work on Dispatchers.Main freezes the UI — move it to IO or Default.
- Expecting CoroutineExceptionHandler to catch async — it won’t; the exception is in the Deferred, so try-catch the await().
- Not re-throwing CancellationException breaks cancellation — always re-throw after cleanup.
- Collecting flows directly in lifecycleScope wastes resources when the UI is hidden — use repeatOnLifecycle.
- Using Job() when you need SupervisorJob() — one child’s failure cancels every sibling.
- Forgetting awaitClose in callbackFlow — leaks the underlying listener.
← Previous: Cancellation & Exceptions · ↑ Chapter Index · Next: Channels →