Flow: scan y combine
Al terminar: Acumular valores con scan y combinar varios flujos con combine.
Al terminar esta lección la app Marcador sabrá seguir dos partidos a la vez y mostrarlos en una sola línea de consola («Rayo FC 1-0 Toros | Lobos 0-0 Águilas»), que se actualiza cada vez que marca cualquiera de los dos. Es lo que hace cualquier pantalla de resultados en vivo.
Para lograrlo aprenderás tres operadores de Flow: combine (juntar varios flujos en uno), scan (llevar una cuenta que emite cada paso) y distinctUntilChanged (no repetir el mismo valor dos veces seguidas). No instalas nada nuevo: todo sale de la librería de corrutinas que ya tienes en :torneo. Es la parte 2 de Flow; la parte 3, flatMapLatest y debounce, añade el cambio de partido y el buscador.
Juntar flujos: combine
Una pantalla de jornada necesita el marcador de dos partidos a la vez, y cada partido es su propio flujo. combine recibe varios flujos y una función que junta sus últimos valores. Cada vez que cualquiera de los flujos emite, combine vuelve a llamar a esa función y emite el resultado.
Para probarlo sin partidos, en el Kotlin Playground:
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.runBlocking
fun main() = runBlocking {
// Un flujo de números: el 1 a los 10 ms y el 2 a los 20 ms
val numeros = flow { for (n in 1..2) { delay(10); emit(n) } }
// Un flujo de letras: a los 15 ms, 30 ms y 45 ms
val letras = flow { for (l in listOf("a", "b", "c")) { delay(15); emit(l) } }
// Cada vez que cualquiera emite, junta el último número con la última letra
combine(numeros, letras) { n, l -> "$n$l" }.collect { println(it) }
}Al ejecutarlo, imprime:
1a
2a
2b
2cSale 1a y no 1 solo porque combine esperó a que los dos flujos hubieran emitido. Desde ahí, cada emisión cambia una mitad y conserva la otra: a los 20 ms llega el 2 y sale 2a (la letra sigue siendo la última, la a).
Como combine recoge los dos flujos a la vez, no importa cuál sea más rápido: cada uno avanza a su ritmo.
Error típico: un combine que no emite
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.runBlocking
fun main() = runBlocking {
val rapido = flow { delay(100); emit(1) }
val lento = flow { delay(3_000); emit("x") } // tarda 3 segundos en emitir
// No sale nada durante 3 segundos: espera al primer valor de `lento`
combine(rapido, lento) { n, s -> "$n$s" }.collect { println(it) }
}Imprime 1x, pero a los 3 segundos. Falla porque combine no puede combinar lo que no existe: tiene el 1 desde el principio, pero le falta el primer valor del flujo lento. La solución es que el flujo lento emita algo desde el principio:
val lento = flow { emit("…"); delay(3_000); emit("x") }Con eso salen 1… a los 100 ms y 1x a los 3 segundos. La solución de la app es la misma idea, y la enseña el apartado siguiente: scan parte de 0-0 para que cada partido emita algo antes de su primer gol.
Llevar una cuenta: scan y distinctUntilChanged
Dos operadores pequeños que la app usa para que el marcador de cada partido exista desde el minuto 0.
scan(inicial) { acumulado, valor -> … }va llevando una cuenta. Empieza con el valor inicial y, cada vez que llega un valor, calcula la cuenta nueva con la lambda. Es comofold(que recorre una colección acumulando y devuelve solo el resultado final), peroscanemite cada paso: primero el valor inicial y luego el resultado tras cada valor que llega.distinctUntilChanged()deja pasar un valor solo si es distinto del anterior.
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.runBlocking
fun main() = runBlocking {
println("scan:")
// flowOf(...) crea un flujo con esos valores, que termina al acabarlos.
// scan parte de 0 y emite el total tras cada paso.
flowOf(1, 0, 1, 1).scan(0) { total, n -> total + n }.collect { println(it) }
println("distinctUntilChanged:")
// Quita los repetidos que van seguidos (no los repetidos en general).
flowOf(0, 1, 1, 2, 2, 1).distinctUntilChanged().collect { println(it) }
}Al ejecutarlo, imprime:
scan:
0
1
1
2
3
distinctUntilChanged:
0
1
2
1Fíjate en que scan emite cinco valores para cuatro entradas: el 0 inicial más uno por entrada. Y en el segundo caso el último 1 sí sale, porque el anterior era un 2: solo se quitan los repetidos seguidos. El import kotlinx.coroutines.flow.* importa de golpe todos los nombres de ese paquete (flowOf, scan, collect…): el * significa «todo lo que haya dentro».
En la app, el valor inicial es Marcador(0, 0) y el paso es «si el evento es un gol, devuelve una copia del marcador con un gol más; si no, el mismo marcador». Así el flujo empieza en 0-0 y emite tras cada gol. Una tarjeta devuelve el marcador sin cambios, y distinctUntilChanged impide que se emita otra vez el mismo 1-0.
Todo junto, con cestas de la compra
Este ejemplo junta scan, distinctUntilChanged y combine con datos ajenos al proyecto. Te sirve de modelo para el ejercicio y para el paso. Tiene dos piezas que quizá no recuerdes: override fun toString() (cómo se escribe un objeto al imprimirlo, visto en Clases que se validan) y ::DosCestas, una referencia a constructor: es lo mismo que la referencia a función de Flow frío (::nombre), pero apuntando a un constructor, y sirve como la lambda { a, l -> DosCestas(a, l) }.
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.scan
import kotlinx.coroutines.runBlocking
// Un dato inmutable con su propio toString: así se imprime "3/1" y no Cesta(...)
data class Cesta(val frutas: Int, val verduras: Int) {
override fun toString(): String = "$frutas/$verduras"
}
data class DosCestas(val ana: Cesta, val leo: Cesta)
// scan: empieza en Cesta(0, 0) y, por cada producto, devuelve la cesta nueva (con copy)
fun llenar(productos: Flow<String>): Flow<Cesta> =
productos.scan(Cesta(0, 0)) { cesta, producto ->
if (producto == "manzana") cesta.copy(frutas = cesta.frutas + 1)
else cesta.copy(verduras = cesta.verduras + 1)
}.distinctUntilChanged()
fun main() = runBlocking {
val ana = llenar(flow { delay(10); emit("manzana"); delay(20); emit("col") })
val leo = llenar(flow { delay(15); emit("manzana") })
// ::DosCestas es una referencia al constructor: vale lo mismo que { a, l -> DosCestas(a, l) }
combine(ana, leo, ::DosCestas).collect { println("Ana ${it.ana} | Leo ${it.leo}") }
}Al ejecutarlo, imprime:
Ana 0/0 | Leo 0/0
Ana 1/0 | Leo 0/0
Ana 1/0 | Leo 1/0
Ana 1/1 | Leo 1/0La primera línea sale sin que nadie haya comprado nada, gracias al valor inicial de scan: sin él, combine esperaría a la primera compra de los dos.
Reto · Ejercicio: el marcador de baloncesto
En el Kotlin Playground, sin tocar tu proyecto.
En un partido de baloncesto, los locales anotan tres veces, una cada 30 ms: una canasta de 2 puntos, otra de 3 y un tiro fallado que vale 0. Los visitantes anotan una sola vez, 2 puntos, a los 45 ms. Cada equipo parte de 0 puntos y va sumando; el total de un equipo nunca debe aparecer dos veces seguidas. Muestra los dos marcadores juntos, con este formato, cada vez que cambie cualquiera de ellos. La consola debe mostrar:
Locales 0 - Visitantes 0
Locales 2 - Visitantes 0
Locales 2 - Visitantes 2
Locales 5 - Visitantes 2Para pensar: si no descartas los repetidos, aparece una quinta línea. ¿Cuál es y por qué sale?
Ver solución
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
// Un flujo que emite cada puntuación, esperando msEntre antes de cada una
fun canastas(puntos: List<Int>, msEntre: Long): Flow<Int> = flow {
for (p in puntos) { delay(msEntre); emit(p) }
}
fun main() = runBlocking {
// scan suma los puntos desde 0; distinctUntilChanged quita el total repetido
val local = canastas(listOf(2, 3, 0), 30).scan(0) { total, p -> total + p }.distinctUntilChanged()
val visitante = canastas(listOf(2), 45).scan(0) { total, p -> total + p }.distinctUntilChanged()
combine(local, visitante) { l, v -> "Locales $l - Visitantes $v" }.collect { println(it) }
}La quinta línea es un segundo Locales 5 - Visitantes 2: el tiro fallado vale 0, así que el total de los locales sigue en 5 y scan lo emite otra vez. Sin distinctUntilChanged, combine lo trata como un cambio y vuelve a mostrar la misma línea.
Paso · La jornada de Marcador
Objetivo. Crear Marcador.kt (el marcador como dato, su flujo y la combinación de los dos partidos) y una demo en Main.kt. Al terminar, ./gradlew :torneo:run imprime una jornada con siete líneas de dos marcadores. Esta lección no añade tests.
Crea Marcador.kt. En la vista Android, despliega torneo → kotlin+java. Haz clic derecho sobre el paquete com.sebastiannarvaez.marcador.torneo (el que no lleva (test) detrás; ahí está Main.kt) → New → Kotlin Class/File → File → escribe Marcador y pulsa Intro. Se abre Marcador.kt con la línea package ya escrita.
Main.kt ya existe: ahí añadirás una llamada a demoJornada() dentro de main (después de la última demo que hay, la del Flow frío) y definirás demoJornada() debajo de main.
Piezas de este paso:
- La clase
Marcador(local, visitante): unadata classinmutable con dosInt(cada gol produce una copia nueva concopy, como en data class e igualdad), una propiedadgoles(la suma) y unoverride fun toString()para que se imprima como «1-0» en lugar deMarcador(local=1, visitante=0). (La app entera se llama Marcador, pero esta es otra cosa: una clase pequeña con el marcador de un partido. En la lección, «la claseMarcador» es siempre esta.) Partido.marcador(): función de extensión que devuelve el marcador actual de unPartido(Marcador(golesLocal, golesVisitante)).PartidoEnVivo.marcadores(): Flow<Marcador>: los eventos del partido pasados porscan(parte de 0-0) ydistinctUntilChanged. Un gol suma allocalsievento.equipo == guion.local, y si no alvisitante.MarcadoresDeJornada(primero, segundo)congolesTotales: agrupa los dos marcadores para quecombinereciba un solo tipo.marcadoresDeLaJornada(primero, segundo): Flow<MarcadoresDeJornada>: combina los marcadores de dos partidos con la referencia al constructor.
Intenta escribirlo tú, en este orden:
Marcadory su flujo. Un marcador se imprime como «1-0».marcadores()de un partido en vivo empieza en 0-0 y entrega el marcador nuevo tras cada gol, sin repetir el mismo marcador dos veces seguidas.- La jornada.
marcadoresDeLaJornada(primero, segundo)entrega los dos marcadores juntos cada vez que cambia cualquiera de ellos. - La demo,
demoJornada(). Muestra la jornada Rayo FC-Toros y Lobos-Águilas, línea a línea, con los dos marcadores.
Tras las demos de lecciones anteriores, debe verse (el título puedes escribirlo como prefieras):
== la jornada, dos marcadores combinados ==
Rayo FC 0-0 Toros | Lobos 0-0 Águilas
Rayo FC 1-0 Toros | Lobos 0-0 Águilas
Rayo FC 1-0 Toros | Lobos 1-0 Águilas
Rayo FC 1-0 Toros | Lobos 1-1 Águilas
Rayo FC 1-1 Toros | Lobos 1-1 Águilas
Rayo FC 1-1 Toros | Lobos 1-2 Águilas
Rayo FC 2-1 Toros | Lobos 1-2 ÁguilasIntenta primero. Cuando termines, compara con la solución.Mostrar el código
package com.sebastiannarvaez.marcador.torneo
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.scan
// El marcador en un instante. Inmutable: cada gol produce uno nuevo.
data class Marcador(val local: Int, val visitante: Int) {
val goles: Int get() = local + visitante
override fun toString() = "$local-$visitante"
}
fun Partido.marcador(): Marcador = Marcador(golesLocal, golesVisitante)
// `scan` es como `fold` pero emite cada paso: parte de 0-0 y va emitiendo el
// marcador tras cada evento. `distinctUntilChanged` deja pasar solo los cambios
// (una tarjeta no cambia el marcador, así que no emite nada).
fun PartidoEnVivo.marcadores(): Flow<Marcador> =
eventos()
.scan(Marcador(0, 0)) { marcador, evento ->
if (evento is Gol) {
if (evento.equipo == guion.local) marcador.copy(local = marcador.local + 1)
else marcador.copy(visitante = marcador.visitante + 1)
} else marcador
}
.distinctUntilChanged()
// Los dos marcadores de la jornada, en un solo Flow.
data class MarcadoresDeJornada(val primero: Marcador, val segundo: Marcador) {
val golesTotales: Int get() = primero.goles + segundo.goles
}
// `combine` junta varios flujos: cada vez que CUALQUIERA emite, emite la
// combinación de los últimos valores de todos. No emite hasta que todos hayan
// emitido al menos una vez (por eso `scan` parte de 0-0).
fun marcadoresDeLaJornada(primero: PartidoEnVivo, segundo: PartidoEnVivo): Flow<MarcadoresDeJornada> =
combine(primero.marcadores(), segundo.marcadores(), ::MarcadoresDeJornada)En Main.kt hay un cambio: dentro de main la llamada, y debajo la función nueva. El resto del fichero no cambia.
…
println("== la jornada, dos marcadores combinados ==")
demoJornada()
…
suspend fun demoJornada() {
val uno = PartidoEnVivo(Ejemplo.rayoContraToros)
val otro = PartidoEnVivo(Ejemplo.lobosContraAguilas)
marcadoresDeLaJornada(uno, otro).collect { jornada ->
println(" ${Ejemplo.rayo.nombre} ${jornada.primero} ${Ejemplo.toros.nombre} | ${Ejemplo.lobos.nombre} ${jornada.segundo} ${Ejemplo.aguilas.nombre}")
}
}Punto de control
- En la Terminal de Android Studio,
./gradlew :torneo:runtermina enBUILD SUCCESSFUL. Tras las demos anteriores sale la sección de la jornada con siete líneas de marcadores, de0-0 | 0-0a2-1 | 1-2. ./gradlew :torneo:testpasa; abre el informe (como en Bucles y tu primer test) y comprueba que pone 112 tests: esta lección no añade ninguno.- Puedes explicar por qué
scanparte deMarcador(0, 0).
Comprueba lo que entendiste
4 preguntas · necesitas 3 para completar la lección
1.Combinas dos flujos con
combine. El segundo tarda 10 segundos en emitir su primer valor. ¿Qué ves durante esos 10 segundos?2.Una lista tiene los valores 1, 0, 1, 1. Si la recorres con
fold(0)sumando, y un flujo con esos valores conscan(0)sumando, ¿en qué se diferencian?3.Un flujo emite 0, 1, 1, 2, 2, 1. ¿Qué deja pasar
distinctUntilChanged()?4.En
combine(a, b, ::MarcadoresDeJornada), ¿qué es::MarcadoresDeJornada?