wandres.dev
CICLO DE VIDA · Activity, proceso y estado

Observar el ciclo de vida: LifecycleOwner, repeatOnLifecycle y la batería

Saber cuándo ocurren los callbacks no sirve de nada si el trabajo asíncrono no se ata a ellos. Esta lección presenta el ciclo de vida como algo que se observa en lugar de algo que se sobrescribe: qué es un LifecycleOwner, cómo un observador se pone al día al registrarse y por qué eso elimina la clase de errores de suscripción tardía. Explica el problema concreto de una corrutina que colecciona un flujo mientras la pantalla está oculta, por qué suspender la recolección no basta cuando el productor es caliente y en qué se diferencia de cancelarla de verdad. Desarrolla repeatOnLifecycle como el único patrón correcto, con su cancelación en STOPPED y su relanzamiento en STARTED, y las consecuencias que eso tiene para el código que se coloca dentro. Añade el equivalente idiomático en Compose y los efectos con ciclo, y cierra con el catálogo de fugas que siguen consumiendo batería con la app en segundo plano.

⏱ 20 min

Las cuatro lecciones anteriores describieron el contrato: el sistema te dice cuánta atención tienes y puede reciclarte cuando quiera. Falta la parte que convierte ese conocimiento en código, y es la que más apps arruina en silencio. Una pantalla moderna no ejecuta acciones puntuales dentro de los callbacks; observa flujos que emiten sin parar, escucha sensores, mantiene conexiones abiertas, recibe posiciones del navegador satelital. Todo ese trabajo vive en corrutinas cuyo ciclo natural es el del ámbito que las lanzó, y ese ámbito no sabe nada de si la pantalla se ve. Atarlo al ciclo de vida no es una optimización elegante: es la diferencia entre una app que descansa cuando nadie la mira y una que sigue trabajando dentro del bolsillo del usuario durante horas.

🎯 Al terminar esta lección sabrás
  • Entender qué es un LifecycleOwner y por qué un observador que se pone al día elimina los errores de registro tardío.
  • Distinguir suspender la recolección de cancelarla, y ver por qué con productores calientes la diferencia es total.
  • Dominar repeatOnLifecycle y las consecuencias de que el bloque se cancele y se relance.
  • Escribir el equivalente idiomático en Compose y reconocer las fugas que siguen gastando en segundo plano.

El ciclo de vida se observa, no se hereda

Un LifecycleOwner es simplemente algo que tiene un ciclo de vida y lo expone: una Activity, un fragmento, y en Compose la entrada de la pila de navegación o el propietario que obtienes de la composición local. Cualquier componente puede pedirle su Lifecycle y registrar un observador, y ahí está la ventaja frente a sobrescribir callbacks: quien observa recibe el estado actual en el momento de registrarse, no solo los cambios futuros. Si te registras con la pantalla ya visible, el observador recibe de inmediato los eventos que se perdió.

Conviene precisar de dónde sale el propietario en cada contexto, porque no siempre es el que uno cree. En una Activity es ella misma. En Compose, el propietario que obtienes de la composición local es, cuando hay navegación, la entrada de la pila del destino actual, no la actividad: eso significa que una pantalla que queda tapada por otra dentro de la misma actividad baja su estado y detiene su trabajo, que es exactamente lo que quieres. Esa diferencia es la razón de que el mismo código funcione bien en una app de una sola pantalla y en una con veinte destinos sin cambiar nada.

Esa propiedad, que parece un detalle, es la que elimina una familia entera de errores. Con callbacks sobrescritos, la lógica de un componente queda repartida por la Activity y depende de que alguien recuerde llamarlo desde los seis sitios correctos; con observadores, el componente se ata solo y su corrección no depende de la disciplina de quien lo use. Es el mismo argumento que sostiene toda la biblioteca de ciclo de vida: mover la responsabilidad del anfitrión al invitado.

class ControlDeCamara(propietario: LifecycleOwner) : DefaultLifecycleObserver {

    init { propietario.lifecycle.addObserver(this) }

    override fun onStart(propietario: LifecycleOwner) { abrirCamara() }
    override fun onStop(propietario: LifecycleOwner) { cerrarCamara() }
}

