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

El ciclo de vida de una Activity: seis puertas y un contrato

El ciclo de vida no es una lista de métodos que hay que memorizar sino la interfaz por la que el sistema te comunica cuánta atención del usuario tienes en este instante. Esta lección separa dos cosas que casi siempre se confunden: los estados, que duran, y los callbacks, que solo avisan de que se ha cruzado una frontera. Recorre los tres pares simétricos y explica qué recurso gobierna cada uno, por qué adquirir en la puerta ascendente y liberar en su gemela descendente es la única disciplina que evita fugas, y qué obligaciones concretas tiene cada callback. Detalla el catálogo de errores por puerta: trabajo bloqueante en onCreate que retrasa el primer fotograma, persistencia en onPause que congela la transición, y la confianza indebida en un onDestroy que puede no ejecutarse jamás. Cierra con la ruptura que introdujo el multiventana, cuando perder el foco dejó de significar dejar de verse, y con la razón por la que el umbral correcto para alimentar píxeles es visible y no enfocado.

⏱ 18 min

Todo el que llega a Android recibe el mismo diagrama con seis cajas y flechas, lo memoriza y sigue adelante creyendo que ya lo entendió. Casi siempre lo entendió mal, porque el diagrama sugiere una escalera de métodos que se llaman en orden y lo que realmente describe es otra cosa: un canal por el que el sistema operativo te informa, en tiempo real, de cuánta atención del usuario tiene tu pantalla ahora mismo. Tu app no decide cuándo se ejecuta ninguno de esos métodos. Los ejecuta Android en respuesta a hechos que ocurren fuera de tu programa: una llamada entrante, un usuario que gira el dispositivo, otra app que reclama la mitad de la pantalla, un sistema al que se le acaba la memoria. Aprender el ciclo de vida no es aprender seis nombres, es aceptar un reparto de poder.

🎯 Al terminar esta lección sabrás
  • Distinguir los estados, que duran en el tiempo, de los eventos y callbacks, que solo señalan una frontera cruzada.
  • Interiorizar la simetría de los tres pares y decidir qué recurso pertenece a cada uno.
  • Reconocer el trabajo prohibido en cada callback y el coste concreto de ponerlo ahí.
  • Entender por qué perder el foco dejó de significar dejar de verse y qué umbral usar para alimentar la interfaz.

Estados que duran, callbacks que solo avisan

La primera corrección de modelo mental es esta: Lifecycle expone un estado, no una secuencia. Los valores son INITIALIZED, CREATED, STARTED, RESUMED y DESTROYED, y en cualquier instante tu Activity está exactamente en uno de ellos, que puedes consultar con lifecycle.currentState. Los callbacks que tanto se memorizan no son el estado: son los eventos que se emiten al cruzar de un estado al siguiente. onStart no es un lugar donde tu app vive, es un aviso momentáneo de que acaba de pasar de CREATED a STARTED.

Esta distinción parece pedante hasta que produce el primer bug. Un componente que se suscribe en onStart y se desuscribe en onStop funciona porque los eventos vienen emparejados. Un componente que se registra tarde, después de que el evento ya pasó, jamás recibe el aviso y se queda esperando eternamente algo que no volverá. Por eso las librerías modernas no te dejan escuchar eventos sueltos: te dejan preguntar por el estado actual con lifecycle.currentState y te entregan la transición con un observador que se pone al día en el momento de registrarse. La API que ves como una lista de métodos es, por debajo, una máquina de estados con memoria.

class DetalleActivity : ComponentActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent { Pantalla() }        // el arbol de UI se declara una sola vez
    }

    override fun onStart() { super.onStart() }      // ya se ve, aun sin foco
    override fun onResume() { super.onResume() }    // tiene el foco de entrada
    override fun onPause() { super.onPause() }      // pierde el foco, puede seguir visible
    override fun onStop() { super.onStop() }        // deja de verse por completo
    override fun onDestroy() { super.onDestroy() }  // puede no llegar nunca
}

Falta un séptimo nombre que rompe la simetría aparente y suele desconcertar: onRestart. No corresponde a ninguna transición nueva, solo distingue la primera subida a visible de las siguientes. Se ejecuta cuando una pantalla que ya estuvo parada vuelve al frente, justo antes de su onStart, y su única utilidad legítima es refrescar datos que pudieron quedarse obsoletos mientras nadie miraba. En una app moderna, donde los datos llegan como flujos observables que ya se reactivan solos, casi nunca hace falta escribirlo.

