wandres.dev
FLOW Y STATEFLOW · streams en Kotlin

Operadores: map, filter, flatMapLatest, debounce y combine

Los operadores son el vocabulario con el que se transforma un stream sin salir del stream. Esta lección los presenta como una gramática, no como una lista: primero la distinción estructural entre operadores intermedios —perezosos, fríos, que solo devuelven otro Flow— y terminales, que son los únicos que ejecutan; después map y filter como la aritmética de la forma, y transform como el operador general del que ambos son casos particulares; después la entrada del tiempo con debounce, que espera al silencio del usuario, y flatMapLatest, que cancela la búsqueda anterior en cuanto llega una nueva y resuelve por construcción la carrera de respuestas desordenadas; y por último combine, que une fuentes independientes emitiendo con el último valor de cada una, frente a zip, que empareja estrictamente. Cierra explicando por qué encadenar operadores en lugar de escribir el mismo control de flujo a mano no es azúcar sintáctico sino la diferencia entre declarar una intención y coordinar un estado imperativo propenso a errores.

⏱ 18 min

Un Flow desnudo sirve de poco: casi nunca quieres los valores tal como salen de su fuente. Quieres los que cumplen una condición, transformados a otra forma, agrupados, retrasados, deduplicados, fusionados con los de otra fuente o reemplazados por los de una consulta que depende de ellos. Los operadores son el vocabulario que hace todo eso sin salir del stream, y ahí está la clave: cada operador toma un Flow y devuelve otro Flow, de modo que la cadena entera sigue siendo una sola expresión componible, todavía fría, todavía sin ejecutar. Aprenderlos no es memorizar una lista de funciones sino adquirir una gramática: piezas pequeñas que se encajan hasta que la cadena dice, literalmente, lo que querías que ocurriera.

🎯 Al terminar esta lección sabrás
  • Distinguir operadores intermedios de terminales y entender por qué solo los segundos ejecutan algo.
  • Transformar la forma de un stream con map y filter, y reconocer transform como el caso general.
  • Domar el tiempo con debounce y resolver la carrera de respuestas desordenadas con flatMapLatest.
  • Unir fuentes independientes con combine y saber cuándo lo correcto es zip y no combine.

Intermedios y terminales: la gramática del stream

Todo operador cae en uno de dos grupos, y confundirlos es la primera fuente de desconcierto. Los intermediosmap, filter, onEach, debounce, flatMapLatest, combine, catch, flowOn— reciben un Flow y devuelven otro Flow. No ejecutan nada, no suspenden, no producen ningún valor: se limitan a envolver al anterior con una capa que describe qué hará cuando alguien lo encienda. Los terminalescollect, first, toList, fold, launchIn— son funciones suspend que encienden la cadena entera y la consumen.

De esa asimetría se derivan dos hábitos sanos. El primero: una cadena de veinte operadores sin terminal es código muerto que no ha hecho nada, y si tu stream “no funciona” lo primero que hay que mirar es si alguien lo colecciona. El segundo: como los intermedios se aplican en el orden en que los escribes, colocar un filter antes o después de un map caro cambia cuánto trabajo se hace de verdad. Filtra pronto, transforma tarde.

Esa pereza tiene además una consecuencia que conviene nombrar: los operadores intermedios no crean hilos, ni corrutinas, ni objetos vivos. Encadenar quince es prácticamente gratis en reposo, porque lo único que existe hasta el terminal es una pila de envoltorios. Por eso no hay que economizar eslabones por miedo al rendimiento, sino por claridad: una cadena larga se paga en legibilidad, no en ciclos.

val nombresVisibles: Flow<String> = repo.usuarios()   // Flow<Usuario>
    .filter { usuario -> usuario.visible }            // descarta antes de trabajar
    .map { usuario -> usuario.nombre.uppercase() }    // transforma lo que queda
    .distinctUntilChanged()                           // no repite el ultimo valor
    .onEach { nombre -> log("emitiendo $nombre") }    // efecto lateral de paso

onEach merece una nota: es el operador para asomarse sin alterar —registrar, medir, depurar— porque devuelve el mismo valor que recibió. Y distinctUntilChanged es el guardián barato contra el trabajo redundante: si el valor nuevo es igual al anterior según equals, no lo propaga. En una interfaz eso se traduce en recomposiciones que dejan de ocurrir sin que hayas escrito una sola condición.