Suspender no es cancelar, y con flujos calientes eso lo cambia todo

Aquí está el error que sigue apareciendo en código nuevo. Una pantalla lanza una corrutina en lifecycleScope para coleccionar el estado del ViewModel, y ese ámbito solo se cancela cuando la Activity se destruye. Mandar la app a segundo plano no destruye nada: la corrutina sigue viva, el flujo sigue emitiendo, y la interfaz que ya nadie ve sigue actualizándose. Si detrás hay una consulta a base de datos que se reobserva, un temporizador o una posición del navegador satelital, el gasto es continuo y perfectamente invisible.

Existió una solución intermedia que hoy está desaconsejada y conviene entender por qué, porque el motivo es instructivo. Las funciones que ejecutaban un bloque solo mientras el ciclo estaba al menos iniciado no cancelaban la corrutina: la suspendían. Con un flujo frío eso podría bastar, pero con un flujo caliente, que es el caso de todo StateFlow o SharedFlow, el productor está al otro lado y no se entera de nada. Sigue produciendo, sigue consumiendo recursos, y las emisiones se acumulan o se descartan según la política del búfer. Suspender al consumidor no apaga al productor; solo apaga la parte que se veía.

La forma correcta es cancelar de verdad la recolección cuando la pantalla deja de verse, y volver a suscribirse cuando vuelve. Cancelar propaga la baja hacia arriba: el flujo frío detiene su cuerpo, el callbackFlow ejecuta su cierre y desregistra el oyente nativo, y un StateFlow compartido con WhileSubscribed deja de tener suscriptores y detiene también su cadena de origen.

flowchart TD
A[La pantalla llega a STARTED] --> B[repeatOnLifecycle lanza el bloque]
B --> C[Se colecciona el flujo y la interfaz se actualiza]
C --> D[La pantalla baja a CREATED al dejar de verse]
D --> E[Se cancela el bloque entero]
E --> F[El productor pierde su suscriptor y se detiene]
F --> G[La pantalla vuelve a STARTED]
G --> B
style E fill:#f9e2af,color:#11111b
style F fill:#a6e3a1,color:#11111b

repeatOnLifecycle: el patrón y sus consecuencias

repeatOnLifecycle es una función suspendible que recibe un estado mínimo y un bloque. Mientras el ciclo alcanza ese estado, lanza el bloque en una corrutina nueva; cuando baja por debajo, la cancela entera; cuando vuelve a subir, la lanza otra vez desde cero. Se invoca desde lifecycleScope y no retorna hasta que el ciclo se destruye, por lo que todo lo que escribas después de ella en la misma corrutina no se ejecutará mientras la pantalla exista.

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    lifecycleScope.launch {
        repeatOnLifecycle(Lifecycle.State.STARTED) {
            launch { vm.estado.collect { pintar(it) } }
            launch { vm.ubicacion.collect { mover(it) } }
        }
    }
}

El estado que se pasa es casi siempre STARTED, por la razón que quedó establecida en la primera lección: el umbral de lo que alimenta píxeles es la visibilidad y no el foco. Elegir RESUMED produce cortes al abrir un diálogo del sistema o al usar la pantalla dividida; elegir CREATED no arregla nada porque la pantalla oculta también está creada.

Repara también en la estructura interna del ejemplo. Cada flujo se colecciona en su propio launch dentro del bloque, y eso no es un adorno: collect es una función que no retorna mientras el flujo siga vivo, así que dos recolecciones seguidas en la misma corrutina dejarían la segunda sin ejecutarse nunca. Es uno de los errores más frecuentes al escribir este patrón por primera vez, y su síntoma es desconcertante porque no falla nada, simplemente hay una parte de la pantalla que jamás se actualiza.

Cuando solo hay un flujo que consumir existe una variante más compacta: un operador que envuelve el flujo y le aplica la misma política de recolección. Hace exactamente lo mismo por dentro y se lee mejor en el caso simple, pero conviene saber que crea una recolección independiente por cada flujo al que se aplica, mientras que un solo repeatOnLifecycle con varios launch dentro comparte una única observación del ciclo.

