El contrato de Flow y escribir tus propios operadores
Un operador es simplemente una función de extensión sobre Flow. Las dos reglas que impone el runtime son lo que evita que escribas uno roto — y ambos fallos son famosos.
Explorar la explicación completa 8 ideas
En Kotlin no hay ningún registro de operadores. `map`, `filter`, `debounce` y todos los demás son funciones de extensión corrientes sobre `Flow<T>` que devuelven un `Flow<R>`, y el tuyo también lo será: `fun Flow<Order>.onlyPaid(): Flow<Order> = filter { it.paid }` es un operador real, indistinguible de uno incorporado en el punto de llamada. Esa es toda la historia de extensibilidad, y por eso la librería estándar puede darse el lujo de ser chica — lo que falte lo escribís una vez, le ponés buen nombre y lo testeás por separado.
El builder `flow { }` impone dos invariantes, y conocerlos es la diferencia entre un operador que funciona y uno que falla de un modo que nadie puede depurar. El primero es la **preservación de contexto**: solo la corrutina que ejecuta el builder puede llamar a `emit`. Escribir `flow { withContext(Dispatchers.IO) { emit(x) } }` lanza `IllegalStateException: Flow invariant is violated`, y la librería hace bien en negarse — un flow cuyas emisiones llegan de hilos arbitrarios no tiene una historia coherente de cancelación ni de excepciones. Para mover trabajo aguas arriba, usá `.flowOn(dispatcher)`, que cambia dónde corre el productor sin cambiar quién llama a `emit`.
El segundo invariante es la **transparencia de excepciones**, y es el más sutil. La excepción de un colector propaga *de vuelta a través* del `emit` que lo invocó — así que un `try/catch(Throwable)` envuelto alrededor de tu `emit` captura fallos lanzados por **código aguas abajo del que no sabés nada**. Tu operador entonces maneja en silencio el bug de otro, y el fallo desaparece por completo: nada escapa del `collect`, nada se loguea, y el stream simplemente termina antes. Esto es Kotlin legal sobre el que ningún compilador puede avisarte, y por eso mismo `catch { }` existe como operador separado.
Los operadores declarativos de error son **posicionales**: cada uno ve solo lo que está *arriba* suyo en la cadena. `.catch { }` maneja fallos de aguas arriba y puede `emit` un valor de reemplazo, así que un pipeline que se quedó sin reintentos puede degradar en vez de lanzar. Colocado antes de la etapa que falla, es código muerto. `.onCompletion { cause -> … }` corre con éxito, con fallo y con cancelación, recibe la causa (`null` cuando todo salió bien) y — a diferencia de `catch` — **no** maneja nada, lo que lo vuelve el hogar correcto de logging y métricas. `.retryWhen { cause, attempt -> … }` se re-suscribe a todo el upstream, con `attempt` contando reintentos desde cero.
Para un operador que solo transforma valores, `transform { }` es la herramienta: te entrega cada valor de arriba y te deja `emit` cero, uno o muchos resultados. `map` es `transform { emit(f(it)) }`; `filter` es `transform { if (p(it)) emit(it) }`; un flat-map sobre una lista es una línea. Como estás dentro de la corrutina propia del builder, todo eso es legal — los invariantes solo muerden cuando una corrutina *distinta* quiere producir.
Y ahí es exactamente cuando necesitás `channelFlow { }`. Corre tu bloque en un `ProducerScope` respaldado por un canal, así que `send` es seguro desde cualquier corrutina hija — que es lo que exige un operador con **timer**, con **callback** o con **varios productores concurrentes**. Un operador de batching que vacía por tamaño *o* por timeout no se puede escribir con `flow { }`, porque la corrutina temporizadora tendría que emitir; con `channelFlow { }` es un `launch { delay(t); flush() }` y un lock alrededor del vaciado. `callbackFlow { }` es el mismo builder especializado para APIs de listeners, con un `awaitClose { }` obligatorio que el runtime verifica — porque un puente de callbacks sin limpieza es un leak garantizado.
Dos reglas de diseño mantienen honestos a los operadores propios. **Devolvé un Flow frío**: tu función no debería hacer trabajo al llamarse, solo al recolectarse, así compone como cualquier otro operador y se puede reintentar, cachear o recolectar dos veces sin riesgo. Y **no te tragues la cancelación**: si tu operador captura ampliamente por sus propios motivos, relanzá primero `CancellationException`, o un colector que se va deja tu operador corriendo.
Por último, testealos como funciones corrientes, porque eso es lo que son. `runTest` te da tiempo virtual, así que un operador con timeout se verifica por *horario* — `[1, 2]@100` — en vez de durmiendo y cruzando los dedos. Construí la fuente con `flow { delay(…); emit(…) }` para guionar una línea de tiempo exacta, recolectá con `toList()`, y afirmá sobre los valores y sobre `currentTime`. Un operador cuyo comportamiento podés fijar al milisegundo es uno que podés refactorizar sin miedo.
Aprendamos juntos.
Pregúntame sobre este curso o por dónde empezar.
import kotlinx.coroutines.*import kotlinx.coroutines.flow.*fun main() = runBlocking {// 1. Context preservation: only the builder's coroutine may emit.val illegal = flow { withContext(Dispatchers.Default) { emit(1) } }try { illegal.collect { } }catch (e: Throwable) { println("1 = " + e::class.simpleName) }// 2. Exception transparency: the collector's failure unwinds back THROUGH// emit, so a broad catch in the builder eats somebody else's bug.val leaky = flow {try { emit(1); emit(2) }catch (e: Throwable) { println("2 = swallowed '" + e.message + "' (not mine!)") }}leaky.collect { if (it == 2) throw IllegalStateException("collector blew up") }println("2 = ...and nothing escaped the collect")// 3. The legal way: catch() sees UPSTREAM failures only, and may emit.val guarded = flow<Int> { emit(1); throw IllegalStateException("upstream") }.catch { println("3 = caught '" + it.message + "', degrading"); emit(-1) }println("3 = " + guarded.toList())}
Both invariants, demonstrated. Emitting from another context is refused outright; a try/catch around emit silently swallows the COLLECTOR's failure, which is the bug catch { } exists to prevent.
Arena IDEimport kotlinx.coroutines.flow.*import kotlinx.coroutines.runBlocking// Real operators, written the way the standard library writes them: the// per-collection state lives INSIDE the function, so each collection is// independent and the operator stays cold.fun <T, R> Flow<T>.mapIndexedNotNull(f: (Int, T) -> R?): Flow<R> {var i = 0return transform { value -> f(i++, value)?.let { emit(it) } }}// A domain operator reads at the call site like a built-in.data class Order(val id: Int, val paid: Boolean)fun Flow<Order>.onlyPaid(): Flow<Order> = filter { it.paid }fun main() = runBlocking {val orders = flowOf(Order(1, true), Order(2, false), Order(3, true))println("onlyPaid = " + orders.onlyPaid().map { it.id }.toList())println("labelled = " + flowOf("a", "b", "c").mapIndexedNotNull { i, s -> if (i % 2 == 0) "" + i + s else null }.toList())}
transform { } is enough for anything that only reshapes values — zero, one or many emissions per input, all inside the builder's own coroutine.
Arena IDEimport kotlinx.coroutines.*import kotlinx.coroutines.flow.*import kotlinx.coroutines.sync.*import kotlinx.coroutines.test.*@OptIn(ExperimentalCoroutinesApi::class)fun <T> Flow<T>.chunked(size: Int, timeoutMs: Long): Flow<List<T>> = channelFlow {val buf = mutableListOf<T>()var timer: Job? = nullval lock = Mutex()suspend fun flush() = lock.withLock {if (buf.isNotEmpty()) { send(buf.toList()); buf.clear() }}collect { v ->lock.withLock { buf.add(v) }if (buf.size >= size) {timer?.cancel(); timer = null // or it would cut the NEXT batch shortflush()} else if (timer == null) {timer = launch { delay(timeoutMs); flush(); timer = null }}}timer?.cancel()flush() // never lose the final partial batch}@OptIn(ExperimentalCoroutinesApi::class)fun main() = runTest {// 1,2 arrive together; 3,4,5 arrive after a long gap.val src = flow {emit(1); delay(10); emit(2); delay(290)emit(3); delay(10); emit(4); delay(10); emit(5)}src.chunked(size = 3, timeoutMs = 100).collect { println(it.toString() + "@" + currentTime) }}
The operator flow { } cannot express: a batch that flushes on SIZE or on a TIMEOUT needs a second coroutine to be able to emit — so channelFlow, and a lock so the two triggers cannot send the same buffer twice.
Arena IDEimport kotlinx.coroutines.*import kotlinx.coroutines.channels.awaitCloseimport kotlinx.coroutines.flow.*import kotlinx.coroutines.test.*class Ticker { // a legacy push APIprivate var listener: ((Int) -> Unit)? = nullval log = mutableListOf<String>()fun register(l: (Int) -> Unit) { listener = l; log.add("registered") }fun unregister() { listener = null; log.add("unregistered") }fun push(v: Int) { listener?.invoke(v) }}fun Ticker.prices(): Flow<Int> = callbackFlow {register { v -> trySend(v) } // the callback cannot suspendawaitClose { unregister() } // runs on completion AND cancel}@OptIn(ExperimentalCoroutinesApi::class)fun main() = runTest {val ticker = Ticker()val got = mutableListOf<Int>()val job = launch { ticker.prices().take(2).collect { got.add(it) } }runCurrent() // let the collector subscriberepeat(3) { ticker.push(it + 1) }runCurrent()job.join()println("got = " + got)println("lifecycle = " + ticker.log.joinToString(","))}
callbackFlow for a listener API. awaitClose is mandatory — the runtime throws without it — because a callback bridge that never unregisters is a guaranteed leak.
Arena IDE🧠 Comprueba tu comprensión
0/1 · 0/1 answered1. You write a custom operator as `flow { try { source.collect { emit(transform(it)) } } catch (e: Throwable) { emit(fallback) } }`. What is wrong with it?