Flow frío
Al terminar: Escribir un flow, recogerlo con collect y usar map, filter y flowOn.
Al terminar esta lección, el partido dejará de ser «una función que devuelve el resultado al final» y pasará a ser un flujo de eventos que llegan uno a uno: el gol del 12, la tarjeta del 30, el cambio del 85. Podrás narrarlo en directo por consola, quedarte solo con los goles y cortar la emisión al primero. Y jugarPartido, que ya escribiste, se reescribirá encima del flujo sin cambiar lo que devuelve (a eso se le llama refactorizar: cambiar el interior de un código sin cambiar lo que hace por fuera).
Importa porque un Flow es la forma de Kotlin de representar varios valores a lo largo del tiempo, y en Android lo verás en pantallas, bases de datos y red. Esta lección es la parte 1: el flujo frío y sus operadores básicos (flow, collect, map, filter, filterIsInstance, flowOn); el resto llega en Flow: scan y combine.
Un flujo que no arranca hasta que alguien lo recoge
Una función suspend devuelve un valor, al final. Un Flow<T> entrega cero, uno o muchos valores de tipo T, a medida que se producen (T es el tipo de cada valor, como en List<Jugador>; lo viste en Genéricos). Se construye con flow { … }, y dentro se entrega cada valor con emit. Quien lo consume lo recoge (collect).
La palabra clave es frío: el bloque de flow { } no corre al crearlo. Corre cuando alguien lo recoge, y cada recogida lo ejecuta entero desde el principio.
Un operador es una función que se aplica a un flujo con un punto (flujo.map { … }), como las de las listas que ya conoces. Hay dos tipos:
| Tipo | Qué hace | Ejemplos |
|---|---|---|
| Intermedios | Reciben un flow y devuelven otro flow. No ejecutan nada. | map, filter, filterIsInstance, flowOn |
| Terminales | Arrancan la recogida y devuelven un resultado. Son suspend. | collect, toList, first, fold |
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
fun numeros(): Flow<Int> = flow {
println(" (el flow arranca)")
for (n in 1..5) {
delay(100) // trabajo que tarda
emit(n) // entrega un valor y espera a que el colector lo procese
}
}
fun main() = runBlocking {
val pares = numeros()
.filter { it % 2 == 0 } // se queda solo con los pares
.map { "número $it" } // transforma cada uno
println("Flow creado") // hasta aquí, nada ha corrido
pares.collect { println(it) }
}Puedes probarlo tal cual en el Kotlin Playground: import kotlinx.coroutines.* trae todo lo de ese paquete de una vez (runBlocking, delay) y el segundo import …* trae Flow, flow, filter y map. Imprime:
Flow creado
(el flow arranca)
número 2
número 4El número 2 sale hacia los 200 ms y el número 4 hacia los 400 ms, contados desde que arranca el flow. Fíjate en el orden: Flow creado sale antes que (el flow arranca).
flowOn: en qué hilo corre el flujo
Por defecto, el flujo corre donde corre quien lo recoge. Con flowOn(Dispatchers.Default) pasas lo que está antes de él (el flow { } y los operadores anteriores) a un hilo del grupo Default (Dispatchers y withContext); lo que va después sigue donde estaba quien recoge:
flow { … } → map { … } → flowOn(Default) → collect { … }
\_____ corre en Default _____/ \_ corre donde recoges _/Aparte, una regla: no uses withContext dentro de un flow { } para cambiar de hilo, porque falla en ejecución con una IllegalStateException. Para eso está flowOn.
Error típico: creer que el flow corre una sola vez
Guardas la narración en una variable y la recoges dos veces, esperando que la segunda sea «gratis». (PartidoEnVivo y narracion() los escribirás en el paso de abajo.)
val narracion = enVivo.narracion()
narracion.collect { println(" $it") } // un partido completo
val ms = measureTimeMillis { narracion.collect { } } // ¡otro partido completo!
println("Segundo collect: $ms ms")No da error. El segundo collect vuelve a jugar el partido entero: tarda algo más de un segundo, casi lo mismo que el primero (la cifra exacta varía de una ejecución a otra y depende de tu máquina; en el punto de control verás una medida). Si el flow hiciera una llamada de red, harías dos llamadas.
val lineas = enVivo.narracion().toList() // se juega el partido una vez
lineas.forEach { println(" $it") }
println("Otra vez, sin volver a jugar: ${lineas.size} líneas")Si necesitas repartir un mismo flujo entre varios espectadores sin repetirlo, hace falta un flujo caliente: es el tema de StateFlow y SharedFlow.
En el módulo de Swift verás su equivalente, AsyncSequence y AsyncStream: for await recoge los valores como aquí lo hace collect.
Reto · Ejercicio: un flow de cuenta atrás
En el Kotlin Playground, fuera de tu proyecto, que ya trae la librería de corrutinas.
Escribe una cuenta atrás que, al arrancar, avise con «(arranca la cuenta atrás)» y después entregue los números desde el que le pidas hasta 1, uno cada 100 ms. En main: crea la de 5 sin recogerla e imprime Creada; recógela dos veces sin hacer nada con los valores; recoge solo los pares convertidos a texto, como «par 4», e imprímelos; e imprime solo el primer valor. La consola debe mostrar:
Creada
(arranca la cuenta atrás)
(arranca la cuenta atrás)
(arranca la cuenta atrás)
par 4
par 2
(arranca la cuenta atrás)
Primero: 5Ver solución
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
fun cuentaAtras(desde: Int): Flow<Int> = flow {
println("(arranca la cuenta atrás)")
for (n in desde downTo 1) {
delay(100)
emit(n)
}
}
fun main() = runBlocking {
val cuenta = cuentaAtras(5)
println("Creada") // El aviso NO sale: es frío
cuenta.collect { } // El aviso sale...
cuenta.collect { } // ...y sale otra vez: dos veces en total
cuenta.filter { it % 2 == 0 }
.map { "par $it" }
.collect { println(it) } // par 4, par 2
println("Primero: ${cuenta.first()}") // 5, en unos 100 ms: corta el resto
}Con first(), la última línea termina en unos 100 ms, no en 500: cancela el flujo después del primer valor.
Paso · El partido como flujo de eventos
Objetivo. Crear PartidoEnVivo.kt, con eventos() como un Flow, y tres derivados: narracion(), goles() y primerGol(). Después, reescribir jugarPartido encima de eventos(). Al terminar, ./gradlew :torneo:run narra los cinco eventos del partido y demuestra que el segundo collect vuelve a jugarlo.
Esta lección no instala nada: Flow y sus operadores (kotlinx.coroutines.flow) vienen en la misma librería de corrutinas que añadiste en Corrutinas y suspend.
Qué hacer en cada fichero (el código completo está más abajo):
- Crea
PartidoEnVivo.kt. Entorneo → kotlin+java, clic derecho sobre el paquetecom.sebastiannarvaez.marcador.torneo→ New → Kotlin Class/File → File → escribePartidoEnVivoy pulsa Intro. Dentro,class PartidoEnVivo(val guion: Partido, val msPorMinuto: Long = 10):guiones el partido con todo lo que va a pasar ymsPorMinutocuánto dura un minuto de juego en tiempo real. Los tests dependen de esa forma. - Abre
Simulacion.kt(doble clic en su nodo, en el mismo paquete): añade elimportkotlinx.coroutines.flow.foldjunto a los de arriba y sustituye la funciónjugarPartido, con el comentario que lleva encima, por la nueva (con el comentario ya ajustado para ti). El resto del fichero no cambia. - Abre
Main.kty haz dos cambios: la llamadademoFlowFrio()al final del bloquerunBlockingdemain, trasdemoErrores(), y la funcióndemoFlowFrio()justo debajo de la llave que cierramain. Escribe la versión de más abajo. - El test se copia con el botón, al final.
Cosas que verás y conviene aclarar:
filterIsInstance<Gol>()en lugar defilter { it is Gol }: se queda con los elementos de ese tipo y cambia el tipo del flow deFlow<EventoDePartido>aFlow<Gol>. Confilter { it is Gol }, seguiría siendoFlow<EventoDePartido>.fold, el mismo de las listas de Colecciones, ahora sobre un flujo, es un operador terminal que acumula: recibe un valor inicial (unPartidovacío) y, por cada evento, una lambda{ jugado, evento -> … }que devuelve el acumulado nuevo. Aquí es lo que reconstruye elPartido.first()recoge el primer valor y cancela el resto del flujo: el partido no se sigue jugando por nada.jugarPartidocambia por dentro y no por fuera. Los tests de las lecciones anteriores siguen en verde: es una refactorización.emitdentro deforEach { emit(it) }funciona sin más: entrega cada elemento de la lista, uno tras otro.
Piezas nuevas de este paso: flow { … emit(x) … } dentro de una clase, filterIsInstance, map(::funcion) (una referencia a función, que pasa describir de Sealed: estados con datos como lambda), los terminales collect, toList, first y fold(inicial) { acumulado, elemento -> … }. (measureTimeMillis se vio en Corrutinas y suspend.) Todas en una estación del tiempo:
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.fold
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.runBlocking
import kotlin.system.measureTimeMillis
sealed interface Aviso { val hora: Int }
data class Lluvia(override val hora: Int, val litros: Int) : Aviso
data class Viento(override val hora: Int) : Aviso
fun leer(aviso: Aviso): String = "${aviso.hora}h: $aviso"
class Estacion(private val avisos: List<Aviso>, private val msPorHora: Long = 10) {
// flow { … }: no corre al llamar a la función, sino al recogerlo
fun avisos(): Flow<Aviso> = flow {
for (hora in 0..5) {
delay(msPorHora) // una hora de reloj
avisos.filter { it.hora == hora }.forEach { emit(it) } // emit dentro de forEach: entrega cada aviso de esa hora
}
}
fun lluvias(): Flow<Lluvia> = avisos().filterIsInstance<Lluvia>() // filtra Y cambia el tipo a Flow<Lluvia>
fun lectura(): Flow<String> = avisos().map(::leer) // ::leer: la función leer pasada como lambda
}
fun main() = runBlocking {
val estacion = Estacion(listOf(Lluvia(1, 3), Viento(2), Lluvia(4, 5)))
estacion.lectura().collect { println(it) } // collect: recoge cada valor
println(estacion.lluvias().toList()) // toList: recoge todos en una lista
println(estacion.avisos().first()) // first: el primero, y cancela el resto
// fold: valor inicial 0 y, por cada lluvia, el acumulado nuevo
val total = estacion.lluvias().fold(0) { litros, lluvia -> litros + lluvia.litros }
println("Litros: $total")
val ms = measureTimeMillis { estacion.avisos().toList() } // cada recogida vuelve a correr el flow
println("Otra recogida, otras 6 horas: ${ms >= 60}")
}Al ejecutarlo, imprime:
1h: Lluvia(hora=1, litros=3)
2h: Viento(hora=2)
4h: Lluvia(hora=4, litros=5)
[Lluvia(hora=1, litros=3), Lluvia(hora=4, litros=5)]
Lluvia(hora=1, litros=3)
Litros: 8
Otra recogida, otras 6 horas: trueIntenta escribirlo tú, en este orden:
eventos()entrega los eventos del guion uno a uno, a medida que pasan los minutos: los del minuto 0 al empezar y, después, minuto a minuto con su espera, como hacíajugarPartido. Corre en el grupo de hilosDefault.- Tres derivados:
narracion()entrega cada evento ya descrito como texto;goles()entrega solo los goles;primerGol()espera al primer gol, lo devuelve y no deja que el partido siga jugándose. jugarPartido, por dentro, pasa a construir el partido jugado acumulando los eventos deeventos(); por fuera no cambia, y sus tests siguen en verde.demoFlowFrio()prepara la narración del Rayo FC-Toros y avisa de que aún no ha corrido nada; la recoge mostrando cada línea; la vuelve a recoger, la misma, midiendo cuánto tarda; y por último muestra el primer gol.
Tras las demos anteriores, debe verse (los milisegundos varían; lo importante es que el aviso sale antes de la narración y que el segundo recorrido vuelve a durar más de un segundo):
== un Flow frío, la narración del partido ==
Flow creado; nada ha corrido todavía
12' Gol de Ana (Rayo FC)
30' Tarjeta amarilla para Iván
55' Gol de Iván (Toros)
80' Gol de Ana (Rayo FC)
85' Cambio: sale Ana, entra Luis
Segundo collect de la misma variable: otro partido completo (1284 ms)
Primer gol: 12' Gol de Ana (Rayo FC) (el resto del partido se canceló)Cuando termines, abre el bloque y compara.
Intenta primero. Cuando termines, compara con la solución.Mostrar el código
package com.sebastiannarvaez.marcador.torneo
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
// Un partido que se está jugando. `guion` es todo lo que va a pasar.
class PartidoEnVivo(val guion: Partido, val msPorMinuto: Long = 10) {
// Un Flow FRÍO: `flow { ... }` no hace nada hasta que alguien lo recoge con
// `collect` (o `first`, `toList`, `fold`…). Y cada `collect` ejecuta el bloque
// ENTERO otra vez, desde el principio: dos espectadores = dos partidos
// independientes. `emit` entrega un valor y suspende hasta que el que recoge
// lo ha procesado.
//
// `flowOn` cambia el dispatcher de lo que está ANTES (el `flow { }` y sus
// operadores anteriores); quien recoge sigue en su propio contexto.
fun eventos(): Flow<EventoDePartido> = flow {
guion.eventos.filter { it.minuto == 0 }.forEach { emit(it) }
val ultimoMinuto = maxOf(MINUTOS_DEL_PARTIDO, guion.eventos.maxOfOrNull { it.minuto } ?: 0)
for (minuto in 1..ultimoMinuto) {
delay(msPorMinuto)
guion.eventos.filter { it.minuto == minuto }.forEach { emit(it) }
}
}.flowOn(Dispatchers.Default)
// Operadores: cada uno devuelve OTRO Flow, y sigue sin ejecutarse nada hasta
// que se recoja el resultado.
fun narracion(): Flow<String> = eventos().map(::describir)
fun goles(): Flow<Gol> = eventos().filterIsInstance<Gol>()
// `first()` es un operador terminal que se queda con el primer valor y CANCELA
// el resto del flujo: el partido no se sigue jugando por nada.
suspend fun primerGol(): Gol = goles().first()
}En Simulacion.kt, el import y jugarPartido con su comentario entero. El resto del fichero no cambia.
import kotlinx.coroutines.flow.fold// `suspend` NO significa «corre en otro hilo». Significa «esta función puede
// pausarse»: dentro puede llamar a otras `suspend` (como `delay`) y, mientras
// espera, el hilo queda libre para hacer otra cosa. Solo se puede llamar desde
// otra `suspend` o desde una corrutina.
//
// `partido` es el GUION: todo lo que va a pasar. La función lo «juega» minuto a
// minuto y devuelve el partido tal como quedó. `msPorMinuto` es cuánto dura un
// minuto de juego en tiempo real (10 ms hace un partido de un segundo).
//
// Ahora el minuto a minuto vive en `PartidoEnVivo.eventos()` (un Flow) y
// aquí solo se acumula lo que emite: `fold` parte de un partido vacío y va
// registrando cada evento.
suspend fun jugarPartido(partido: Partido, msPorMinuto: Long = 10): Partido =
PartidoEnVivo(partido, msPorMinuto).eventos()
.fold(Partido(partido.local, partido.visitante)) { jugado, evento -> jugado.registrar(evento) }En Main.kt, la llamada y la función demoFlowFrio(). El resto del fichero no cambia.
println("== un Flow frío, la narración del partido ==")
demoFlowFrio()Escribe esta versión de demoFlowFrio. Se guarda narracion en una variable y esa misma variable se recoge dos veces, igual que en el error típico de arriba: así se ve lo frío. (Si llamaras a eventos() dos veces, cada llamada crearía un flow nuevo y no demostraría nada.)
suspend fun demoFlowFrio() {
val enVivo = PartidoEnVivo(Ejemplo.rayoContraToros)
// Aún no ha pasado nada: crear el Flow no arranca el partido.
val narracion = enVivo.narracion()
println("Flow creado; nada ha corrido todavía")
narracion.collect { println(" $it") }
// La MISMA variable, recogida otra vez: el frío se repite entero.
val ms = measureTimeMillis { narracion.collect { } }
println("Segundo collect de la misma variable: otro partido completo ($ms ms)")
val gol = enVivo.primerGol()
println("Primer gol: ${describir(gol)} (el resto del partido se canceló)")
}El test: se copia con el botón
FlowFrioTest.kt (6 pruebas: orden, narración, solo goles, frío, first y el primer gol) comprueba lo que acabas de escribir y se copia con el botón Copiar fichero, como en Clases y objetos.
- En
torneo → kotlin+java, clic derecho sobre el paquetecom.sebastiannarvaez.marcador.torneo (test)→ New → Kotlin Class/File → File → escribeFlowFrioTesty pulsa Intro. - Pulsa Copiar fichero en el bloque de abajo.
- En el editor, selecciona todo y pega: Cmd+A / Cmd+V. El texto copiado trae su propia línea
package.
El test usa AtomicInteger (un contador seguro entre hilos) y onEach (anota cada elemento que pasa); no hace falta dominarlos, solo copiarlo.
Punto de control
- En la Terminal de Android Studio,
./gradlew :torneo:runtermina enBUILD SUCCESSFULe imprime este bloque (los milisegundos del segundocollectvarían entre ejecuciones y dependen de tu máquina; si escribiste otro título, saldrá el tuyo):
== un Flow frío, la narración del partido ==
Flow creado; nada ha corrido todavía
12' Gol de Ana (Rayo FC)
30' Tarjeta amarilla para Iván
55' Gol de Iván (Toros)
80' Gol de Ana (Rayo FC)
85' Cambio: sale Ana, entra Luis
Segundo collect de la misma variable: otro partido completo (1284 ms)
Primer gol: 12' Gol de Ana (Rayo FC) (el resto del partido se canceló)./gradlew :torneo:testtermina enBUILD SUCCESSFULcon 112 pruebas: las 106 anteriores más las 6 deFlowFrioTest. Los tests deSimulacionTestyJornadaTestsiguen pasando tras reescribirjugarPartido.- Desde ahora
jugarPartidocorre enDefault: si ejecutas las demos de lecciones anteriores, las dos líneasPitido finalpueden salir en cualquier orden.
Comprueba lo que entendiste
5 preguntas · necesitas 4 para completar la lección
1.Ejecutas
val narracion = enVivo.narracion()y ninguna línea más. ¿Qué ha pasado con el partido?2.Recoges dos veces el mismo
Flowfrío del partido concollect. ¿Qué ocurre?3.¿Cuál es la diferencia entre
filter { it is Gol }yfilterIsInstance<Gol>()sobre unFlow<EventoDePartido>?4.En
flow { ... }.flowOn(Dispatchers.Default), ¿qué se ejecuta enDefault?5.¿Qué hace
first()sobreeventos().filterIsInstance<Gol>()?