La forma profesional de trabajar con todo esto, sin embargo, no es sobrescribir métodos sino registrar observadores. Sobrescribir obliga a que la lógica de cada componente viva dentro de la Activity y a que alguien recuerde llamarlo desde los seis sitios; observar deja que cada componente se ate a sí mismo y sobreviva a cualquier refactorización de la pantalla que lo aloja.

class ReproductorConCiclo(propietario: LifecycleOwner) : DefaultLifecycleObserver {

    init { propietario.lifecycle.addObserver(this) }   // se pone al dia al registrarse

    override fun onStart(propietario: LifecycleOwner) = reanudar()
    override fun onStop(propietario: LifecycleOwner) = pausar()
}

// consultar el estado en cualquier momento, sin depender de haber visto el evento
val puedeMostrar = lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)
flowchart TD
A[Creada tras onCreate] --> B[Visible tras onStart]
B --> C[Con foco tras onResume]
C -->|otra ventana reclama el foco| D[onPause y sigue visible]
D -->|recupera el foco| C
D -->|queda tapada del todo| E[onStop y deja de verse]
E -->|el usuario regresa| F[onRestart y luego onStart]
F --> B
E -->|finish o cambio de configuracion| H[onDestroy]
E -->|el sistema recicla el proceso| G[No se ejecuta nada mas]
style G fill:#f38ba8,color:#11111b
style C fill:#a6e3a1,color:#11111b

Los tres pares y el recurso que gobierna cada uno

Los seis callbacks son en realidad tres pares simétricos, y la simetría no es estética sino operativa: cada recurso que adquieras en una puerta ascendente debe liberarse en su gemela descendente, sin excepciones y sin atajos. onCreate y onDestroy acotan la existencia de la instancia y por tanto la construcción de todo lo que dura tanto como ella. onStart y onStop acotan la visibilidad, es decir, el intervalo en que lo que hagas puede llegar a los ojos de alguien. onResume y onPause acotan el foco de entrada, el intervalo en que las pulsaciones y el teclado se dirigen a ti.

De ahí se deduce dónde va cada cosa sin necesidad de tablas. Un sensor cuya lectura solo tiene sentido si se muestra pertenece al par de visibilidad. Una cámara, que es un recurso exclusivo del sistema y solo puede tenerla una ventana a la vez, pertenece al par del foco en dispositivos con multiventana, porque una app visible pero sin foco debe cederla. La recolección de un flujo que alimenta píxeles pertenece a la visibilidad, no al foco, y esa decisión es tan importante que la última lección del nivel entera va sobre ella.

class MapaActivity : ComponentActivity() {

    private lateinit var sensores: SensorManager

    override fun onStart() {
        super.onStart()
        sensores.registerListener(oyente, brujula, SensorManager.SENSOR_DELAY_UI)
    }

    override fun onStop() {
        super.onStop()
        sensores.unregisterListener(oyente)   // gemelo exacto: sin esto, fuga garantizada
    }
}
🧱

onCreate y onDestroy

Acotan la existencia de la instancia. Aquí va lo que se construye una vez por pantalla: el árbol de interfaz, el ViewModel, el enlace con las dependencias.

👁️

onStart y onStop

Acotan la visibilidad. Aquí va todo lo que produce píxeles o alimenta la interfaz: sensores de lectura, recolección de flujos, animaciones continuas.

🎯

onResume y onPause

Acotan el foco de entrada. Aquí va lo que depende de la interacción o de un recurso exclusivo que solo puede tener una ventana a la vez.

💾

El guardado de estado

No es un par sino un punto: ocurre antes de dejar de verse y es lo último razonablemente garantizado antes de perder el proceso.

La disciplina tiene una consecuencia que conviene decir en voz alta: si tu código de adquisición y el de liberación no están en callbacks emparejados, tienes un bug latente aunque la app funcione hoy. Registrar en onCreate y desregistrar en onStop deja un oyente vivo la segunda vez que la pantalla vuelve al frente, porque onCreate no se repite pero onStop sí. Registrar en onResume y desregistrar en onStop duplica la suscripción cada vez que el usuario abre y cierra un diálogo del sistema. Ninguno de esos errores produce un fallo inmediato; producen consumo de batería que nadie sabe atribuir.