lifecycleScope.launch {
    vm.estado
        .flowWithLifecycle(lifecycle, Lifecycle.State.STARTED)
        .collect { pintar(it) }
}

Y hay una consecuencia que se pasa por alto con frecuencia: como el bloque se cancela y se relanza, todo lo que haya dentro se ejecuta muchas veces. Coleccionar flujos es idempotente y por eso encaja de forma natural, pero una llamada puntual colocada ahí se dispara en cada vuelta a primer plano. Los disparos de una sola vez pertenecen al ViewModel, donde la retención garantiza que ocurran una vez por pantalla y no una vez por visita.

⚠️
El flujo caliente que nadie recolecta puede seguir costando dinero

Cancelar la recolección solo detiene el origen si la cadena está preparada para enterarse. Un StateFlow creado con stateIn y la política de compartir mientras haya suscriptores se detiene solo; uno creado con la política de empezar con avidez sigue corriendo aunque no lo mire nadie, con la app en segundo plano y el usuario dormido. El plazo habitual de unos cinco segundos en esa política no es una cifra mágica: es la ventana que permite atravesar una rotación sin desmontar y volver a montar la cadena, y por tanto sin repetir la consulta a la base de datos o a la red.

En Compose, y las fugas que quedan

En Compose el patrón anterior está empaquetado. collectAsStateWithLifecycle colecciona un flujo respetando el ciclo de vida del propietario de la composición, es decir, hace exactamente lo que haría un repeatOnLifecycle con umbral STARTED, y devuelve un estado que la interfaz puede leer. Es la forma correcta de consumir el estado de un ViewModel en una app Android, y su hermana sin ciclo de vida solo es apropiada en código multiplataforma donde ese concepto no existe.

@Composable
fun Pantalla(vm: PantallaViewModel = viewModel()) {
    val estado by vm.estado.collectAsStateWithLifecycle()

    LifecycleStartEffect(Unit) {
        sensores.registrar(oyente)
        onStopOrDispose { sensores.desregistrar(oyente) }
    }

    Contenido(estado)
}

Su comportamiento merece una precisión, porque hay una diferencia con el patrón manual que sorprende: cuando la pantalla deja de verse, la recolección se cancela pero el último valor recibido permanece disponible, de modo que al volver la interfaz se pinta de inmediato con lo último conocido y se actualiza en cuanto llegue una emisión nueva. No hay parpadeo ni estado vacío intermedio, que es justamente lo que se busca. Y como toda la lógica queda dentro de la función, la pantalla no necesita saber en qué estado está su propietario ni gestionar suscripción alguna.

Los efectos con ciclo cierran el círculo para todo lo que no es un flujo. LifecycleStartEffect ejecuta su cuerpo al alcanzar el estado iniciado y su bloque de salida al dejarlo, y LifecycleResumeEffect hace lo propio con el foco, que es donde encaja un recurso exclusivo como la cámara. Antes de que existieran había que escribir a mano un efecto desechable que registrara un observador sobre el propietario de la composición local, y todavía se encuentra ese patrón en código heredado; hace lo mismo con más ruido y más ocasiones de equivocarse.

Existe además un ciclo de vida que no pertenece a ninguna pantalla sino a la aplicación entera, expuesto por el propietario de ciclo del proceso. Su estado sube a iniciado cuando cualquier actividad de tu app se vuelve visible y baja cuando todas dejan de verlo, con un pequeño retardo que evita falsos avisos durante las recreaciones. Es la herramienta correcta para lo que de verdad es global: reanudar una conexión persistente, marcar una sesión de analítica, dejar de sondear un servidor. Y es una tentación peligrosa para todo lo demás, porque no conoce los cambios de configuración ni la navegación y no debe usarse para nada que dependa de una pantalla concreta.

🖼️

Umbral STARTED

Todo lo que alimenta píxeles: recolección de estado, sensores de lectura, animaciones continuas. Es el valor por defecto y casi siempre el correcto.

🎯

Umbral RESUMED

Lo que depende de la interacción directa o de un recurso que solo una ventana debería usar. Cortará al abrir cualquier diálogo del sistema.

🌍

Ciclo del proceso

