StateFlow y SharedFlow
Al terminar: Entender StateFlow y SharedFlow, y cuándo usar cada uno.
Al terminar esta lección Marcador tendrá un partido que no depende de quién lo mire: guardará su marcador en un valor que cualquiera puede leer y avisará de cada gol a quien esté escuchando. Todavía no habrá nada nuevo en pantalla: es la pieza que la lección siguiente (stateIn, shareIn y los flujos calientes) usa para que dos partidos se vean a la vez.
Hasta ahora cada espectador recibía su propio partido, porque un flow { } es frío. Aquí aprendes los dos flujos que existen aunque nadie mire: StateFlow y SharedFlow. Tampoco instalas nada: todo sale de la librería de corrutinas que ya tienes en :torneo.
Flujos calientes: StateFlow y SharedFlow
Un flujo caliente existe tenga o no suscriptores: no arranca cuando alguien lo recoge, ya está en marcha. Un suscriptor (en la documentación de Kotlin, un colector) es simplemente el código que llama a collect sobre el flujo; en esta lección las dos palabras significan lo mismo. Kotlin trae dos flujos calientes, para dos necesidades distintas: un estado (algo que tiene un valor ahora, como el marcador) y un evento (algo que ocurre en un instante, como un gol).
StateFlow | SharedFlow | |
|---|---|---|
| Valor actual | Siempre (value) | No |
| Valor inicial | Obligatorio | No |
| Quien llega tarde | Recibe el estado actual | Solo recibe lo que pase desde que llega (con replay, algo de lo anterior) |
| Valores iguales seguidos | Se descartan | Se entregan todos |
| Para | Estado que se pinta | Eventos que ocurren |
Los dos se crean mutables (que se pueden cambiar) para poder emitir y se exponen de solo lectura para que nadie de fuera emita:
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
class Reloj {
// Quien tiene el Reloj puede LEER el minuto, pero no cambiarlo.
private val _minuto = MutableStateFlow(0)
val minuto: StateFlow<Int> = _minuto.asStateFlow()
// Un evento suelto: sin valor actual y sin repetición.
private val _pitidos = MutableSharedFlow<String>()
val pitidos: SharedFlow<String> = _pitidos.asSharedFlow()
suspend fun avanzar() {
_minuto.update { it + 1 } // cambio atómico
_pitidos.emit("minuto ${_minuto.value}")
}
}
fun main() = runBlocking {
val r = Reloj() // un reloj nuevo: el minuto vale 0
r.avanzar() // el minuto pasa a 1 (nadie escucha los pitidos)
println(r.minuto.value) // lee el valor actual desde fuera
}Al ejecutarlo en el Playground sale:
1Fíjate en el par _minuto / minuto: por convención, el nombre con guion bajo es la versión privada y mutable y el nombre normal es la que ven los demás, de solo lectura. asStateFlow() y asSharedFlow() hacen esa conversión.
update { it + 1 } cambia el valor de forma atómica, es decir, de un solo golpe: si dos corrutinas lo llaman a la vez, ninguna pisa a la otra. (it es el valor de ahora; lo que devuelve la llave es el valor nuevo.) Y asignar un valor igual al actual no emite nada: por eso un StateFlow no repite 1-0 dos veces.
Ninguno de los dos deja de recoger por su cuenta: quien lo recoge sigue suscrito hasta que alguien cancele su corrutina. Un collect sobre un StateFlow o un SharedFlow no termina nunca por sí solo.
Un ejemplo completo: una caja registradora
Los dos flujos juntos, en una clase pequeña. La caja guarda el total de lo vendido (un estado) y avisa de cada venta grande (un evento). Léelo línea a línea:
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
class Caja(private val nombre: String, private val ventas: List<Int>) {
private var abierta = false
private val _total = MutableStateFlow(0) // estado: siempre tiene valor
val total: StateFlow<Int> = _total.asStateFlow()
// extraBufferCapacity = 16: hasta 16 avisos pendientes sin que emit se suspenda
private val _avisos = MutableSharedFlow<String>(extraBufferCapacity = 16)
val avisos: SharedFlow<String> = _avisos.asSharedFlow()
// Recoge la fuente UNA vez y alimenta los dos flujos
fun abrir(alcance: CoroutineScope): Job {
check(!abierta) { "La caja ya está abierta" } // si es false, IllegalStateException
abierta = true
return alcance.launch {
flow { ventas.forEach { delay(20); emit(it) } }.collect { euros ->
_total.update { it + euros } // cambio atómico
if (euros >= 50) _avisos.emit("$nombre: venta grande de $euros")
}
}
}
}
fun main() = runBlocking {
val norte = Caja("Norte", listOf(10, 60))
println("Antes de abrir: ${norte.total.value}")
// `this` es el alcance de runBlocking: la caja lanza su corrutina dentro de él
norte.abrir(this).join() // abre la caja y espera a que termine
println("Después de abrir: ${norte.total.value}")
try {
norte.abrir(this) // ya estaba abierta: check falla
println("esto no debería verse")
} catch (e: IllegalStateException) {
println(e.message)
}
}Al ejecutarlo en el Playground sale:
Antes de abrir: 0
Después de abrir: 70
La caja ya está abiertaTres cosas nuevas:
abrirrecoge la fuente una sola vez y alimenta a los dos flujos: el total sube con cada venta y, si la venta es grande, sale un aviso. Nadie escucha los avisos en estemain, y no pasa nada: unSharedFlowsin oyentes los deja pasar.MutableSharedFlow(extraBufferCapacity = 16)reserva sitio para 16 avisos pendientes, de modo queemitno se queda esperando si un oyente va lento.replaysigue en 0: quien llega tarde no ve los avisos anteriores.check(condición) { "mensaje" }lanza unIllegalStateExceptioncon ese mensaje si la condición es falsa (lo viste en Excepciones: try, catch y require). Aquí impide abrir la misma caja dos veces, que duplicaría las ventas.
En la lección siguiente pondrás dos cajas a la vez y sumarás sus totales. Ahora, a practicar.
Reto · Ejercicio: un marcador y sus avisos
En el Kotlin Playground, sin tocar tu proyecto.
- Crea un estado que empieza en 0 y un flujo de avisos de texto que no guarda nada para quien llega tarde.
- Al estado cámbiale el valor a 1, otra vez a 1 y luego a 2, con
update { }(la llave devuelve el valor nuevo); al flujo de avisos envíale «gol» y «tarjeta». Todo sin ningún suscriptor todavía. - Después suscríbete a los dos, cada uno en su corrutina, deja pasar 100 ms y cancélalas. Cada línea que recibas se imprime con su nombre delante.
La consola debe mostrar solo esta línea (del flujo de avisos no sale ninguna):
marcador: 2Explica por qué.
Ver solución
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
fun main() = runBlocking {
val marcador = MutableStateFlow(0)
val avisos = MutableSharedFlow<String>()
marcador.update { 1 } // la llave devuelve el valor nuevo
marcador.update { 1 }
marcador.update { 2 }
avisos.emit("gol") // sin suscriptores: nadie lo oye
avisos.emit("tarjeta")
val a = launch { marcador.collect { println("marcador: $it") } }
val b = launch { avisos.collect { println("aviso: $it") } }
delay(100)
a.cancel(); b.cancel()
}Imprime solo marcador: 2. El StateFlow guarda el estado actual y se lo da al suscriptor nuevo; los valores intermedios (y el 1 repetido) se perdieron. El SharedFlow no imprime nada: «gol» y «tarjeta» se emitieron cuando nadie escuchaba y no hay replay. Estado: te interesa el último. Evento: solo lo ve quien estaba presente.
Paso · Un partido que se comparte
Objetivo. PartidoEnVivo gana marcador (un StateFlow), goles (un SharedFlow) e iniciar; Marcador.kt gana despuesDe; y goles() pasa a llamarse golesEnFrio(). No se ve nada nuevo al ejecutar la app, y hoy no hay tests nuevos: solo compruebas que compila y que los tests de antes siguen en verde. Los tests de estas piezas llegan en la lección siguiente.
Todo se hace sobre ficheros que ya tienes. Dos decisiones a tener en cuenta:
goles()pasa agolesEnFrio(). AhoraPartidoEnVivotendrá una propiedadgoles(elSharedFlow, caliente). Si la función de ahora siguiera llamándosegoles(), dos cosas muy distintas (un flujo frío y uno caliente) se distinguirían solo por unos paréntesis, y es muy fácil leer o escribir una por la otra. CongolesEnFrio()el nombre ya dice qué es. Es el único cambio a algo ya escrito, y afecta a dos sitios:primerGolyFlowFrioTest.despuesDese extrae deMarcador.ktpara que el flujo frío (marcadores()) y el caliente (iniciar) calculen el marcador con la misma función.
Dónde va cada cosa. Despliega torneo → kotlin+java → com.sebastiannarvaez.marcador.torneo y abre cada fichero con doble clic:
PartidoEnVivo.kt: los imports arriba; los flujos calientes yiniciaral final de la clase, antes de la llave que la cierra.Marcador.kt:despuesDeva antes demarcadores(), que pasa a usarla.FlowFrioTest.kt(en el paquete que lleva(test)): una línea con el nombre nuevo.
Piezas nuevas de PartidoEnVivo:
val marcador: StateFlow<Marcador>, con unMutableStateFlow(Marcador(0, 0))privado detrás, yval goles: SharedFlow<Gol>, con unMutableSharedFlow<Gol>privado. Es el mismo patrón de la caja.fun iniciar(alcance: CoroutineScope): Job, que recogeeventos()una vez y alimenta los dos. UnCoroutineScopees el alcance, la «zona» de la que cuelgan las corrutinas y que decide cuándo se cancelan (Alcance, Job y cancelación); unJobes el mando de una corrutina.check(!iniciado) { "El partido ya empezó" }: como en la caja, impide llamar dos veces ainiciar, que duplicaría los goles.
Intenta escribirlo tú, en este orden:
- El cambio de nombre:
goles()pasa agolesEnFrio(), enPartidoEnVivoy enFlowFrioTest. despuesDe(evento, partido): el marcador que queda tras un evento (un gol suma al equipo que marcó; cualquier otro evento lo deja igual).marcadores()pasa a usarlo.- Dentro de
PartidoEnVivo:marcadores el estado actual del partido, que empieza en 0-0 y que todos pueden leer pero solo el partido cambia;golesavisa de cada gol a quien esté escuchando en ese momento, sin repetir los anteriores a quien llegue tarde.iniciar(alcance)juega el partido una sola vez, actualizando los dos, y se niega a empezar dos veces («El partido ya empezó»).
Intenta primero. Cuando termines, compara con la solución.Mostrar el código
En PartidoEnVivo.kt hay dos cambios. Los imports que faltan (MutableStateFlow, StateFlow, MutableSharedFlow, SharedFlow, asStateFlow, asSharedFlow, update, CoroutineScope, Job y launch) los ofrece Android Studio con Option+Enter sobre cada nombre en rojo.
Primero el renombrado. En PartidoEnVivo.kt, goles() pasa a golesEnFrio(), y primerGol la usa:
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() fun golesEnFrio(): 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 = golesEnFrio().first()Después, los flujos calientes y iniciar. Van al final de la clase, antes de la llave que la cierra (después de narracion() y de primerGol). Con comentarios cortos:
…
// `eventos()` es frío: cada espectador ve SU partido. Un partido de verdad es
// uno solo y todos ven lo mismo, y para eso hacen falta flujos calientes.
// StateFlow: siempre tiene un valor (`value`), el estado actual, y quien se
// suscribe recibe primero ese valor. Si llegan dos valores muy seguidos, un
// espectador lento solo ve el último, y los valores iguales al anterior se
// descartan. Es la elección para ESTADO que se pinta: el marcador.
private val marcadorActual = MutableStateFlow(Marcador(0, 0))
val marcador: StateFlow<Marcador> = marcadorActual.asStateFlow()
// SharedFlow: un canal de EVENTOS. Sin valor actual y sin repetición
// (`replay = 0`): quien se suscribe tarde NO ve los goles que ya pasaron, y
// cada gol llega a todos los suscriptores, uno por uno. Es la elección para
// cosas que ocurren (un gol, un aviso), no para estado.
private val golesEmitidos = MutableSharedFlow<Gol>(extraBufferCapacity = 16)
val goles: SharedFlow<Gol> = golesEmitidos.asSharedFlow()
private var iniciado = false
// Arranca el partido: recoge `eventos()` UNA vez y alimenta a los dos flujos
// calientes. Devuelve el Job, que termina al pitar el final. Se ejecuta en el
// alcance que le den, así que quien lo da decide cuándo se cancela todo.
fun iniciar(alcance: CoroutineScope): Job {
check(!iniciado) { "El partido ya empezó" }
iniciado = true
return alcance.launch {
eventos().collect { evento ->
if (evento is Gol) {
marcadorActual.update { it.despuesDe(evento, guion) }
golesEmitidos.emit(evento)
}
}
}
}
}El resto del fichero no cambia. En Marcador.kt se añade despuesDe y marcadores() lo usa:
// El marcador tras un evento: solo un gol lo cambia.
fun Marcador.despuesDe(evento: EventoDePartido, partido: Partido): Marcador = when {
evento !is Gol -> this
evento.equipo == partido.local -> copy(local = local + 1)
else -> copy(visitante = visitante + 1)
}
// `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 -> marcador.despuesDe(evento, guion) }
.distinctUntilChanged()
En FlowFrioTest.kt cambia una línea, la del nombre nuevo:
assertEquals(3, enVivo.goles().toList().size) assertEquals(3, enVivo.golesEnFrio().toList().size)Ejecuta los tests: en la Terminal de Android Studio, ./gradlew :torneo:test. Debe terminar en BUILD SUCCESSFUL.
Punto de control
./gradlew :torneo:testtermina enBUILD SUCCESSFUL. Abre el informe (como en Bucles y tu primer test) y comprueba que arriba pone 118 tests: los mismos de antes, conFlowFrioTesten verde tras el cambio de nombre.PartidoEnVivotienemarcador,goleseiniciar, yMarcador.kttienedespuesDe.- Sabes decir cuándo usarías un
StateFlowy cuándo unSharedFlow.
La lección siguiente, stateIn, shareIn y los flujos calientes, pone dos partidos a la vez con estos flujos y llega a 127 tests.
Comprueba lo que entendiste
4 preguntas · necesitas 3 para completar la lección
1.Un espectador se conecta al partido en el minuto 60. ¿Qué recibe de un
StateFlow<Marcador>y de unSharedFlow<Gol>(sinreplay)?2.¿Por qué
PartidoEnVivoguarda unMutableStateFlowprivado y expone unStateFlowconasStateFlow()?3.¿Qué diferencia hay entre un
Channely unSharedFlow?4.La caja ya está abierta y alguien llama otra vez a
abrir, que empieza concheck(!abierta) { "La caja ya está abierta" }. ¿Qué pasa?