Lo que no se hace en cada puerta

onCreate es el sitio donde más daño se hace, porque todo lo que escribas ahí se ejecuta antes de que el usuario vea el primer fotograma. Cualquier lectura de disco, cualquier consulta a base de datos, cualquier inicialización de librería pesada se convierte directamente en tiempo de arranque percibido y, si se pasa de unos pocos segundos, en un cierre por no responder. La regla operativa es que onCreate solo declara: instala el árbol de interfaz, pide el ViewModel, conecta la inyección de dependencias, y delega todo lo que tarde a una corrutina o a una carga perezosa.

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)

    // mal: lectura de disco en el hilo principal antes del primer fotograma
    val ajustes = leerAjustesDeDisco()

    // bien: se declara la pantalla y el trabajo se delega
    setContent { Pantalla() }
}

onStart tiene un peligro más discreto: es el callback que más veces se ejecuta a lo largo de la vida de una pantalla, porque cada vuelta desde el escritorio, cada regreso de otra app y cada cierre de una pantalla superior pasan por él. Lo que pongas ahí debe ser barato y, sobre todo, idempotente. Una petición de red colocada en onStart se dispara cada vez que el usuario mira el reloj y vuelve, y esa clase de derroche no aparece en ningún perfil de rendimiento porque cada llamada individual es perfectamente razonable.

onResume sufre el mismo problema amplificado, ya que también se ejecuta al cerrar cualquier diálogo del sistema, incluido el de permisos. Un patrón que se ve con frecuencia y que hay que evitar es comprobar el permiso y pedirlo desde onResume: como la propia petición hace pasar la pantalla por onPause y luego otra vez por onResume, se construye un bucle que solo se rompe si el usuario acepta.

onPause es el segundo sitio donde más daño se hace, por una razón que casi nadie conoce: mientras tu onPause no retorne, la siguiente pantalla no puede recibir su onResume. Es decir, todo lo que hagas ahí se lo cobra el usuario como retraso al navegar. Persistir datos en onPause es una costumbre heredada de una época en la que era el último callback garantizado; desde hace muchas versiones no lo es, y el lugar correcto para guardar es onStop, que ya ocurre con la pantalla oculta y no bloquea ninguna transición visible.

onDestroy cierra el catálogo con la advertencia más importante de la lección: no está garantizado. Si el sistema decide reciclar tu proceso porque necesita memoria, lo mata sin ceremonia y ningún callback tuyo se ejecuta. Todo lo que pongas ahí es limpieza de buena voluntad, útil para el caso ordenado, inútil para el caso que de verdad importa. Cualquier promesa que deba cumplirse pase lo que pase no pertenece a onDestroy, sino a un mecanismo que sobreviva a tu proceso.

⚠️
onDestroy no es el unico que no viene garantizado

La lista completa de callbacks que el sistema puede saltarse cuando mata el proceso incluye onDestroy y, en escenarios de escasez extrema, también parte de lo que esperabas después de onStop. Lo único razonablemente garantizado antes de perder el proceso es el guardado de estado, que ocurre antes de onStop en las versiones modernas. De ahí una heurística útil para revisar código ajeno: si encuentras una escritura importante dentro de onDestroy, no estás mirando limpieza, estás mirando una pérdida de datos esperando a que baje la memoria del dispositivo.

El foco dejó de ser la visibilidad

Durante los primeros años de Android, perder el foco y dejar de verse eran prácticamente lo mismo: solo había una app en pantalla y onPause era una señal fiable de que ya nadie miraba. El multiventana rompió esa equivalencia para siempre. Hoy tu Activity puede estar en STARTED y perfectamente visible en media pantalla mientras el usuario teclea en la otra mitad, y en esa situación tu onPause ya se ejecutó. Si detuviste el vídeo, vaciaste la lista o paraste la animación en onPause, el usuario ve una pantalla congelada que sí está mirando.

