flatMapLatest y debounce
Al terminar: Cambiar de flujo con flatMapLatest y esperar a que el usuario deje de escribir con debounce.
Al terminar esta lección la app Marcador sabrá cambiar de partido sin que el anterior siga mandando marcadores, y esperar a que el usuario deje de escribir antes de buscar un equipo. Son las dos cosas que faltan en una pantalla de resultados en vivo: elegir un partido y buscar.
Los dos operadores que lo hacen posible son flatMapLatest (pasar a otro flujo y abandonar el anterior) y debounce (esperar a que algo se calme). Los dos están marcados como «aún pueden cambiar», así que también verás qué es un opt-in y qué es el aviso w: del compilador. Es la parte 3 de Flow; sigue a Flow: scan y combine.
Cambiar de flujo: flatMapLatest
Imagina que el usuario elige un partido para seguirlo y luego cambia a otro. El marcador del primero no debe seguir llegando. flatMapLatest convierte cada valor del flujo de entrada en otro flujo y, cuando llega un valor nuevo, cancela el flujo anterior: solo el último sigue emitiendo.
Qué es un opt-in. Una API es lo que una librería ofrece para que la uses. Algunas piezas de la librería se marcan como «experimentales» o «en vista previa»: funcionan, pero sus autores se reservan cambiarlas en el futuro. Para usarlas debes decirlo con @OptIn(…) («acepto el riesgo»), con la marca que corresponda dentro: aquí ExperimentalCoroutinesApi::class. Es el mismo mecanismo que el @OptIn de GlobalScope en Un alcance propio. Sin la anotación, el compilador avisa.
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.runBlocking
@OptIn(ExperimentalCoroutinesApi::class) // acepto usar una API experimental (el opt-in, explicado arriba)
fun main() = runBlocking {
// El flujo de entrada emite "a" y, 100 ms después, "b"
flow { emit("a"); delay(100); emit("b") }
.flatMapLatest { valor ->
// Para cada valor, un flujo nuevo: el valor ya, y 200 ms después el valor con «_ultimo»
flow { emit(valor); delay(200); emit(valor + "_ultimo") }
}
.collect { println(it) }
}Al ejecutarlo, imprime:
a
b
b_ultimoa_ultimo nunca sale: el flujo de a necesitaba 200 ms para emitirlo, pero a los 100 ms llegó b y flatMapLatest lo canceló.
Error típico: flatMapConcat en vez de flatMapLatest
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.flatMapConcat
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.runBlocking
@OptIn(ExperimentalCoroutinesApi::class)
fun main() = runBlocking {
flow { emit("a"); delay(100); emit("b") }
.flatMapConcat { valor -> // espera a que termine cada flujo interno
flow { emit(valor); delay(200); emit(valor + "_ultimo") }
}
.collect { println(it) }
}Imprime:
a
a_ultimo
b
b_ultimoflatMapConcat termina cada flujo interno antes de empezar el siguiente. a_ultimo llega aunque ya se pidió b, y en una pantalla eso son resultados viejos que llegan tarde. Con flatMapLatest el resultado es a, b, b_ultimo.
Esperar a que se calmen: debounce
Un buscador que consulta la red en cada letra hace tres búsquedas para «ray». debounce(ms) deja pasar un valor solo si no llega otro en los siguientes ms milisegundos; el último siempre pasa.
Un ejemplo con un buscador de tiendas. contains(texto, ignoreCase = true) pregunta si un nombre contiene el texto sin mirar mayúsculas:
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.runBlocking
@OptIn(FlowPreview::class) // debounce está en vista previa: sin esto, avisa
fun main() = runBlocking {
val tiendas = listOf("Frutería Sol", "Mercado Luna")
// Alguien escribe «f», 50 ms después «fru», y luego se calla 400 ms
flow { emit("f"); delay(50); emit("fru"); delay(400) }
.debounce(300) // solo pasa lo que va seguido de 300 ms de calma
.map { texto -> tiendas.filter { it.contains(texto, ignoreCase = true) } }
.collect { println(it) }
}Al ejecutarlo, imprime una sola búsqueda, la del último texto:
[Frutería Sol]debounce tiene un aviso. Está en estado de «vista previa», y si no pones su @OptIn el compilador lo avisa con una línea que empieza por w: (de warning, «aviso»). Un aviso no es un error: el programa compila y funciona. En el paso de más abajo lo verás aparecer en tu Terminal (cuando compiles con ./gradlew :torneo:run), y al final verás por qué se queda y cómo se silenciaría.
Error típico: una pausa mayor que el ritmo del flujo
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.runBlocking
@OptIn(FlowPreview::class)
fun main() = runBlocking {
// Emite un valor cada 100 ms durante 2 segundos
flow { repeat(20) { emit(it); delay(100) } }
.debounce(300)
.collect { println(it) }
}Imprime un solo valor:
19Cada valor llega antes de que pasen 300 ms desde el anterior, así que todos se descartan salvo el último, que sale al terminar el flujo. Un debounce sobre un flujo continuo (un marcador que se actualiza sin parar) no deja pasar casi nada. Sirve para un teclado, que se para; no para un reloj.
Reto · Ejercicio: un buscador que no se atropella
En el Kotlin Playground, sin tocar tu proyecto.
Simula a alguien que escribe «a», «ab» y «abc» con 50 ms entre cada una y luego se calla medio segundo. Busca solo cuando lleve 100 ms sin escribir y, si llega otra consulta mientras buscas, abandona la búsqueda anterior. Cada búsqueda avisa «buscando» seguido del texto, tarda 300 ms y entrega «resultados de» seguido del texto. Los dos operadores piden aceptar su marca de API, como en los ejemplos de esta lección. La consola debe mostrar:
buscando abc
resultados de abcDespués quita la espera de los 100 ms y vuelve a ejecutar. Ahora debe mostrar:
buscando a
buscando ab
buscando abc
resultados de abcVer solución
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
@OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class) // una @OptIn admite varias marcas
fun main() = runBlocking {
flow {
for (texto in listOf("a", "ab", "abc")) { emit(texto); delay(50) }
delay(500) // luego se calla medio segundo
}
.debounce(100) // solo busca tras 100 ms sin escribir
.flatMapLatest { texto -> // cada consulta lanza una búsqueda y cancela la anterior
flow {
println("buscando $texto")
delay(300)
emit("resultados de $texto")
}
}
.collect { println(it) }
}Con debounce sale un buscando abc y un resultado. Sin él salen tres buscando (a, ab, abc), pero sigue llegando un solo resultado: flatMapLatest cancela las dos primeras búsquedas. Cada uno protege de algo distinto: debounce evita lanzar búsquedas inútiles; flatMapLatest evita mostrar resultados viejos.
Paso · Seguir un partido y buscar equipos
Objetivo. Añadir seguirPartido y buscarEquipos a Marcador.kt, ampliar la demo de Main.kt y copiar MarcadorTest.kt. Al terminar, ./gradlew :torneo:run imprime también el seguimiento de un partido con cambio a los 300 ms, y :torneo:test pasa 118 tests.
Piezas de este paso:
seguirPartido(elegido: Flow<PartidoEnVivo>): Flow<Marcador>: recibe un flujo de partidos elegidos y entrega el marcador del último elegido, abandonando el anterior.buscarEquipos(consulta: Flow<String>, equipos: List<Equipo>, pausaMs: Long = 300): Flow<List<Equipo>>: espera a que la consulta deje de cambiar durantepausaMs(300 si no se dice) y entrega los equipos cuyo nombre la contiene, sin mirar mayúsculas. Una consulta vacía da una lista vacía.
Intenta escribirlo tú, en este orden:
seguirPartido(elegido): entrega el marcador del partido elegido y, si se elige otro, abandona el anterior y sigue el nuevo.buscarEquipos(consulta, equipos, pausaMs), como se acaba de describir.- La demo: al final de
demoJornada(), sigue el primer partido y, a los 300 ms, cambia al segundo, mostrando cada marcador que ve.
Después del seguimiento debe verse:
Siguiendo un partido y cambiando a los 300 ms:
marcador visto: 0-0
marcador visto: 1-0
marcador visto: 0-0
marcador visto: 1-0
marcador visto: 1-1
marcador visto: 1-2Al ejecutar ./gradlew :torneo:run, antes de las demos, saldrá una línea que empieza por w:: es el aviso de debounce (más abajo se ve completa). Después abre el bloque y compara.
Intenta primero. Cuando termines, compara con la solución.Mostrar el código
Añade estos import arriba de Marcador.kt (Android Studio te los ofrece con Option+Enter sobre cada nombre en rojo) y las dos funciones al final del fichero. Lo que ya tenías no cambia.
package com.sebastiannarvaez.marcador.torneo
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
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)
// `flatMapLatest`: cada vez que llega un partido nuevo a seguir, CANCELA el
// marcador del anterior y empieza a recoger el nuevo. Es lo que quieres cuando
// el usuario cambia de partido en pantalla: no seguir mandando datos del viejo.
@OptIn(ExperimentalCoroutinesApi::class)
fun seguirPartido(elegido: Flow<PartidoEnVivo>): Flow<Marcador> = elegido.flatMapLatest { it.marcadores() }
// `debounce`: espera a que el que escribe se calle `pausaMs` antes de dejar
// pasar la última consulta. «r», «ra», «ray» seguidos producen una sola búsqueda.
fun buscarEquipos(consulta: Flow<String>, equipos: List<Equipo>, pausaMs: Long = 300): Flow<List<Equipo>> =
consulta
.debounce(pausaMs)
.map { it.trim() }
.distinctUntilChanged()
.map { texto ->
if (texto.isEmpty()) emptyList() else equipos.filter { it.nombre.contains(texto, ignoreCase = true) }
}En Main.kt hay dos cambios: un import arriba y, al final de demoJornada(), el seguimiento. El resto del fichero no cambia.
import kotlinx.coroutines.flow.flow
…
suspend fun demoJornada() {
…
marcadoresDeLaJornada(uno, otro).collect { jornada ->
println(" ${Ejemplo.rayo.nombre} ${jornada.primero} ${Ejemplo.toros.nombre} | ${Ejemplo.lobos.nombre} ${jornada.segundo} ${Ejemplo.aguilas.nombre}")
}
// flatMapLatest: cambiar de partido a los 300 ms cancela el seguimiento del primero.
val elegidos = flow {
emit(uno); delay(300); emit(otro)
}
println("Siguiendo un partido y cambiando a los 300 ms:")
seguirPartido(elegidos).collect { println(" marcador visto: $it") }
}emit(uno); delay(300); emit(otro) elige un partido, espera 300 ms y elige otro. El primer partido llega a marcar (a los 120 ms, con 10 ms por minuto) antes del cambio; el del minuto 55 ya no se ve. Y uno vuelve a empezar en 0-0 aunque ya lo viste en la jornada de arriba: un flujo frío, al seguirlo otra vez, se vuelve a jugar desde el principio.
El aviso de debounce. Al compilar, Gradle imprime algo así en la Terminal (la ruta y el número de línea cambian si tu Marcador.kt no es idéntico al de la lección):
w: …/Marcador.kt:55:10 This declaration is in a preview state and can be changed in a backwards-incompatible manner with a best-effort migration. Its usage should be marked with '@kotlinx.coroutines.FlowPreview' or '@OptIn(kotlinx.coroutines.FlowPreview::class)' if you accept the drawback of relying on preview APIAhora el test. Sus seis pruebas comprueban lo que has escrito y las rellenas con el botón Copiar fichero (explicado en Clases y objetos: copia el contenido al portapapeles; el fichero lo creas tú). El orden es siempre el mismo:
- En
torneo → kotlin+java, clic derecho sobre el paquetecom.sebastiannarvaez.marcador.torneo (test)(el que lleva(test)) → New → Kotlin Class/File → File →MarcadorTest. Se abre con solo la líneapackage. - Pulsa el botón Copiar fichero de abajo. Aparecerá un aviso de que se ha copiado.
- Haz clic dentro de
MarcadorTest.kt, selecciona todo con Cmd+A y pega con Cmd+V. El contenido pegado ya trae su líneapackage, así que sustituye la que puso el IDE.
Dos de esas pruebas enseñan algo que aún no has visto. Las dos usan runBlocking, que ya conoces: el cuerpo del test es una corrutina y el test espera a que termine. Y toList() recoge un flujo entero en una lista (el flujo debe terminar para que toList() devuelva algo).
@Test
fun flatMapLatestDejaDeSeguirAlPartidoAnterior() = runBlocking {
val elegidos = flow {
emit(PartidoEnVivo(Ejemplo.rayoContraToros, msPorMinuto = 5)) // el gol del 12 llegaría a los ~60 ms
delay(30) // cambia de partido antes de verlo
emit(PartidoEnVivo(Ejemplo.lobosContraAguilas, msPorMinuto = 1))
}
val marcadores = seguirPartido(elegidos).toList()
// 0-0 del primero, 0-0 del segundo y los goles del segundo: nunca el 1-0 del primero.
assertEquals(listOf("0-0", "0-0", "1-0", "1-1", "1-2"), marcadores.map { it.toString() })
}El primer partido va lento (5 ms por minuto de juego), así que su primer gol, el del minuto 12, llegaría a los 60 ms. El flujo elegidos cambia de partido a los 30 ms: flatMapLatest cancela el primero antes de que marque, y el 1-0 de ese partido nunca aparece en la lista.
@Test
fun debounceSoloBuscaLoUltimoQueSeEscribio() = runBlocking {
val teclado = flow {
for (texto in listOf("r", "ra", "ray")) { emit(texto); delay(10) }
delay(200)
}
val busquedas = buscarEquipos(teclado, listOf(Ejemplo.rayo, Ejemplo.toros), pausaMs = 100).toList()
assertEquals(listOf(listOf(Ejemplo.rayo)), busquedas)
}El «teclado» escribe tres textos con 10 ms entre cada uno y luego se calla 200 ms. Con una pausa de 100 ms, debounce descarta «r» y «ra» (cada una va seguida de otra antes de 100 ms) y deja pasar solo «ray»: una única búsqueda, que encuentra a Rayo FC.
buscarEquipos está en la app como el caso realista de debounce (un buscador que no consulta con cada tecla): la ejercita este test y demoJornada no la llama.
Ejecuta los tests: en la Terminal de Android Studio, ./gradlew :torneo:test. Debe terminar en BUILD SUCCESSFUL.
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) y seis líneasmarcador visto:0-0,1-0,0-0,1-0,1-1,1-2. ./gradlew :torneo:testpasa; abre el informe (como en Bucles y tu primer test) y comprueba que pone 118 tests, de ellos 6 deMarcadorTest.- Al compilar
Marcador.ktaparece el avisow:dedebouncey no es un error: se queda así. - Puedes explicar para qué sirve cada operador del buscador:
debounceyflatMapLatest.
Comprueba lo que entendiste
4 preguntas · necesitas 3 para completar la lección
1.¿Qué hace
flatMapLatestcuando llega un valor nuevo mientras el flujo anterior sigue activo?2.Un usuario escribe «r», «ra» y «ray» con 50 ms entre letras y luego deja de escribir. Con
debounce(300), ¿qué llega a la búsqueda?3.Compilas
buscarEquiposy salew: … This declaration is in a preview state …. ¿Qué significa y qué haces?4.En un buscador con
debounceyflatMapLatest, ¿de qué protege cada uno?