wandres.dev
SIDE EFFECTS · eventos de una sola vez

Consumir los efectos en Compose con el ciclo de vida correcto

Emitir bien un efecto no sirve de nada si se consume mal. Esta lección estudia el extremo receptor: por qué la recolección debe atarse al ciclo de vida y no a la composición, qué hace exactamente repeatOnLifecycle y por qué reiniciar la colección no reintroduce el bug del evento repetido, qué estado del ciclo de vida elegir para navegar sin excepciones, y cuáles son los errores clásicos —LaunchedEffect con clave inestable, colección sin ciclo de vida, ejecución de efectos dentro del cuerpo de un Composable— que devuelven al sistema los fantasmas que tanto costó eliminar.

⏱ 18 min

Un canal de entrega única solo cumple su promesa si alguien lo escucha bien. Y escuchar bien, en Compose, es más delicado de lo que parece, porque hay dos relojes corriendo a la vez: el de la composición, que se reevalúa muchas veces por segundo y sin garantías sobre cuándo, y el del ciclo de vida, que sigue el ritmo lento y observable de la pantalla que aparece, se oculta y se destruye. Atar la recolección de efectos al reloj equivocado produce dos fallos simétricos y ambos frecuentes: si la atas a la composición, ejecutas efectos en recomposiciones y vuelves al punto de partida; si no la atas a nada, sigues recolectando con la app en segundo plano y navegas hacia una pantalla que ya no está en el primer plano, con excepción incluida. El extremo receptor es donde una arquitectura correcta se gana o se pierde.

🎯 Al terminar esta lección sabrás
  • Distinguir el reloj de la composición del reloj del ciclo de vida y saber a cuál atar la recolección.
  • Explicar qué hace repeatOnLifecycle y por qué reiniciar la colección no duplica efectos ya consumidos.
  • Elegir el estado del ciclo de vida adecuado para navegar sin provocar excepciones ni perder efectos.
  • Reconocer los tres errores clásicos de consumo y la corrección concreta de cada uno.

Dos relojes y un solo sitio correcto

El cuerpo de un Composable es una función que describe interfaz y que puede ejecutarse cuantas veces quiera el runtime: en cada recomposición, en cada cambio de un estado leído, incluso de forma especulativa. De ahí sale la primera regla, que es absoluta: nunca ejecutes un efecto directamente en el cuerpo de un Composable. Navegar, avisar o vibrar desde ahí es apostar a que el runtime no reevalúe esa función, y siempre la reevalúa.

Compose ofrece el puente adecuado: los efectos secundarios se lanzan en corrutinas gestionadas por la composición —LaunchedEffect es el caso típico— que sobreviven a las recomposiciones mientras su clave no cambie. Pero la composición no es el reloj correcto para consumir eventos de una pantalla, porque una pantalla puede seguir compuesta y estar en segundo plano, o desaparecer del árbol sin que nada de eso corresponda a su estado de visibilidad real. El reloj correcto es el ciclo de vida.

@Composable
fun CarritoScreen(vm: CarritoViewModel, nav: NavController) {
    val estado by vm.estado.collectAsStateWithLifecycle()
    val duenio = LocalLifecycleOwner.current

    LaunchedEffect(vm, duenio) {
        duenio.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
            vm.efectos.collect { efecto -> manejar(efecto, nav) }
        }
    }

    CarritoContenido(estado, onBorrar = vm::borrarLinea)
}

Lee la estructura con atención. LaunchedEffect con claves estables —el propio modelo de vista y el dueño del ciclo de vida— arranca una corrutina que no se reinicia en cada recomposición. Dentro, repeatOnLifecycle ejecuta el bloque cada vez que el ciclo de vida alcanza el estado indicado y cancela la corrutina cuando cae por debajo. La colección del canal vive así exactamente mientras la pantalla está visible.

stateDiagram-v2
[*] --> Creada
Creada --> Visible: onStart arranca la coleccion
Visible --> Oculta: onStop cancela la coleccion
Oculta --> Visible: onStart reinicia la coleccion
Oculta --> Destruida: los efectos pendientes siguen en el bufer

Reiniciar la colección no duplica nada

La duda razonable al ver repeatOnLifecycle es si reiniciar la colección tras volver del segundo plano no reintroduce el bug de la lección anterior. La respuesta es que no, y el motivo es exactamente la propiedad que se eligió al modelar el transporte: el canal no tiene historial. Al cancelarse la colección y volver a empezar, no hay nada que reemitir; los efectos ya recibidos desaparecieron al recibirse, y los que se hayan emitido mientras la pantalla estaba oculta esperan en el búfer y se entregan al reanudar, una sola vez cada uno.