Detrás de map y filter hay un único operador general del que ambos son casos particulares: transform, que por cada valor de entrada puede emitir cero, uno o muchos de salida. map es el transform que siempre emite uno; filter es el que emite uno o ninguno. Verlo así evita coleccionar operadores como si fueran cromos: casi todos son atajos con nombre propio sobre la misma capacidad de reescribir el stream elemento a elemento.

// map y filter son transform con una politica de emision fijada
fun <T, R> Flow<T>.miMap(f: (T) -> R): Flow<R> = transform { v -> emit(f(v)) }
fun <T> Flow<T>.miFilter(p: (T) -> Boolean): Flow<T> = transform { v -> if (p(v)) emit(v) }

// transform brilla cuando la cardinalidad cambia de verdad
val palabras: Flow<String> = lineas.transform { linea ->
    linea.split(" ").forEach { palabra -> emit(palabra) }   // uno entra, muchos salen
}

Con esa reducción en la cabeza, el catálogo entero se ordena en cuatro familias, y reconocer a cuál pertenece un operador te dice de antemano qué garantías conserva y cuáles no.

🔁

Transformacion

map, transform, runningFold, withIndex. Cambian la forma o la cardinalidad del valor y no tocan el tiempo: lo que entra ordenado sale ordenado.

🧹

Filtrado

filter, take, drop, distinctUntilChanged. Deciden qué pasa y qué no. Son el sitio barato donde recortar trabajo antes de que cueste.

⏱️

Tiempo

debounce, sample, timeout, buffer, conflate. No cambian los valores: cambian cuándo llegan y cuáles se descartan cuando el ritmo no da.

🌊

Combinacion

combine, zip, merge, la familia flatMap. Unen o aplanan varias fuentes, y son los únicos que obligan a decidir una política de concurrencia.

La distinción práctica que sale de esa tabla es que los operadores de las dos primeras familias son casi siempre inocuos y los de las dos últimas son decisiones de diseño. Meter un map de más cuesta unos ciclos; meter un flatMapMerge donde tocaba flatMapLatest cambia el comportamiento observable de la aplicación bajo carga. Revisa los primeros con la vista y los segundos con un argumento.

El tiempo entra en escena: debounce y flatMapLatest

Los operadores anteriores transforman valores; los que vienen ahora transforman cuándo y si ocurren las cosas, y son los que de verdad separan un stream de una lista. El escenario canónico es la búsqueda incremental: un campo de texto emite una consulta por cada tecla pulsada, y cada consulta desencadena una llamada de red. Sin operadores de tiempo, escribir “kotlin” son seis peticiones, cinco de ellas inútiles, y las respuestas pueden llegar desordenadas.

debounce ataca el primer problema: no propaga un valor hasta que ha pasado el tiempo indicado sin que llegue otro. Espera al silencio del usuario. flatMapLatest ataca el segundo: por cada valor de entrada abre un nuevo Flow —la consulta— y, en cuanto llega el siguiente valor, cancela el flujo anterior antes de abrir el nuevo. La respuesta vieja no llega tarde y pisa a la nueva, porque la vieja ni siquiera termina.

val resultados: Flow<List<Item>> = consultas          // Flow<String> del campo
    .debounce(300)                                    // espera al silencio
    .filter { texto -> texto.length >= 2 }
    .distinctUntilChanged()                           // ignora el mismo texto
    .flatMapLatest { texto -> repo.buscar(texto) }    // cancela la busqueda previa
    .catch { error -> emit(emptyList()) }             // el stream sobrevive al fallo
flowchart LR
T[Teclas del usuario] --> D[debounce espera el silencio]
D --> F[distinctUntilChanged descarta repetidos]
F --> FL[flatMapLatest cancela la consulta anterior]
FL --> R[Solo la ultima respuesta llega a la interfaz]
style T fill:#89b4fa,color:#11111b
style FL fill:#cba6f7,color:#11111b
style R fill:#a6e3a1,color:#11111b

