wandres.dev
ORBIT: EL CONTAINER · la API a fondo

stateFlow y sideEffectFlow: las dos salidas

El container solo publica dos cosas hacia afuera, y la diferencia entre ambas está cableada en sus tipos. Esta lección examina las dos salidas con detalle operativo: por qué el estado es un flujo conflado con valor actual garantizado y qué implica esa conflación para la interfaz, por qué los efectos viajan sobre un canal con semántica de entrega y un búfer que evita perderlos antes del primer suscriptor, qué ocurre exactamente cuando hay cero, uno o varios coleccionistas de cada flujo, cómo se suscribe la interfaz a los dos con la disciplina de ciclo de vida correcta en Compose y en el sistema de vistas, y qué errores de consumo devuelven al sistema los bugs que esta separación pretendía eliminar.

⏱ 19 min

Una pantalla produce dos clases de información que se parecen lo justo para confundirse y difieren en todo lo importante. Está lo que la pantalla es —el nombre cargado, la lista de resultados, si hay una operación en curso— y está lo que la pantalla hace —navegar ahora, mostrar este aviso una vez, pedir el foco—. La primera clase debe reponerse siempre que alguien vuelva a mirar; la segunda debe consumirse exactamente una vez y desaparecer. Casi todos los bugs clásicos de una interfaz reactiva nacen de haber metido información del segundo tipo en un vehículo del primero: el aviso que reaparece al girar el dispositivo, la navegación que se repite al volver atrás. Orbit resuelve la confusión de la única manera que sobrevive al paso del tiempo, que es negando la posibilidad de cometerla: expone dos salidas, con dos tipos y dos semánticas, y te obliga a elegir en cada emisión.

🎯 Al terminar esta lección sabrás
  • Explicar por qué el estado viaja en un flujo conflado con valor actual y qué implica esa conflación para el consumidor.
  • Describir la semántica de entrega del canal que respalda los efectos, incluido su búfer y su comportamiento sin suscriptores.
  • Suscribirse a ambas salidas con la disciplina de ciclo de vida adecuada en Compose y en el sistema de vistas.
  • Diagnosticar los errores de consumo que reintroducen el evento repetido o el efecto perdido.

Dos tipos, dos contratos

La interfaz del container declara las dos salidas con tipos deliberadamente distintos, y esa distinción tipada es el mecanismo pedagógico más eficaz de la librería: no hay que recordar una regla porque el compilador ya te sitúa en el vehículo adecuado.

interface Container<STATE : Any, SIDE_EFFECT : Any> {
    val stateFlow: StateFlow<STATE>
    val sideEffectFlow: Flow<SIDE_EFFECT>
    val settings: Settings
}

stateFlow es un flujo con valor actual: siempre se puede leer su propiedad value sin suspender, y todo coleccionista recibe inmediatamente el último estado al suscribirse. Además es conflado, lo que significa que si las mutaciones llegan más rápido de lo que un coleccionista lento las procesa, los estados intermedios se descartan y solo se entrega el más reciente. Para el estado esto es exactamente lo correcto, porque un estado intermedio no observado no representa ninguna pérdida de información: la pantalla solo puede mostrar uno a la vez, y siempre debe mostrar el último.

sideEffectFlow es un flujo respaldado por un canal, y un canal tiene semántica de entrega en vez de semántica de valor. Cada elemento se entrega a un receptor y desaparece; no hay valor actual que consultar, no hay reemisión a los suscriptores que lleguen después y no hay conflación, porque descartar un aviso intermedio sí perdería información. Un efecto es un hecho puntual, y los hechos no se resumen.

🛰️

Estado: fotografía repuesta

Valor actual garantizado, reemisión al nuevo suscriptor, conflación de intermedios y distribución a todos los coleccionistas simultáneos. Sirve para lo que debe sobrevivir a una rotación.

Efecto: hecho entregado

Sin valor actual, sin reemisión, con búfer hasta que aparece un receptor y consumo destructivo. Sirve para lo que debe ocurrir exactamente una vez y nunca más.

ℹ️
Qué pasa cuando nadie escucha

Si el estado muta sin coleccionistas, no ocurre nada especial: el valor queda guardado y se entregará al primero que se suscriba. Si se emite un efecto sin coleccionistas, el elemento espera en el búfer del canal —sesenta y cuatro elementos por defecto, ajustable en los ajustes del container— y se entregará al primer suscriptor que aparezca. Esa espera resuelve una carrera real: un efecto emitido durante el arranque, antes de que la vista se haya suscrito, no se pierde en el vacío. Lo que sí conviene evitar es acumular decenas de efectos con la pantalla oculta, porque se entregarán todos de golpe al reanudar.

