wandres.dev
FLOW Y STATEFLOW · streams en Kotlin

SharedFlow y Channel: eventos frente a estado

Hay salidas que no describen el mundo sino que lo cambian una vez: navegar, avisar, vibrar. Esta lección estudia las dos primitivas calientes que Kotlin ofrece para ellas y explica por qué la elección entre ambas es una decisión de semántica y no de gusto. SharedFlow es la difusión configurable —replay, capacidad de búfer y política de desbordamiento— que entrega cada emisión a todos los suscriptores presentes y descarta lo emitido cuando no hay ninguno; Channel es la cola de entrega exactamente una vez, que retiene lo emitido hasta que alguien lo recoge y se lo da a un solo receptor. Se desmonta después el error más extendido del ecosistema Android: usar StateFlow para eventos, con su bug del toast que resucita al girar la pantalla, y se examinan los parches históricos —banderas booleanas, envoltorios de evento consumido, SingleLiveEvent— como epiciclos que simulan una semántica que el estado por naturaleza no tiene. Cierra con la regla de decisión práctica entre replay cero y canal con búfer según si perder un evento sin suscriptores es aceptable.

⏱ 17 min

StateFlow responde perfectamente a la pregunta “¿cómo están las cosas ahora?”, y precisamente por eso responde fatal a la pregunta “¿qué acaba de pasar?”. Un estado se retiene, se reemite a quien llegue y se deduplica; un evento ocurre una vez, va dirigido a quien esté escuchando en ese instante y no debe repetirse jamás. Confundir ambos no produce un error de compilación: produce el bug más reconocible de la plataforma Android, el aviso que reaparece cuando el usuario gira el teléfono sin haber hecho nada. Esta lección estudia las dos primitivas que Kotlin ofrece para los hechos puntuales —SharedFlow y Channel— y, sobre todo, cómo elegir entre ellas leyendo su semántica en vez de su popularidad.

🎯 Al terminar esta lección sabrás
  • Configurar un SharedFlow entendiendo qué hacen replay, la capacidad extra y la política de desbordamiento.
  • Usar un Channel como cola de entrega exactamente una vez y saber cuándo esa garantía es la que necesitas.
  • Diagnosticar el bug del aviso que resucita y explicar por qué nace de modelar un evento como estado.
  • Aplicar una regla de decisión clara entre StateFlow, SharedFlow y Channel según la semántica de cada salida.

SharedFlow: difusión caliente y configurable

SharedFlow es el flujo caliente general, del que StateFlow es un caso particular muy afinado. No exige valor inicial, no está obligado a conflar y no deduplica: emite lo que le pidas, cuando se lo pidas, a todos los suscriptores que haya en ese momento. Su comportamiento se ajusta con tres parámetros que conviene entender por separado, porque de su combinación sale toda la familia de flujos calientes.

El primero es replay: cuántos valores pasados recibe un suscriptor nuevo nada más engancharse. Con replay = 0 no recibe nada de lo anterior, que es lo correcto para un evento; con replay = 1 recibe el último, que es esencialmente lo que hace un StateFlow. El segundo es la capacidad de búfer adicional, que amortigua cuando el productor va más rápido que los consumidores. Y el tercero es la política de desbordamiento: si el búfer se llena, SUSPEND frena al emisor, DROP_OLDEST tira lo más viejo y DROP_LATEST descarta lo recién llegado.