Lo verdaderamente global. Ignora la navegación y las recreaciones, así que no sirve para estado de pantalla.

♾️

Ámbito de aplicación

Trabajo que debe terminar aunque el usuario se vaya. Legítimo, pero no sobrevive al proceso: si la promesa es seria, delégala al sistema.

Queda el catálogo de lo que sigue gastando aunque hagas todo lo anterior, porque no todo el trabajo pertenece a una pantalla. Una corrutina lanzada en el ámbito de la aplicación no se cancela nunca y es correcta solo si su promesa debe cumplirse aunque el usuario se vaya. Un receptor registrado en onCreate y no liberado sigue despertando el proceso. Y sobre todo: nada de esto sobrevive a la muerte del proceso, así que el trabajo que de verdad tiene que terminar no pertenece a ninguna corrutina atada a una pantalla, sino a los mecanismos que el sistema custodia por ti.

Atar el trabajo al ciclo de vida es aceptar que la atencion es el recurso escaso

Merece la pena preguntarse por qué la solución correcta acabó siendo cancelar y relanzar, y no algo más suave como pausar y reanudar, que a primera vista parece más eficiente y desde luego más intuitivo. La respuesta obliga a mirar dónde está de verdad el gasto, y es un desplazamiento que cuesta hacer: el gasto casi nunca está en el consumidor. Quien quema batería no es la línea que actualiza un texto en pantalla, sino el sensor que sigue muestreando, la conexión que sigue abierta, la consulta que se reobserva, el temporizador que sigue despertando el procesador. Pausar al consumidor detiene lo barato y deja corriendo lo caro, y encima lo hace de forma invisible, porque la pantalla ya no muestra nada que delate el consumo. Cancelar, en cambio, es una señal que viaja hacia arriba por toda la cadena hasta alcanzar al productor real y apagarlo, y esa propagación es exactamente el mecanismo que la concurrencia estructurada existía para proporcionar. La aparente ineficiencia de reconstruir la suscripción es el precio de tener una única señal que llegue hasta el fondo, y es un precio ridículo comparado con lo que se ahorra. Hay algo más general escondido aquí, y es lo que convierte esta lección en la conclusión natural del nivel. Todo el ciclo de vida de Android es un sistema para responder a una sola pregunta, cuánta atención humana tienes ahora mismo, y todo el diseño de una app consiste en hacer que el gasto de recursos sea proporcional a esa respuesta. Cuando el usuario mira, gasta lo que haga falta; cuando no mira, no gastes nada; cuando el sistema necesita tu memoria, entrégala sin resistirte y aprende a volver. Visto así, los cinco temas del nivel dejan de ser cinco asuntos separados y se revelan como uno solo mirado desde cinco distancias: los callbacks son la señal, la recreación es la prueba de que separaste el estado de su presentación, la muerte del proceso es el límite de lo que puedes prometer, el estado guardado es lo que decides no perder, y observar el ciclo de vida es la disciplina que hace que todo lo anterior tenga efecto sobre la batería de alguien. Una app que respeta ese contrato desaparece cuando no hace falta y regresa como si nunca se hubiera ido, y ese comportamiento, que ningún usuario sabrá nombrar, es lo único que separa una app que se tolera de una que se conserva instalada.

⚔️ Haz que tu app descanse
  1. Busca en tu código toda recolección de flujos hecha desde lifecycleScope sin repeatOnLifecycle y registra las emisiones con la app en segundo plano. Anota cuántas ocurren mientras nadie mira.
  2. Convierte una de ellas al patrón correcto y comprueba con registros que la cancelación llega hasta el productor y no solo hasta el consumidor.
  3. Crea un StateFlow con la política ávida y otro con la de suscriptores activos sobre la misma consulta. Compara qué ocurre en segundo plano y qué ocurre al girar el dispositivo.
  4. Sustituye un efecto desechable con observador manual por LifecycleStartEffect y explica qué caso concreto dejas de poder equivocar.
  5. Coloca deliberadamente una llamada de una sola vez dentro del bloque de repeatOnLifecycle, cuenta cuántas veces se dispara en cinco idas y vueltas a primer plano, y llévala al sitio que le corresponde.