El orden de esos eslabones no es negociable y conviene saber por qué. debounce va primero porque su trabajo es reducir el caudal antes de que nadie gaste nada en él; ponerlo después del flatMapLatest sería llamar a la red seis veces y luego decidir con calma. distinctUntilChanged va después del debounce porque el usuario puede borrar una letra y reescribirla dentro de la ventana de silencio, y sin ese eslabón repetirías una consulta idéntica. Y catch va al final porque solo debe cubrir lo que hay encima: si lo pusieras antes del flatMapLatest, un fallo de red quedaría fuera de su alcance.

Su pariente cercano, sample, ayuda a fijar la diferencia. debounce espera al silencio y por eso sirve para entradas a ráfagas, como teclear: si el usuario no para nunca, no emite nunca. sample toma el último valor cada intervalo fijo pase lo que pase, y por eso sirve para caudales continuos, como la posición de un dedo o un sensor: emite con cadencia constante aunque la fuente no calle jamás. Elegir entre ambos es preguntarse si lo que quieres es esperar una pausa o imponer un ritmo.

Compara ahora esas cinco líneas con lo que exigiría hacerlo a mano: un temporizador que reiniciar en cada tecla, una referencia a la petición en vuelo para cancelarla, una marca de tiempo o un contador de secuencia para descartar respuestas obsoletas, y un bloque de captura de errores que no mate el bucle. Media docena de variables mutables coordinadas entre sí, cada una con su oportunidad de tener un bug de concurrencia. La cadena declarativa no es una versión bonita de ese código: es una versión en la que esos estados intermedios no existen, y por lo tanto no pueden desincronizarse.

💡
La familia flatMap se elige por la politica de concurrencia

Los tres hermanos hacen lo mismo —aplanar un flujo de flujos— y se diferencian solo en qué ocurre cuando llega un valor nuevo mientras el anterior sigue produciendo. flatMapLatest cancela el anterior: es lo que quieres cuando el resultado viejo ya no importa, como en una búsqueda o al cambiar de filtro. flatMapMerge deja correr todos a la vez y entrelaza sus emisiones: útil cuando cada resultado es valioso y el orden da igual, como al descargar en paralelo. flatMapConcat espera a que el anterior termine antes de empezar el siguiente: preserva el orden estricto al coste de la latencia. Elegir mal aquí no da un error de compilación, da un bug sutil; nombra en voz alta la política que necesitas antes de escribir el operador.

combine: unir fuentes que laten por separado

El último movimiento del vocabulario es fusionar. combine toma dos o más flujos y emite un valor nuevo cada vez que cualquiera de ellos emite, usando el último valor conocido de los demás. Esa semántica —el último de cada uno— es exactamente la que necesita una pantalla cuyo contenido depende de varias fuentes que cambian a ritmos distintos: la sesión del usuario, los filtros activos, la lista del repositorio, la conectividad.

data class PantallaState(val items: List<Item>, val filtro: Filtro, val hayRed: Boolean)

val estado: Flow<PantallaState> = combine(
    repo.items(),          // cambia cuando cambia la base de datos
    filtros.seleccion(),   // cambia cuando el usuario toca un chip
    red.conectividad(),    // cambia cuando entra o sale el wifi
) { items, filtro, hayRed ->
    PantallaState(items.filter(filtro::admite), filtro, hayRed)
}

Fíjate en lo que acaba de ocurrir: tres fuentes independientes, con tres cadencias distintas, se han fundido en un único flujo de estado coherente sin una sola variable mutable ni un solo callback que sincronizar. Cada vez que cualquiera cambia, la lambda recalcula el estado completo con lo más reciente de todo. Es la traducción exacta, en términos de streams, de la idea que recorre todo el track: el estado es una función de sus entradas, y si esa función es total, no hay combinación de entradas que produzca una pantalla inconsistente.

Merece detenerse en una consecuencia que a veces sorprende: combine puede emitir estados intermedios que nunca existieron como intención. Si dos fuentes cambian casi a la vez, primero llega una y se emite un estado que mezcla lo nuevo de esa con lo viejo de la otra, y solo después llega el estado definitivo. Para una interfaz conflada eso es inofensivo, porque el parpadeo intermedio suele descartarse antes de dibujarse; para un cálculo caro o para un efecto irreversible, no lo es. La cura, cuando importa, es dejar que el estado se estabilice —con debounce de pocos milisegundos o con conflate— antes de actuar sobre él.