private val _eventos = MutableSharedFlow<UiEvent>(
    replay = 0,                                        // nada de historia para el nuevo
    extraBufferCapacity = 1,                           // margen para no suspender
    onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
val eventos: SharedFlow<UiEvent> = _eventos.asSharedFlow()

fun avisar(mensaje: String) {
    _eventos.tryEmit(UiEvent.Aviso(mensaje))   // no suspende; devuelve si entro
}

Hay un detalle que decide muchos diseños: con replay = 0 y ningún suscriptor, una emisión se pierde. No se guarda para más tarde, no espera a nadie: se difunde al vacío y desaparece. Para un indicador en vivo eso está bien —si nadie mira, nadie se pierde nada relevante—, pero para una orden de navegación emitida mientras la pantalla estaba en segundo plano es un desastre silencioso: el usuario vuelve y la aplicación se quedó donde estaba. Ese único matiz es el que separa a SharedFlow de Channel.

Conviene distinguir además sus dos formas de emitir. emit es una función suspend que espera si el búfer está lleno y la política es suspender, de modo que respeta la contrapresión pero exige estar dentro de una corrutina. tryEmit no suspende y devuelve si el valor entró o se descartó; es lo que necesitas desde un contexto síncrono, pero solo tiene sentido si le has dado capacidad extra al búfer, porque un SharedFlow sin margen alguno rechazará todo lo que le llegue por esa vía. Ese es, de hecho, el fallo más común al configurarlo: réplica cero, capacidad cero y tryEmit, una combinación en la que ningún evento llega jamás y nada avisa.

Del lado del consumo, un SharedFlow se colecciona como cualquier otro flujo, y en Compose la disciplina es la misma que con el estado: hacerlo dentro de un efecto atado al ciclo de vida para no crear una suscripción nueva en cada recomposición.

@Composable
fun LoginScreen(viewModel: LoginViewModel, navegar: (String) -> Unit) {
    val ciclo = LocalLifecycleOwner.current.lifecycle
    LaunchedEffect(Unit) {
        ciclo.repeatOnLifecycle(Lifecycle.State.STARTED) {
            viewModel.eventos.collect { evento ->
                when (evento) {
                    UiEvent.NavegarAHome -> navegar("home")
                    is UiEvent.Aviso -> snackbar.showSnackbar(evento.mensaje)
                }
            }
        }
    }
}
ℹ️
StateFlow es un SharedFlow con la ropa puesta

Conceptualmente, un StateFlow es un SharedFlow con replay = 1, conflación y deduplicación por igualdad ya cableadas, más la obligación de tener valor inicial y un value síncrono. Ver la jerarquía así ordena la elección: si necesitas exactamente esa combinación —y para el estado siempre la necesitas— usa StateFlow, que además la documenta en el tipo. SharedFlow es la herramienta cuando quieres otra combinación: cero réplica para eventos, o varias réplicas para un histórico corto que un suscriptor nuevo deba ver.

Channel: entrega exactamente una vez

Channel viene de otra tradición: no es difusión, es una cola. Lo que se envía se guarda hasta que alguien lo recoge, y cuando alguien lo recoge desaparece. Si hay dos receptores, cada elemento le llega a uno solo de ellos —se reparten, no se duplican—. Esa es la semántica de entrega exactamente una vez, y es justo la que un evento de navegación pide: no quiero que se pierda si nadie escucha todavía, y no quiero que se ejecute dos veces si hay dos observadores por accidente.

private val _eventos = Channel<UiEvent>(Channel.BUFFERED)
val eventos: Flow<UiEvent> = _eventos.receiveAsFlow()   // un solo consumidor

fun entrar(usuario: String, clave: String) = viewModelScope.launch {
    _state.update { it.copy(cargando = true) }
    val ok = repo.login(usuario, clave)
    _state.update { it.copy(cargando = false) }
    if (ok) _eventos.send(UiEvent.NavegarAHome)   // se guarda hasta que lo recojan
    else _eventos.send(UiEvent.Aviso("credenciales invalidas"))
}

receiveAsFlow expone el canal como un Flow normal para que la interfaz lo coleccione con las herramientas de siempre, conservando la garantía de consumo único. Si la pantalla estaba en pausa cuando se emitió NavegarAHome, el evento espera pacientemente en el búfer y se entrega en cuanto la colección se reanuda: nada se pierde y nada se repite. Esa combinación —retención sin repetición— es exactamente lo que ni StateFlow ni un SharedFlow sin réplica pueden ofrecer a la vez.

flowchart TD
EV[Salida que quieres emitir] --> Q{Describe el mundo o lo cambia}
Q -->|Lo describe: es un hecho retenido| ST[StateFlow con valor actual]
Q -->|Lo cambia: ocurre una vez| P{Puedes perderlo si nadie escucha}
P -->|Si, es informativo y efimero| SH[SharedFlow con replay cero]
P -->|No, debe llegar igualmente| CH[Channel con buffer y consumo unico]
style ST fill:#89b4fa,color:#11111b
style SH fill:#f9e2af,color:#11111b
style CH fill:#a6e3a1,color:#11111b

La otra decisión del canal es su capacidad. Con Channel.RENDEZVOUS, el valor por defecto, send suspende hasta que alguien recibe: perfecto entre dos corrutinas que quieres acompasar, desastroso para eventos de interfaz, porque bloquearías la lógica hasta que la pantalla volviese a primer plano. Con Channel.BUFFERED el emisor deposita y sigue. Con Channel.UNLIMITED nunca se frena, al precio de que una fuga de eventos no consumidos crezca sin control. Para eventos de interfaz, BUFFERED es la elección sana y trySend la forma de emitir desde un contexto que no puede suspender.

🖼️

StateFlow

Un valor siempre presente, conflado y deduplicado, que se reemite íntegro a quien llegue. Para todo lo que la pantalla es.

📡

SharedFlow

Difusión a todos los suscriptores presentes, con réplica y búfer configurables. Para lo que ocurre y puede perderse sin daño.

📮

Channel

Cola de entrega exactamente una vez a un único receptor, que retiene hasta que alguien recoge. Para lo que ocurre y no puede perderse.

🌊

Flow frio

Ni retiene ni difunde: describe. Es lo que devuelven las capas que no conocen el ciclo de vida de nadie.

La contrapartida del canal es su límite: está pensado para un consumidor. Si dos pantallas coleccionan el mismo canal, los eventos se repartirán entre ellas de forma impredecible en lugar de llegarles a ambas, y eso casi nunca es lo que querías. Cuando de verdad necesitas que varios observadores reciban el mismo evento, la primitiva correcta es SharedFlow; cuando necesitas que llegue exactamente a uno y no se pierda, es Channel. El límite no es un defecto: es la definición.

⚠️
consumeAsFlow no es receiveAsFlow

receiveAsFlow permite que la colección se detenga y se reanude sobre el mismo canal, que es lo que ocurre continuamente con el ciclo de vida de una pantalla. consumeAsFlow cancela el canal cuando la colección termina, así que tras la primera pausa el canal queda inutilizable y los eventos posteriores mueren en silencio. Es un fallo difícil de ver porque la primera ejecución funciona perfectamente. Para eventos de interfaz, receiveAsFlow es la elección.

StateFlow no es un bus de eventos

Ahora el error. Supón que guardas el mensaje de error en el estado: state.copy(error = "sin conexion"). La primera vez funciona, y esa es la trampa. Pero el estado es una fotografía retenida que se reemite íntegra a cada nuevo suscriptor: gira la pantalla y la vista nueva se suscribe, recibe el último estado —que sigue conteniendo el error— y muestra el aviso otra vez, sin que haya ocurrido nada. Vuelve de segundo plano: lo mismo. El evento quedó fosilizado dentro de la fotografía y resucita en cada relectura.

La industria intentó parchear esto durante años sin separar los conceptos, y el catálogo de remedios es instructivo precisamente por lo mucho que se parecen entre sí. La bandera booleana que reseteas después de mostrar el aviso obliga a la vista a escribir en el estado, rompiendo la unidireccionalidad. El envoltorio de evento con una marca de consumido convierte el estado en algo que cambia al leerlo, lo cual ya no es un estado. SingleLiveEvent fue el mismo truco a nivel de framework, y se rompía en cuanto había dos observadores. Los tres simulan, con estado, una semántica de entrega única que el estado por definición no tiene, y ninguno compone.

// Mal: el evento fosilizado dentro del estado resucita en cada resuscripcion
_state.update { it.copy(error = "sin conexion") }
// ...y la vista, ademas, tiene que escribir para limpiarlo
fun errorMostrado() = _state.update { it.copy(error = null) }

// Bien: el estado describe, el canal notifica, y nadie escribe hacia atras
_state.update { it.copy(cargando = false) }
_eventos.send(UiEvent.Aviso("sin conexion"))

Fíjate en el detalle que delata al parche: errorMostrado. Es la vista informando al ViewModel de que ya ha consumido algo, es decir, un flujo de datos que va en dirección contraria a la que MVI promete. Cada vez que veas a la interfaz llamando a una función cuyo único propósito es limpiar una marca del estado, tienes delante un evento disfrazado y una unidireccionalidad rota; el canal de eventos elimina ambas cosas a la vez, porque un valor consumido de una cola no necesita que nadie avise de nada.

Queda la elección fina entre las dos primitivas de evento, y la regla cabe en una pregunta: ¿es aceptable que este evento se pierda si en ese instante nadie escucha? Para un aviso puramente informativo —una animación, un sonido, un contador en vivo— la respuesta suele ser sí, y SharedFlow con réplica cero es más simple y admite varios observadores. Para una orden de navegación, un resultado de pago o un cierre de sesión, la respuesta es rotundamente no, y el canal con búfer es la elección correcta precisamente porque retiene. Responder esa pregunta por escrito, salida a salida, cuesta un minuto y ahorra la clase de bug que solo se reproduce cuando el usuario recibe una llamada a mitad del proceso.

El tipo que eliges es la semantica que prometes

Detrás de esta lección hay una idea que trasciende a Kotlin y a Android. Cuando eliges entre StateFlow, SharedFlow y Channel no estás escogiendo una implementación con distintas prestaciones: estás firmando un contrato sobre la naturaleza temporal de lo que emites, y ese contrato lo hará cumplir la biblioteca aunque tú te distraigas. Al escribir StateFlow prometes que ese dato es un hecho persistente, que tiene sentido preguntarlo en cualquier momento, que dos lecturas seguidas sin cambios de por medio deben dar lo mismo y que quien llegue tarde merece conocerlo íntegro. Al escribir Channel prometes lo contrario: que eso es un acontecimiento, que pertenece al instante en que ocurrió, que preguntarlo más tarde no significa nada y que ejecutarlo dos veces sería un error. Los parches históricos —la bandera, el envoltorio, el evento único— fracasaron todos por la misma razón, y no fue por descuido de sus autores: intentaban imponer la semántica correcta desde fuera, con disciplina del programador, sobre un tipo que prometía lo contrario desde dentro. Y en cualquier competición entre lo que el tipo garantiza y lo que el programador recuerda, gana el tipo, siempre, sobre todo el martes por la tarde tras un refactor apresurado. La lección práctica cabe en una frase: cuando un bug se repite en toda una industria durante una década, casi nunca es que todo el mundo sea descuidado; es que el tipo con el que se modelaba el problema prometía algo distinto de lo que el problema pedía. Antes de escribir la primera línea de una pantalla, clasifica cada salida —¿esto describe o esto sucede?— y deja que el tipo cargue con la promesa. Lo que el sistema de tipos garantiza no puede olvidarse en una revisión de código.

⚔️ Clasifica y elige el tipo correcto
  1. Toma una pantalla de pago y clasifica cada salida —total, botón deshabilitado, pago aceptado, navegar al recibo, error de tarjeta— como estado, evento difundible o evento que no puede perderse.
  2. Modela un aviso de error dentro del estado, gira la pantalla y describe con precisión la secuencia que hace reaparecer el mensaje.
  3. Implementa el mismo aviso con SharedFlow de réplica cero y explica en qué situación exacta el evento se perdería.
  4. Sustitúyelo por un Channel con receiveAsFlow y razona por qué ahora sobrevive a un paso por segundo plano.
  5. Explica por qué consumeAsFlow rompe el segundo ciclo de vida de una pantalla mientras que receiveAsFlow no, y por qué el fallo es difícil de detectar en pruebas manuales.