Este punto es el que más veces se malinterpreta al revisar código ajeno, y conviene tenerlo dicho con precisión: quien reintroduce el bug no es la reanudación de la colección, sino la existencia de un valor retenido al otro lado. Reiniciar una lectura sobre una cola vacía no produce nada; reiniciar una lectura sobre una celda con valor produce ese valor otra vez. La diferencia está en el flujo, no en el consumidor.

Compáralo con el estado, que hace lo contrario y también lo correcto: collectAsStateWithLifecycle reinicia igualmente su colección y al reanudar recibe de inmediato el último estado, porque el estado sí debe reponerse. Las dos mitades de la pantalla usan la misma disciplina de ciclo de vida y obtienen comportamientos opuestos, no por cómo se recolectan, sino por la naturaleza del flujo que hay al otro lado.

⚠️
El estado del ciclo de vida no es un detalle menor

Con STARTED, la colección arranca cuando la pantalla se vuelve visible y se cancela al ocultarse. Esa es la elección por defecto sensata, y la que evita el fallo más feo del consumo de efectos: intentar navegar con la actividad en segundo plano, que en varias versiones de la librería de navegación provoca una excepción por manipular el estado de fragmentos tras onSaveInstanceState. Subir a RESUMED es más estricto —solo con la pantalla realmente en primer plano— y hace más segura la navegación en escenarios con diálogos o pantallas parcialmente visibles, a costa de retrasar los efectos. Bajar a CREATED es casi siempre un error: recolectarías con la pantalla invisible.

Los tres errores clásicos

El extremo receptor concentra unos pocos fallos que se repiten en todos los proyectos, y los tres tienen la misma raíz: haber atado la ejecución del efecto a algo que se repite.

🔑

Clave inestable

Un LaunchedEffect con una clave que cambia en cada recomposición —una lambda recreada, un objeto sin igualdad estructural— cancela y relanza la corrutina sin parar. La colección se reinicia constantemente y los efectos pueden llegar a destiempo.

👻

Colección sin ciclo de vida

Recolectar el canal dentro de un LaunchedEffect a secas mantiene viva la colección con la app en segundo plano. Los efectos se consumen sin que nadie los vea y la navegación se ejecuta cuando no debe.

⚠️

Efecto en el cuerpo

Llamar a navigate o mostrar un aviso directamente en el cuerpo del Composable, o dentro de un condicional sobre el estado, ejecuta el efecto en cada recomposición que cumpla la condición.

Los tres tienen además un rasgo común que los hace difíciles de detectar en revisión: ninguno produce un error de compilación ni una excepción, y los tres funcionan perfectamente en el camino feliz que el autor probó. Solo se manifiestan bajo condiciones de ciclo de vida que rara vez se reproducen a mano, lo que convierte a la revisión de este patrón concreto en uno de los sitios donde más rentable resulta tener una regla escrita y un ayudante compartido.

El tercero merece un matiz porque es el más disfrazado. Su forma habitual no es una llamada suelta sino un condicional sobre un campo del estado que dispara la navegación cuando ese campo cambia. Eso es la lección dos otra vez, con ropa de Compose: el campo permanece, la recomposición lo vuelve a leer y el efecto se repite. La corrección no es proteger el condicional con más banderas, sino mover ese dato al canal de efectos, donde su consumo lo destruye.

// Consumo correcto y minimo con un helper reutilizable
@Composable
fun <T> ObservarEfectos(
    flujo: Flow<T>,
    minimo: Lifecycle.State = Lifecycle.State.STARTED,
    onEfecto: suspend (T) -> Unit,
) {
    val duenio = LocalLifecycleOwner.current
    LaunchedEffect(flujo, duenio, minimo) {
        duenio.lifecycle.repeatOnLifecycle(minimo) {
            flujo.collect { onEfecto(it) }
        }
    }
}

Extraer el patrón a una función tiene una ventaja que va más allá del ahorro de líneas: convierte la decisión sobre el ciclo de vida en un parámetro explícito y revisable, en vez de en una repetición que cada pantalla copia y alguna copia mal. Cuando el equipo decida subir a RESUMED para la navegación, habrá un solo sitio donde discutirlo.

Quién maneja el efecto y dónde

Resuelto el cuándo, queda el dónde, y es una decisión de arquitectura y no de estilo. Orbit ofrece un atajo, collectSideEffect, que encapsula el patrón anterior con el ciclo de vida ya considerado; usarlo es correcto y ahorra la ceremonia. Lo que el atajo no decide por ti es en qué nivel del árbol de composición colocas la traducción del efecto a una acción de la plataforma.

