wandres.dev
ORBIT MVI · container y side effects

El container: estado y side effects

El container es el corazón de Orbit: la unidad que custodia el estado y expone dos flujos observables —uno de estado y otro de side effects—. Esta lección diseca la interfaz ContainerHost, la fábrica container que la materializa sobre viewModelScope, y la distinción técnica entre stateFlow, un StateFlow conflado que siempre tiene valor actual, y sideEffectFlow, respaldado por un Channel para que cada evento se consuma una sola vez. Entender esta doble salida es entender por qué Orbit puede separar limpiamente lo que se retiene de lo que se dispara, y cómo el ciclo de vida y el conteo de suscriptores mantienen viva la lógica solo mientras alguien mira.

⏱ 17 min

Si intent es el verbo de Orbit, el container es su sustantivo: la cosa que existe, que guarda algo y que otros observan. Toda la lógica de un feature orbita —el nombre no es casual— alrededor de esta unidad única que hace dos trabajos y solo dos: custodiar el estado actual y publicar hacia afuera dos corrientes de datos, la del estado y la de los eventos de una vez. Comprender el container con precisión —qué tipo tiene cada flujo, quién lo crea, cuándo vive y cuándo se apaga— es comprender la columna vertebral de la librería, porque todo lo demás son operaciones que escriben en él o lecturas que salen de él.

🎯 Al terminar esta lección sabrás
  • Distinguir la interfaz ContainerHost de la fábrica container que la implementa.
  • Entender los dos flujos de salida: stateFlow y sideEffectFlow, y por qué tienen tipos distintos.
  • Ver por qué el estado es un StateFlow conflado y los side effects un Flow respaldado por un Channel.
  • Situar el ciclo de vida del container sobre viewModelScope y el conteo de suscriptores.

ContainerHost y la fábrica container

Hay que separar dos cosas que se nombran casi igual. ContainerHost<STATE, SIDE_EFFECT> es una interfaz: el contrato que tu clase de lógica implementa para declararse dueña de un estado de tipo STATE y unos eventos de tipo SIDE_EFFECT. Su único requisito es exponer una propiedad container. La fábrica container(...), en cambio, es la función que construye la instancia concreta de Container<STATE, SIDE_EFFECT> que satisface ese contrato. La interfaz dice qué tipos manejas; la fábrica crea la máquina que los custodia.

class PerfilViewModel(
    private val repo: PerfilRepo,
) : ContainerHost<PerfilState, PerfilSideEffect>, ViewModel() {

    override val container = container<PerfilState, PerfilSideEffect>(
        initialState = PerfilState(),
    )
}

La versión de la fábrica que viene en orbit-viewmodel ata el container al viewModelScope del ViewModel: la corrutina raíz donde se ejecutarán todos los intent vive y muere con el ViewModel. No tienes que pasar un scope a mano ni recordar cancelarlo; heredas la gestión de ciclo de vida que Android ya te da. La fábrica también acepta un bloque onCreate que se ejecuta la primera vez que alguien observa el container —el sitio idiomático para la carga inicial de datos— y un bloque para ajustar los Settings.

Dos flujos de salida, dos naturalezas

El container expone exactamente dos corrientes hacia la interfaz, y la elección de su tipo es la decisión de diseño más fina de Orbit.

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

stateFlow es un StateFlow<STATE>: un flujo conflado que siempre tiene un valor actual y que reparte a todos sus coleccionistas el último estado, descartando los intermedios si un coleccionista va lento. Es exactamente lo que quieres para el estado: cuando la pantalla gira o un @Composable se recompone y vuelve a suscribirse, obtiene de inmediato el estado vigente, no un hueco ni una repetición del historial. El estado es una fotografía: solo importa la última.

sideEffectFlow es un Flow<SIDE_EFFECT> respaldado por un Channel. Un canal tiene semántica de entrega, no de valor actual: cada elemento se emite a un único coleccionista y se almacena en un búfer hasta que hay alguien para recibirlo, pero no se reemite a nuevos suscriptores. Es exactamente lo que quieres para un evento de una vez: mostrar un toast, navegar, disparar una vibración. El side effect es un hecho: ocurre una vez y no debe repetirse porque alguien volvió a mirar.

📸

stateFlow — StateFlow

Conflado, siempre tiene valor actual, reemite el último estado a cada nuevo suscriptor. Para lo que se retiene: la fotografía de la pantalla.

sideEffectFlow — Channel

Semántica de entrega, se consume una vez, con búfer hasta el primer suscriptor y sin reemisión. Para lo que se dispara: navegar, un toast, una vibración.