Suscribirse desde la interfaz sin romper nada

Orbit ofrece atajos por plataforma, pero conviene ver primero qué hacen por debajo, porque el atajo solo es seguro si se entiende lo que encapsula. En Compose, el estado se recoge con una función que respeta el ciclo de vida y los efectos se consumen dentro de una corrutina que se cancela cuando la pantalla deja de estar visible.

@Composable
fun BusquedaRuta(
    vm: BusquedaViewModel = hiltViewModel(),
    alAbrirDetalle: (ItemId) -> Unit,
) {
    val estado by vm.collectAsState()
    val anfitrion = remember { SnackbarHostState() }

    vm.collectSideEffect { efecto ->
        when (efecto) {
            is BusquedaEffect.AbrirDetalle -> alAbrirDetalle(efecto.id)
            is BusquedaEffect.Avisar -> anfitrion.showSnackbar(efecto.texto)
            BusquedaEffect.PedirFoco -> Unit
        }
    }

    BusquedaPantalla(estado, anfitrion, onBuscar = vm::buscar)
}

Las dos funciones tienen la misma disciplina interna y comportamientos opuestos, y esa simetría es la mejor forma de comprobar que se ha entendido la lección. Ambas atan su recolección al ciclo de vida y ambas la cancelan cuando la pantalla se oculta. Al reanudar, la del estado recibe de inmediato el último valor, porque el flujo lo retiene; la de los efectos no recibe nada, porque no hay nada retenido, salvo lo que estuviera esperando en el búfer. El comportamiento distinto no viene del consumidor sino de la naturaleza del flujo que hay al otro lado.

flowchart LR
R[reduce] --> E[Estado en el container]
P[postSideEffect] --> K[Canal con bufer]
E --> SF[stateFlow conflado y con valor actual]
K --> SE[sideEffectFlow de entrega unica]
SF --> V[La vista recompone]
SE --> N[La vista navega avisa o vibra]

En el sistema de vistas clásico el papel lo cumple una única llamada que registra ambos consumidores contra un dueño de ciclo de vida, con una función de dibujado para el estado y otra de manejo para los efectos. La forma cambia; el contrato no.

// Sistema de vistas: una sola suscripcion para las dos salidas
override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    viewModel.observe(
        lifecycleOwner = this,
        state = ::pintar,
        sideEffect = ::manejar,
    )
}

private fun pintar(estado: BusquedaState) { /* idempotente, sin efectos */ }
private fun manejar(efecto: BusquedaEffect) { /* navegar, avisar, vibrar */ }

La separación entre las dos funciones no es cosmética y conviene sostenerla con rigor: pintar debe ser idempotente, porque se llamará muchas veces con estados casi idénticos y nunca debe hacer nada más que reflejar; manejar es lo contrario, un lugar donde ocurren acciones irreversibles que se ejecutarán exactamente una vez por efecto. Meter una navegación dentro de la función de pintado es el mismo error que ejecutar un efecto en el cuerpo de un Composable, con otra sintaxis.

Cero, uno o varios coleccionistas

La cardinalidad de las suscripciones distingue a los dos flujos de una forma que hay que conocer antes de diseñar cualquier pantalla con más de un observador.

El estado admite varios coleccionistas simultáneos sin ninguna consecuencia: todos reciben el mismo valor, todos reciben el último al suscribirse y ninguno consume nada que prive a los demás. Dos componentes distintos pueden observar el mismo stateFlow y comportarse de forma coherente, y esa es la base de las pantallas compuestas.

El canal de efectos se comporta al revés y es la fuente de un error de arquitectura que cuesta días localizar: cada elemento se entrega a un receptor. Si dos partes de la interfaz coleccionan el mismo flujo de efectos, los elementos se reparten entre ellas de forma no determinista, y el resultado es un aviso que a veces aparece y a veces no. La conclusión práctica es una regla de diseño, no un consejo: debe haber un único consumidor de efectos por container, situado en el componente de ruta, que traduzca cada efecto a la acción de plataforma correspondiente.

⚠️
Tres errores de consumo que reintroducen los fantasmas

Consumir efectos sin atarlos al ciclo de vida mantiene viva la recolección con la aplicación en segundo plano, y la navegación se ejecuta cuando la pantalla ya no está en primer plano. Consumir el estado sin ciclo de vida gasta recursos por una pantalla que nadie ve. Y ejecutar la acción de plataforma directamente en el cuerpo de un Composable, dentro de un condicional sobre un campo del estado, es la reencarnación exacta del bug del evento fosilizado: el campo permanece, la recomposición lo vuelve a leer y el efecto se repite. Ninguno de los tres produce error de compilación, y los tres funcionan en el camino feliz que el autor probó.