@Composable
fun CarritoRuta(
    vm: CarritoViewModel = hiltViewModel(),
    alPagar: (String) -> Unit,
    alCerrar: () -> Unit,
) {
    val estado by vm.collectAsState()
    val anfitrionSnackbar = remember { SnackbarHostState() }

    vm.collectSideEffect { efecto ->
        when (efecto) {
            is CarritoEfecto.IrAlPago -> alPagar(efecto.carritoId)
            is CarritoEfecto.AvisarStockAgotado ->
                anfitrionSnackbar.showSnackbar(efecto.producto)
            CarritoEfecto.ConfirmarBorrado -> alCerrar()
            CarritoEfecto.PedirFocoEnCupon -> Unit
        }
    }

    CarritoPantalla(estado, anfitrionSnackbar, onBorrar = vm::borrarLinea)
}

La separación entre el componente de ruta y el de pantalla no es decorativa. El de ruta conoce el modelo de vista, consume los efectos y traduce la navegación a lambdas que recibe de fuera; el de pantalla es puro, recibe estado y devuelve interacciones, y por eso se puede previsualizar y probar sin ningún modelo de vista. Pasar la navegación como lambda en vez de recibir el controlador entero mantiene la pantalla ignorante del grafo y hace trivial reutilizarla en otro flujo.

Queda un caso que merece atención propia: los efectos que deben ejecutarse aunque nadie esté mirando. Descargar un archivo, enviar un formulario en cola o registrar una compra no son efectos de interfaz, y atarlos al ciclo de vida de una pantalla es un error de ubicación, no de técnica. Esos trabajos pertenecen a un ámbito de mayor duración —un servicio, un trabajo programado, un repositorio con su propio alcance— y el efecto de interfaz correcto es solamente el aviso de que el trabajo se encoló.

⚠️
Un efecto no consumido no es un efecto inofensivo

Si una pantalla deja de coleccionar durante mucho tiempo y la lógica sigue emitiendo, los efectos se acumulan en el búfer y se entregarán todos de golpe al reanudar. Tres navegaciones encoladas se ejecutan en cascada; cinco avisos se muestran en fila. Conviene por tanto no emitir efectos desde procesos de fondo que sigan vivos con la pantalla oculta, y preferir que esos procesos actualicen el estado, que sí converge, dejando los efectos para lo que nace de una interacción presente.

El consumidor es la mitad olvidada del contrato de entrega única

Se habla mucho del emisor y muy poco del receptor, y sin embargo la garantía de entrega única es un contrato entre dos partes que puede romperse desde cualquiera de ellas. El canal promete entregar cada efecto exactamente una vez a quien lo reciba; lo que no puede prometer es que quien lo reciba haga con él algo sensato, ni que exista alguien escuchando cuando el efecto importa. Esa segunda mitad —cuándo escucho, cuánto tiempo, con qué reloj— es responsabilidad íntegra del consumidor, y es donde se decide si la elegancia del modelo sobrevive al contacto con Android. La razón profunda es que una interfaz moderna vive bajo dos regímenes temporales incompatibles: el declarativo, donde la función que describe la pantalla se ejecuta un número indeterminado de veces y por tanto debe ser idempotente e inofensiva, y el imperativo del sistema operativo, donde la pantalla nace, se muestra, se oculta y muere en una secuencia observable con puntos concretos en los que ciertas operaciones son legales y otras no. Consumir efectos es precisamente el punto de sutura entre ambos regímenes: hay que tomar algo que ocurre en el tiempo asíncrono de la lógica y ejecutarlo en una ventana concreta del tiempo del sistema, ni antes ni después. Por eso ningún atajo funciona: recolectar en la composición ignora el ciclo de vida, recolectar sin ciclo de vida ignora la ventana de legalidad, y ejecutar en el cuerpo ignora la idempotencia. La disciplina de repeatOnLifecycle no es ceremonia de framework sino el reconocimiento explícito de que estos dos relojes existen, corren distinto y solo se tocan en un sitio bien definido. Escribir ese sitio una vez, bien, y reutilizarlo, es lo que impide que la mitad receptora deshaga en silencio todo lo que la mitad emisora construyó.

⚔️ Audita el extremo receptor
  1. Revisa una pantalla tuya y localiza dónde se consumen los efectos. Determina a qué reloj está atada esa recolección: composición, ciclo de vida o ninguno.
  2. Sustituye la recolección por el patrón con repeatOnLifecycle y explica exactamente qué ocurre con un efecto emitido mientras la pantalla está en segundo plano.
  3. Argumenta por qué reiniciar la colección al volver del segundo plano no reintroduce el bug del aviso repetido, apoyándote en la ausencia de historial del canal.
  4. Provoca a propósito el error de la clave inestable en un LaunchedEffect y describe el comportamiento observable que produce.
  5. Busca en tu código un condicional sobre un campo del estado que dispare navegación. Reescríbelo como efecto y explica qué campo del estado desaparece al hacerlo.