ℹ️
El buffer de side effects no los pierde

El Channel que respalda sideEffectFlow tiene un búfer —64 elementos por defecto, ajustable en Settings—. Eso resuelve una carrera sutil: si tu lógica emite un side effect antes de que la interfaz empiece a coleccionar —por ejemplo, durante el onCreate—, el evento no se pierde en el vacío, sino que espera en el búfer hasta que aparece el primer suscriptor. El estado, por su naturaleza conflada, nunca tiene este problema: siempre hay un valor que leer.

Ciclo de vida y conteo de suscriptores

El container no trabaja en el vacío: solo tiene sentido mientras alguien observa. Orbit lo modela con flujos que cuentan suscriptores. Además de stateFlow y sideEffectFlow, el container ofrece variantes con conteo de referencias que detienen el trabajo aguas arriba cuando el número de coleccionistas cae a cero, y lo reanudan cuando vuelve a subir —con un pequeño margen temporal para no reiniciarlo en cada rotación de pantalla—. Así, un intent que colecciona un Flow de la base de datos deja de consumir recursos mientras la pantalla no está visible, sin que tú escribas una sola línea de gestión.

flowchart TD
I[intent] -->|reduce| ST[Estado actual en el container]
I -->|postSideEffect| CH[Canal de side effects]
ST --> SF[stateFlow conflado]
CH --> SEF[sideEffectFlow entrega unica]
SF --> UI[Vista colecciona estado]
SEF --> UI
style ST fill:#a6e3a1,color:#11111b
style CH fill:#f9e2af,color:#11111b
style UI fill:#89b4fa,color:#11111b

Del lado de la interfaz, coleccionar estos flujos es idiomático de cada plataforma. En Jetpack Compose, collectAsState() te da el estado como State<STATE> y collectSideEffect { } consume los eventos respetando el ciclo de vida:

@Composable
fun PerfilScreen(viewModel: PerfilViewModel) {
    val state by viewModel.collectAsState()

    viewModel.collectSideEffect { efecto ->
        when (efecto) {
            is PerfilSideEffect.Navegar -> navController.navigate(efecto.ruta)
            is PerfilSideEffect.Toast -> mostrarToast(efecto.texto)
        }
    }

    Text(state.nombre)
}

En el sistema de vistas clásico, viewModel.observe(lifecycleOwner, state = ::render, sideEffect = ::manejar) cumple el mismo papel. En ambos casos la interfaz es una consumidora pasiva: no pregunta ni tira del estado, solo reacciona a lo que el container publica.

El container es la frontera entre lo retenido y lo efimero

La genialidad del container no está en que guarde estado —eso lo hace cualquier caja— sino en que institucionaliza, a nivel de tipo, la distinción entre las dos clases de salida que toda interfaz produce. Hay datos que son la pantalla y datos que solo la sacuden. Los primeros —el nombre del usuario, si está cargando, la lista de elementos— deben persistir, reponerse tras una rotación y estar siempre disponibles para quien mire: por eso viven en un StateFlow conflado. Los segundos —navega ahora, muestra este error una vez, vibra— no son estado en absoluto; son órdenes puntuales que, si se retienen o se repiten, producen bugs clásicos como el toast que reaparece al girar la pantalla. Otras arquitecturas dejan esta distinción a la disciplina del programador, que tarde o temprano falla y mete un evento efímero dentro del estado. Orbit la cablea en la estructura misma del container: hay dos flujos, con dos tipos y dos semánticas, y elegir en cuál publicas te obliga a decidir, cada vez, si lo que produces es una fotografía o un hecho. Esa coacción tipada es lo que convierte al container en una frontera y no en un simple almacén. Cuando internalices que el estado se reemite y el side effect se consume, habrás entendido no solo Orbit, sino el error de diseño que la mitad de las apps del mundo comete al confundir ambos.

⚔️ Diseca tu propio container
  1. Declara un ContainerHost para una pantalla de login: define su data class de estado y su sealed interface de side effects.
  2. Para cada dato de tu pantalla, decide si va en el estado o es un side effect, y justifica la elección con la regla fotografía-frente-a-hecho.
  3. Explica por qué stateFlow reemite el último valor a un nuevo suscriptor y sideEffectFlow no, y qué bug evita cada comportamiento.
  4. Razona qué pasaría si un side effect se emitiera durante el onCreate antes de que la interfaz coleccione, y cómo lo salva el búfer del canal.
  5. Argumenta por qué atar el container a viewModelScope elimina toda una clase de fugas de corrutinas frente a gestionar un scope a mano.