Hay dos advertencias más que evitan la mayoría de los tropiezos con combine. La primera: no emite nada hasta que todas las fuentes hayan emitido al menos una vez, así que un flujo que tarda en arrancar retrasa a toda la pantalla; la cura habitual es darle un valor inicial con onStart o usar fuentes que ya lo tengan, como un StateFlow. La segunda: combine no es zip. zip empareja el primero con el primero, el segundo con el segundo, y espera a que ambos tengan un valor pendiente; sirve para correlacionar secuencias que van en paralelo, no para reflejar el estado más reciente. Si usas zip donde tocaba combine, la pantalla se quedará esperando parejas que nunca llegan.

📝
Escribe la cadena de arriba abajo como una frase

Una cadena bien ordenada se lee como una oración: de dónde viene el dato, qué se descarta, cuándo se espera, en qué se transforma, con qué se funde y qué pasa si falla. Si al leerla en voz alta el orden suena raro, casi siempre es que un operador está en el sitio equivocado. Ese pequeño ritual —leerla como una frase antes de darla por buena— detecta más bugs de flujo que cualquier depurador, porque los errores de estas cadenas casi nunca son de sintaxis, sino de secuencia.

⚠️
Recuerda que la cadena sigue siendo fria

Ni map, ni debounce, ni combine encienden nada. Una cadena de operadores es una descripción, y si la construyes en dos sitios distintos tendrás dos ejecuciones independientes, con dos consultas a la base de datos y dos suscripciones. Ese es justamente el problema que resuelven stateIn y shareIn, que veremos en la última lección del nivel: convertir la descripción compartida en una única ejecución compartida bajo un scope explícito. Hasta entonces, cada terminal que añadas es una ejecución más.

Un operador es una intencion con nombre; el codigo a mano es una intencion disuelta

La tentación de leer estos operadores como azúcar sintáctico es fuerte y hay que resistirla, porque lo que cambian no es la longitud del código sino dónde vive el significado. Escrito a mano, “cancela la búsqueda anterior cuando el usuario teclea otra letra” no está en ninguna parte del programa: está disuelto en una referencia a un Job, una llamada a cancelar, una comprobación de nulidad y un contador de secuencia repartidos por tres funciones. Nadie que lea esas piezas por separado recupera la intención; hay que reconstruirla ejecutando el programa en la cabeza, y esa reconstrucción es precisamente donde se cuelan los bugs de concurrencia, porque exige acordarse de un estado invisible en cada rama. Con flatMapLatest, la intención es una palabra en la cadena: está nombrada, está localizada, se lee en el mismo sitio donde ocurre y no puede desincronizarse de su implementación porque no tiene implementación propia que mantener. Eso es lo que significa programar declarativamente en un dominio temporal, y explica por qué el vocabulario merece estudiarse como gramática y no como catálogo: cada operador que aprendes es una idea sobre el tiempo —esperar el silencio, quedarte con lo último, no repetirte, mirar sin tocar, fundir lo más reciente de varias fuentes— que a partir de entonces puedes decir en lugar de simular. Y cuando una idea se puede decir, deja de ser una responsabilidad tuya y pasa a ser una responsabilidad de la biblioteca, probada por millones de líneas ajenas. Por eso el programador experto en flujos no es el que conoce más operadores, sino el que reconoce antes qué política temporal necesita su problema; el nombre del operador viene después, y casi siempre ya existe.

⚔️ Escribe la cadena que dice lo que quieres
  1. Construye la cadena completa de una búsqueda incremental con debounce, distinctUntilChanged, flatMapLatest y catch, y justifica por qué cada eslabón está en esa posición y no en otra.
  2. Reescribe esa misma lógica sin operadores, con un Job cancelable y un temporizador manual, y enumera los estados mutables que has tenido que introducir.
  3. Sustituye flatMapLatest por flatMapMerge en la búsqueda y describe el bug visible que aparece cuando dos respuestas llegan desordenadas.
  4. Funde tres fuentes con combine en un único estado de pantalla y explica qué ocurre exactamente si una de ellas nunca llega a emitir.
  5. Expresa map y filter como casos particulares de transform, y propón un caso donde transform haga algo que ninguno de los dos puede.