Queda un caso límite que conviene tener resuelto de antemano. Si una pantalla se destruye con efectos pendientes en el búfer, esos efectos mueren con el container y nadie los recibe. Eso es correcto y deseado: un aviso destinado a una pantalla que ya no existe no debe mostrarse en la siguiente. Lo que no debe hacerse nunca es confiar un trabajo importante —enviar un formulario, registrar una compra, descargar un archivo— a un efecto secundario. Los efectos son órdenes de interfaz; el trabajo que debe completarse aunque nadie mire pertenece a un ámbito de mayor duración, y el efecto correcto es únicamente el aviso de que ese trabajo se encoló.

Probar cada salida por separado

La dualidad se refleja también en las pruebas, y ahí es donde se comprueba si el modelo se ha entendido. Orbit ofrece un modo de prueba que intercepta las dos salidas y permite afirmar sobre ellas de forma independiente, sin ningún doble de la interfaz.

@Test
fun `una busqueda sin resultados avisa una sola vez`() = runTest {
    val vm = BusquedaViewModel(repoFalso).test(this, BusquedaState())

    vm.testIntent { buscar("xyz") }

    vm.assert(BusquedaState()) {
        states(
            { copy(cargando = true, termino = "xyz") },
            { copy(cargando = false, items = emptyList()) },
        )
        postedSideEffects(BusquedaEffect.SinResultados)
    }
}

Dos listas separadas, y esa separación es diagnóstica. La secuencia de estados describe la historia visible de la pantalla y se afirma como una sucesión de transformaciones sobre el estado inicial, de modo que la prueba documenta el recorrido en vez de comprobar solo el destino. La lista de efectos describe qué hechos ocurrieron y cuántas veces, y es ahí donde se cazan los bugs de duplicación: si un aviso aparece dos veces cuando debía aparecer una, la afirmación falla aunque el estado final sea correcto. Una prueba que solo mirase el estado final no vería ninguno de los dos problemas, y por eso las arquitecturas que mezclan hechos y valores en una sola salida son también las más difíciles de probar.

Dos flujos son dos teorías del tiempo conviviendo en el mismo objeto

La decisión de exponer dos salidas en vez de una parece una comodidad de la librería y es, en realidad, el reconocimiento de que una interfaz vive simultáneamente bajo dos regímenes temporales que no se pueden reducir el uno al otro. El primero es el régimen del valor: la pantalla es una función del estado, no tiene memoria de cómo llegó hasta aquí, y cualquier observador que se incorpore en cualquier instante debe poder reconstruirla íntegramente a partir de lo que hay ahora. En ese régimen el pasado es irrelevante y solo existe el presente, y por eso el vehículo correcto es un flujo conflado con valor actual: preguntar dos veces devuelve lo mismo, y llegar tarde no penaliza. El segundo es el régimen del acontecimiento: hay cosas que no son sino que pasan, que tienen una posición en la línea del tiempo y cuya identidad se agota al ocurrir. En ese régimen el presente no existe como valor consultable —no tiene sentido preguntar cuál es el toast actual— y la única pregunta legítima es si ya ocurrió o todavía no. Intentar representar el segundo régimen con las herramientas del primero produce el catálogo entero de bugs que arrastran las aplicaciones reactivas mal diseñadas, y no por descuido de sus autores sino porque un valor retenido, por construcción, no puede expresar la irrepetibilidad. Lo verdaderamente elegante del container es que no elige entre las dos teorías ni intenta unificarlas: las hospeda a la vez, les da tipos distintos y hace que la elección entre ambas sea un acto consciente en cada emisión. Cuando escribes reduce estás afirmando que algo es; cuando escribes postSideEffect estás afirmando que algo ocurre. Esa distinción, que la mayoría de las arquitecturas dejan a la disciplina y que la disciplina siempre acaba perdiendo, aquí está inscrita en la firma de dos propiedades.

⚔️ Audita tus dos salidas
  1. Recorre el estado de una pantalla tuya y busca campos que en realidad describan acontecimientos. Muévelos al canal de efectos y explica qué campo desaparece.
  2. Comprueba experimentalmente qué recibe un coleccionista nuevo del estado y qué recibe uno nuevo de efectos, y relaciona la diferencia con el tipo de cada flujo.
  3. Coloca a propósito dos consumidores del mismo flujo de efectos y describe el comportamiento observable. Después corrige la arquitectura a un único consumidor.
  4. Emite un efecto antes de que la vista se suscriba y razona por qué no se pierde, indicando qué ajuste del container gobierna ese margen.
  5. Argumenta por qué un trabajo que debe completarse con la pantalla cerrada no puede vivir en un efecto secundario, y dónde debería vivir.