$npx -y skills add rcosteira79/android-skills --skill kotlin-flowsUse when working with Flow, StateFlow, SharedFlow, or Channel in Kotlin — including cold vs hot stream decisions, operator chains, lifecycle-safe collection, UI state management, callback bridging, or Channel migration in Android or KMP projects.
| 1 | # Kotlin Flows |
| 2 | |
| 3 | This reference covers the Flow traps and semantic edge cases — Channel vs SharedFlow, callback bridging, retry/error handling, side effects in transforms, and KMP boundaries — not the basics of cold/hot streams, the operator table, or lifecycle-safe collection. |
| 4 | |
| 5 | ## Channel vs SharedFlow — the semantics that bite |
| 6 | |
| 7 | | Found | Action | |
| 8 | |---|---| |
| 9 | | `BroadcastChannel` | Migrate → `SharedFlow` (deprecated) | |
| 10 | | `ConflatedBroadcastChannel` | Migrate → `StateFlow` (deprecated) | |
| 11 | | `Channel` for single-consumer fire-once events (nav, snackbars, one-shot effects) | **Keep — correct.** `Channel(BUFFERED).receiveAsFlow()` | |
| 12 | | `Channel` broadcast to multiple collectors | Migrate → `SharedFlow` (see below) | |
| 13 | | `Channel` as producer-consumer queue | Keep — correct | |
| 14 | |
| 15 | **`Channel.receiveAsFlow()` is fan-out, NOT broadcast.** With multiple collectors, each event reaches **one** collector (the framework picks which), not all of them. If every collector must see every event, you need `SharedFlow`. This is the trap that's easy to fall into when reaching for `Channel` to multicast. |
| 16 | |
| 17 | For single-consumer one-shot events, `Channel(BUFFERED).receiveAsFlow()` is the default because `send` suspends until consumed (the event is queued, never dropped), whereas `SharedFlow(replay = 0)` drops the emission if no collector is active at the instant of emission. Expose the `Flow`, never the `Channel`, and collect with `collect` inside `LaunchedEffect` — **not** `collectAsStateWithLifecycle`, which retains the last event as state and re-fires it on every recomposition / config change. |
| 18 | |
| 19 | ```kotlin |
| 20 | private val _events = Channel<UiEvent>(Channel.BUFFERED) |
| 21 | val events: Flow<UiEvent> = _events.receiveAsFlow() |
| 22 | |
| 23 | // in the UI |
| 24 | LaunchedEffect(Unit) { |
| 25 | viewModel.events.collect { event -> /* navigate / snackbar — consumed once */ } |
| 26 | } |
| 27 | ``` |
| 28 | |
| 29 | **Exactly-once is really at-most-once-with-very-high-probability.** A `receiveAsFlow()` collector cancelled *after* `receive()` succeeds but *before* its block processes the element loses that element. Rare (most cancellations land between receives), but for payment/persistence-critical signals, store the outcome in durable state (a `pendingResult` field on `UiState`, cleared by the UI after consumption) — see `compose/references/state-management.md` "Durable state + acknowledgement". |
| 30 | |
| 31 | ### SharedFlow: `launch { emit() }` over `tryEmit` |
| 32 | |
| 33 | Default to `launch { _events.emit(e) }` — it suspends until the collector is ready, so the effect is never silently lost. `tryEmit()` on a default `MutableSharedFlow()` (no buffer) **silently drops** when no subscriber is ready; adding `extraBufferCapacity = 1` only moves the cliff (a second rapid emission while the buffer is full returns `false` and is dropped with no error). Use `tryEmit` only for miss-tolerable effects (tooltip, sound). |
| 34 | |
| 35 | ## Exposing a read-only flow — explicit backing fields (Kotlin 2.4+) |
| 36 | |
| 37 | The `_x` (mutable) / `x` (read-only) two-property idiom collapses into a single property with explicit backing fields — stable in Kotlin **2.4**, experimental in 2.3: |
| 38 | |
| 39 | ```kotlin |
| 40 | val uiState: StateFlow<UiState> |
| 41 | field = MutableStateFlow(UiState()) // inside the class: uiState.update { … }; callers see StateFlow |
| 42 | ``` |
| 43 | |
| 44 | Applies wherever a `StateFlow` / `SharedFlow` is exposed from a mutable backing (`MutableStateFlow` *is* a `StateFlow`, so the default getter type-checks). It does **not** fit a `Channel` exposed as a `Flow` — a `Channel` isn't a `Flow`, so keep the explicit `_events` / `receiveAsFlow()` pair above. On Kotlin < 2.4 (check the version catalog), keep the classic `private val _x` + `val x = _x.asStateFlow()`. |
| 45 | |
| 46 | ## Callback bridging |
| 47 | |
| 48 | ```kotlin |
| 49 | // Single value: suspendCancellableCoroutine — always prefer it (propagates cancellation) |
| 50 | suspend fun authenticate(token: String): User = suspendCancellableCoroutine { cont -> |
| 51 | val call = authApi.authenticate(token) { user, error -> |
| 52 | if (user != null) cont.resume(user) else cont.resumeWithException(error ?: Exception()) |
| 53 | } |
| 54 | cont.invokeOnCancellation { call.cancel() } |
| 55 | } |
| 56 | // suspendCoroutine only when the API has no cancellation concept at all. |
| 57 | |
| 58 | // Stream: callbackFlow — awaitClose is MANDATORY (omitting it leaks the callback and never completes) |
| 59 | fun LocationManager.locationUpdates(provider: String): Flow<Location> = callbackFlow { |
| 60 | val listener = LocationListener { trySend(it) } |
| 61 | requestLocationUpdates(provider, 1000L, 0f, listener) |
| 62 | awaitClose { removeUpdates(listener) } |
| 63 | } |
| 64 | ``` |
| 65 | |
| 66 | For a third-party SDK with no deregistration API, set a flag inside `awaitClose` to signal shutdown and document the limitation. |
| 67 | |
| 68 | ## retry / retryWhen — always guard the attempt count |
| 69 | |
| 70 | ```kotlin |
| 71 | repository.getItems() |
| 72 | .retryWhen { cause |