El cambio no fue gratuito para la plataforma. Durante años, el contrato decía que solo una actividad podía estar en RESUMED en todo el sistema, y muchas apps se apoyaron en esa exclusividad para gestionar recursos únicos. Con el multiventana, las versiones modernas permiten que varias actividades estén simultáneamente enfocadas en dispositivos que lo declaran, de modo que ni siquiera el foco garantiza ya la exclusividad. Lo único que queda garantizado es que solo una ventana recibe la entrada del teclado en cada momento, y cualquier recurso realmente exclusivo debe adquirirse comprobando y cediéndose ante el fallo, nunca asumiendo que el ciclo de vida lo reserva para ti.

La conclusión práctica es una regla que conviene grabar: el umbral para todo lo que produce píxeles es STARTED, no RESUMED. Se detiene cuando se deja de ver, no cuando se pierde el foco. RESUMED queda reservado para lo que depende de la entrada del usuario o de recursos exclusivos que solo una ventana puede tener a la vez. Esta única distinción explica por qué las APIs modernas que verás en la última lección hablan de repetir el trabajo mientras el ciclo esté al menos en STARTED y no mencionan el foco en ningún sitio.

El ciclo de vida es un protocolo de negociacion, no una lista de metodos

Conviene detenerse en la pregunta que casi nadie se hace: por qué existe siquiera este mecanismo, cuando en un escritorio un programa se abre, corre y se cierra sin que nadie le pregunte nada. La respuesta está en una diferencia de recursos que lo cambia todo. Un teléfono tiene una batería que debe durar un día entero, una memoria que no se amplía, y una pantalla en la que solo cabe una atención humana a la vez. En ese entorno, dejar que cada aplicación decida por sí misma cuánto trabajo hace es matemáticamente inviable: bastan diez apps razonables comportándose cada una razonablemente para agotar el dispositivo, porque ninguna de ellas tiene la información necesaria para saber que las otras nueve existen. Solo el sistema operativo posee esa vista global, y por tanto solo él puede arbitrar. El ciclo de vida es la forma que tomó ese arbitraje. Ahora observa la elegancia del diseño con esa clave. El sistema no te ordena qué hacer, porque no sabe qué haces; te informa de un hecho que solo él conoce, que es cuánta atención del usuario tienes, y te deja traducir esa información a decisiones sobre tus propios recursos. Esa es exactamente la división de conocimiento correcta: él tiene la vista global del dispositivo, tú tienes la vista local de tu programa, y el protocolo cruza justo la información mínima que hace falta. Por eso los estados están graduados en lugar de ser un simple encendido y apagado: entre existir y no existir hay dos escalones intermedios, visible y enfocado, y cada escalón habilita una clase distinta de gasto. Un sensor de brújula tiene sentido si se ve; un micrófono en primer plano solo si el usuario está interactuando. La granularidad de la escalera es la granularidad de las decisiones de consumo que un desarrollador puede tomar de forma sensata. Y la consecuencia final es la que más cuesta aceptar: si el ciclo de vida es un protocolo de negociación, entonces incumplirlo no produce un error de compilación ni una excepción, produce una app que funciona perfectamente en tu dispositivo de gama alta enchufado a la corriente y que agota la batería de otro en tres horas. Los fallos de ciclo de vida no se manifiestan como fallos, se manifiestan como reseñas de una estrella escritas por gente que nunca sabrá qué le pasó a su teléfono.

⚔️ Audita tus propias puertas
  1. Instrumenta una pantalla con un registro en los seis callbacks y ejecuta cinco escenarios: abrir, ir al escritorio, volver, girar, y pulsar atrás. Anota la secuencia exacta de cada uno antes de leer ninguna documentación.
  2. Pon la app en modo pantalla dividida y observa qué callbacks se ejecutan al tocar la otra mitad. Comprueba con lifecycle.currentState que sigues en STARTED mientras tu onPause ya pasó.
  3. Busca en tu código cualquier adquisición de recurso y localiza su liberación. Si no están en callbacks gemelos, corrígelo y explica por escrito qué fuga concreta acabas de cerrar.
  4. Mide el tiempo que tarda tu onCreate y bájalo por debajo de unos pocos milisegundos moviendo todo lo que tarde fuera de él.
  5. Añade una escritura en onDestroy, mata el proceso desde la terminal con la app en segundo plano y demuestra que esa escritura nunca ocurrió.