Saltar al contenido
K
Corrutinas y FlowPaso a paso30 min de lectura · 6 min de máquina Intermedio

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:

ejemplo mínimo: combine (con comentarios)
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
2c

Sale 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

así no vale: el segundo flujo tarda y no sale nada (con comentarios)
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:

corregido: valor inicial en el flujo lento
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 como fold (que recorre una colección acumulando y devuelve solo el resultado final), pero scan emite 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.
ejemplo mínimo: scan y distinctUntilChanged (con comentarios)
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
1

Fí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) }.

ejemplo mínimo: scan, distinctUntilChanged y combine con ::Constructor (con comentarios)
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/0

La 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 2

Para pensar: si no descartas los repetidos, aparece una quinta línea. ¿Cuál es y por qué sale?

Ver solución
solución: el marcador de baloncesto
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.

torneo → kotlin+java → com.sebastiannarvaez.marcador.torneo
Marcador.ktse crea
Main.ktse modifica
Un fichero nuevo y `Main.kt` modificado, tal como los ves en la vista Android.

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): una data class inmutable con dos Int (cada gol produce una copia nueva con copy, como en data class e igualdad), una propiedad goles (la suma) y un override fun toString() para que se imprima como «1-0» en lugar de Marcador(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 clase Marcador» es siempre esta.)
  • Partido.marcador(): función de extensión que devuelve el marcador actual de un Partido (Marcador(golesLocal, golesVisitante)).
  • PartidoEnVivo.marcadores(): Flow<Marcador>: los eventos del partido pasados por scan (parte de 0-0) y distinctUntilChanged. Un gol suma al local si evento.equipo == guion.local, y si no al visitante.
  • MarcadoresDeJornada(primero, segundo) con golesTotales: agrupa los dos marcadores para que combine reciba 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:

  1. Marcador y 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.
  2. La jornada. marcadoresDeLaJornada(primero, segundo) entrega los dos marcadores juntos cada vez que cambia cualquiera de ellos.
  3. 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 Águilas
Intenta primero. Cuando termines, compara con la solución.Mostrar el código
torneo/src/main/java/com/sebastiannarvaez/marcador/torneo/Marcador.kt
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.

Main.kt: llamada y función
    …
 
        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:run termina en BUILD SUCCESSFUL. Tras las demos anteriores sale la sección de la jornada con siete líneas de marcadores, de 0-0 | 0-0 a 2-1 | 1-2.
  • ./gradlew :torneo:test pasa; 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é scan parte de Marcador(0, 0).

Comprueba lo que entendiste

4 preguntas · necesitas 3 para completar la lección

  1. 1.Combinas dos flujos con combine. El segundo tarda 10 segundos en emitir su primer valor. ¿Qué ves durante esos 10 segundos?

  2. 2.Una lista tiene los valores 1, 0, 1, 1. Si la recorres con fold(0) sumando, y un flujo con esos valores con scan(0) sumando, ¿en qué se diferencian?

  3. 3.Un flujo emite 0, 1, 1, 2, 2, 1. ¿Qué deja pasar distinctUntilChanged()?

  4. 4.En combine(a, b, ::MarcadoresDeJornada), ¿qué es ::MarcadoresDeJornada?

Faltan 4 de 4