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

La muerte del proceso: por qué el sistema te mata y cómo provocarlo

Android no cierra tu aplicación, la recicla: cuando la memoria escasea elige víctimas entre los procesos que no están delante del usuario y las elimina sin ejecutar ni un callback más. Esta lección explica la lógica económica detrás de esa decisión, por qué mantener procesos en caché es una optimización y no un descuido, y cómo el asesino de baja memoria ordena a los candidatos mediante una jerarquía de importancia que va del primer plano a la caché. Detalla qué desaparece con el proceso y qué sobrevive fuera de él: la tarea y su pila de retroceso, el estado de instancia guardado y el trabajo delegado al sistema. Distingue con precisión la muerte iniciada por el sistema, que restaura, de la parada forzosa iniciada por el usuario, que no restaura, porque confundirlas invalida cualquier prueba. Cierra con el procedimiento reproducible para provocar la muerte del proceso a propósito y con la razón por la que la opción de no conservar actividades no prueba lo que casi todo el mundo cree.

⏱ 18 min

Hay una frase que conviene decir sin rodeos porque cambia la forma de escribir código para siempre: en Android tu aplicación no termina, la terminan. No existe un cierre ordenado que puedas anticipar, no hay un aviso previo fiable, no se ejecuta ningún callback de despedida. Un día el usuario deja tu app para responder un mensaje, abre dos cosas más, el sistema necesita memoria, elige tu proceso porque es el candidato más barato, y lo elimina. Cuarenta minutos después el usuario vuelve, ve tu icono en la lista de recientes, lo toca esperando encontrar la pantalla donde lo dejó, y lo que ocurra en ese instante depende por completo de decisiones que tomaste al escribir el código.

🎯 Al terminar esta lección sabrás
  • Comprender la lógica de recursos que hace de la muerte de procesos una optimización y no un fallo.
  • Situar tu proceso en la jerarquía de importancia y saber qué lo hace más o menos apetecible.
  • Distinguir lo que muere con el proceso de lo que sobrevive fuera de él, empezando por la tarea.
  • Provocar la muerte del proceso de forma reproducible y descartar los métodos que prueban otra cosa.

Matar procesos es la optimización, no el fallo

La intuición traída del escritorio dice que un programa en segundo plano debería seguir ahí hasta que alguien lo cierre, y que matarlo es un síntoma de que algo va mal. En un dispositivo móvil la lógica se invierte. Arrancar un proceso desde cero es caro: hay que bifurcar desde el proceso plantilla del sistema, cargar clases, inicializar la aplicación, reconstruir la pantalla. Mantener vivo un proceso que ya hizo todo eso, aunque nadie lo esté usando, es la forma más barata que existe de que volver a la app sea instantáneo.

Hay además una razón de arquitectura que refuerza la decisión. Android no tiene memoria de intercambio en disco como un ordenador de escritorio: no existe un fichero donde volcar páginas de un proceso dormido para recuperarlas más tarde, entre otras cosas porque la memoria de estado sólido de un teléfono se desgasta con la escritura y porque la latencia arruinaría la sensación de fluidez. Sin intercambio, la única forma de liberar memoria es eliminar procesos enteros. Lo que en otros sistemas es una degradación gradual, aquí es necesariamente una decisión binaria sobre quién vive.

Por eso Android conserva procesos inactivos en caché mientras haya memoria libre, y por eso mismo tiene que poder eliminarlos sin contemplaciones cuando deja de haberla. Un teléfono con memoria libre no está desperdiciando nada solo si esa memoria libre se usa; la política óptima es llenarla de procesos en caché y desalojarlos por orden de menor valor cuando llegue una petición legítima. Tu proceso en segundo plano no es un ciudadano al que el sistema maltrata: es una entrada de caché que el sistema mantiene por cortesía y desaloja por necesidad.

🔥

Primer plano

La actividad con la que el usuario interactúa ahora, o un servicio ligado a ella. Es lo último que el sistema tocaría; matarlo se percibe como un fallo del dispositivo.

👁️

Visible

Se ve pero no tiene el foco: media pantalla en modo dividido, una actividad detrás de un diálogo. Solo cae bajo presión seria de memoria.

⚙️

Servicio

Trabajo declarado que sigue corriendo sin interfaz. Sobrevive más que la caché, pero el sistema lo detiene y lo reprograma si hace falta.

♻️

En caché

Nada visible, nada declarado. Es el candidato natural y el que casi siempre paga la factura, ordenado además por lo reciente que sea su uso.

Cómo se elige la víctima

El componente responsable es un vigilante de baja memoria que, en las versiones modernas, corre en espacio de usuario y observa las señales de presión de memoria del núcleo en lugar de esperar a que el sistema se ahogue. A cada proceso le corresponde una puntuación de ajuste que refleja su importancia según la categoría en la que esté, y cuando la presión supera un umbral se eliminan procesos empezando por la puntuación peor. Dentro de la misma categoría, el criterio de desempate suele ser el uso más antiguo y el consumo de memoria: un proceso en caché grande y viejo es el candidato ideal.

Antes de llegar al desalojo, el sistema ofrece un aviso intermedio que casi nadie aprovecha. A través de las llamadas de recorte de memoria, la plataforma informa a los procesos de que la presión está subiendo y les da la oportunidad de soltar lo que puedan reconstruir: cachés de imágenes, estructuras derivadas, buffers grandes. No es una despedida ni una garantía de nada, porque una caída brusca de memoria puede saltárselo por completo, pero un proceso que responde a esas señales adelgazando su huella se vuelve un candidato menos apetecible y sobrevive más tiempo en caché.

class App : Application(), ComponentCallbacks2 {
    override fun onTrimMemory(nivel: Int) {
        super.onTrimMemory(nivel)
        if (nivel >= TRIM_MEMORY_BACKGROUND) cacheDeImagenes.vaciar()
    }
}

Hay dos matices que conviene conocer porque cambian el comportamiento observado. El primero es que los procesos en caché de las versiones recientes se congelan, es decir, dejan de recibir tiempo de procesador por completo: no consumen batería mientras esperan, pero tampoco pueden reaccionar a nada. El segundo es que hay presiones que no vienen de la memoria sino de la política de energía, como los cubos de inactividad y el modo de reposo profundo, que no matan el proceso pero restringen tanto lo que puede hacer que el efecto práctico para tu trabajo pendiente es equivalente.

A esa lista hay que sumar una tercera fuente de muertes que no depende de la escasez sino de tu propio comportamiento. Un proceso que consume demasiada memoria puede superar el límite del montón asignado a su aplicación y morir por falta de memoria aunque el dispositivo tenga de sobra. Un servicio en primer plano que no muestra su notificación en el plazo debido se cierra. Un trabajo que excede su tiempo permitido se detiene. Y en muchos dispositivos existen capas del fabricante con políticas de ahorro más agresivas que las del sistema base, que cierran procesos con criterios propios y no documentados. Diseñar suponiendo únicamente el comportamiento del sistema puro es optimista.

⚠️
La memoria del emulador miente sobre la realidad del usuario

Un emulador con memoria generosa y un dispositivo de desarrollo de gama alta con el depurador conectado son el peor entorno posible para descubrir estos problemas, porque en ellos la muerte del proceso casi nunca ocurre de forma espontánea. El dispositivo real de un usuario cualquiera, con memoria ajustada y una docena de apps abiertas, la provoca varias veces al día. Ese desajuste explica un fenómeno recurrente: fallos que ningún miembro del equipo consigue reproducir y que en las tiendas aparecen descritos una y otra vez como que la app se reinicia sola. Nunca son aleatorios; son la muerte del proceso encontrando un estado que nadie guardó.

Lo que muere y lo que no

Cuando el proceso desaparece se lleva absolutamente todo lo que vivía en memoria: instancias de Activity, cada ViewModel con su contenido, los objetos únicos del grafo de dependencias, las cachés, las corrutinas en vuelo, las propiedades de nivel superior. No queda nada y no se ejecuta ningún callback tuyo para despedirlas. Esta es la diferencia esencial con el cambio de configuración de la lección anterior: allí el ViewModel sobrevivía porque alguien lo pasaba de mano en mano dentro del mismo proceso; aquí no hay ninguna mano.

Conviene subrayar una consecuencia que rompe muchos diseños ingenuos. Un objeto único que guarda la sesión iniciada, el carrito de la compra o el usuario actual solo en memoria funciona impecablemente durante todo el desarrollo y desaparece en producción con una frecuencia que nadie sabe medir. El síntoma que llega al equipo no es un fallo sino un comentario difuso: que la app a veces pide iniciar sesión otra vez, que a veces se pierde lo que estaba haciendo. Detrás de ese a veces hay siempre el mismo suceso.

Lo que sí sobrevive vive fuera de tu proceso, en el sistema. La tarea, que es la pila de actividades que el usuario percibe como una app en la lista de recientes, la mantiene el gestor de actividades, y con ella conserva el estado de instancia guardado de cada actividad de la pila. Por eso al volver el usuario no aterriza en la pantalla inicial sino en la que dejó: el sistema arranca un proceso nuevo, recrea la actividad correcta y le entrega el paquete de datos que guardaste. También sobrevive, por definición, todo lo que escribiste en disco y todo lo que delegaste al sistema en forma de trabajo programado o alarma.

Esa última categoría explica una pregunta que aparece pronto y cuya respuesta parece burocrática hasta que se entiende: por qué existe un gestor de trabajo en segundo plano si una corrutina ya sabe hacer las cosas en otro hilo. La diferencia no está en la concurrencia sino en dónde vive la promesa. Una corrutina es memoria de tu proceso y desaparece con él; un trabajo programado es un registro que custodia el sistema, con sus condiciones y sus reintentos, y que sobrevive incluso al reinicio del dispositivo. Cualquier cosa que deba ocurrir aunque el usuario cierre la app pertenece al segundo mecanismo, y confundirlos produce sincronizaciones que funcionan en las pruebas y se pierden en producción.

// Muere con el proceso: valido solo mientras la pantalla exista
viewModelScope.launch { repo.sincronizar() }

// Sobrevive al proceso: la promesa la custodia el sistema
WorkManager.getInstance(contexto).enqueueUniqueWork(
    "sincronizacion",
    ExistingWorkPolicy.KEEP,
    OneTimeWorkRequestBuilder<SincronizarWorker>().build(),
)
flowchart TD
A[El usuario deja la app] --> B[El proceso pasa a estar en cache]
B --> C{Hace falta memoria}
C -->|no| D[El proceso sigue congelado y esperando]
C -->|si| E[El vigilante elige por puntuacion e importancia]
E --> F[Se elimina el proceso sin ejecutar callbacks]
F --> G[La tarea y el estado guardado siguen en el sistema]
G --> H[El usuario vuelve desde recientes]
H --> I[Proceso nuevo y Activity recreada con el estado guardado]
D --> H
style F fill:#f38ba8,color:#11111b
style G fill:#a6e3a1,color:#11111b

Provocarla a propósito, y los métodos que engañan

La distinción que invalida más pruebas de las que nadie sospecha es la que separa la muerte iniciada por el sistema de la parada iniciada por el usuario. Si el usuario desliza la app fuera de la lista de recientes o pulsa forzar detención en los ajustes, está expresando la voluntad de empezar de cero: el sistema descarta la tarea y el estado guardado, y la próxima apertura arranca limpia. Eso no es lo que quieres probar. El botón de detener de tu entorno de desarrollo hace exactamente eso, y por eso no sirve como simulación.

El procedimiento correcto es corto. Pon la app en segundo plano con el botón de inicio, no la cierres, y desde la terminal ejecuta la orden de matar el proceso; después vuelve a la app desde la lista de recientes. Esa orden solo actúa sobre procesos que el sistema podría haber matado por su cuenta, que es precisamente la garantía de fidelidad que buscas.

Hay dos condiciones que arruinan la prueba si se pasan por alto. La primera es tener el depurador conectado: mientras lo esté, el proceso recibe un trato distinto y algunas rutas de reciclado no se comportan igual, así que la comprobación seria se hace con la aplicación ejecutándose por su cuenta. La segunda es el orden de los pasos: si mandas la app al fondo y la matas antes de que el sistema haya terminado de guardar su estado, estás probando algo que en la práctica no ocurre. Espera un par de segundos tras salir y tendrás la secuencia realista.

adb shell am kill com.ejemplo.app          # simula la muerte del sistema
adb shell am kill-all                      # mata todos los procesos en segundo plano
adb shell dumpsys activity processes | head # ver la importancia actual de cada proceso

Hay una segunda vía, complementaria y muy útil para pruebas repetidas: limitar desde las opciones de desarrollador el número de procesos en segundo plano a cero. Con ese ajuste activo, salir de la app la convierte en candidata inmediata y el reciclado ocurre casi siempre, lo que permite recorrer toda la aplicación comprobando cada pantalla sin tener que lanzar una orden por cada prueba. Es agotador como modo de trabajo diario, pero es el mejor barrido que existe antes de una publicación.

La plataforma ofrece además una vía para saber, ya dentro del proceso nuevo, por qué terminó el anterior. La lista de razones de salida que expone el gestor de actividades incluye la muerte por baja memoria, la parada forzosa por el usuario, el cierre por exceder límites y el fallo de la propia aplicación, con marca de tiempo y con la importancia que tenía el proceso en ese instante. Registrarla al arrancar convierte un fenómeno invisible en una métrica que se puede vigilar en producción, y es la única forma honesta de saber con qué frecuencia les ocurre de verdad a tus usuarios.

val am = getSystemService(ActivityManager::class.java)
val razones = am.getHistoricalProcessExitReasons(packageName, 0, 5)
razones.firstOrNull()?.let { salida ->
    // salida.reason distingue baja memoria de parada forzosa o fallo propio
    registrar("Salida anterior: " + salida.reason + " importancia " + salida.importance)
}

Queda el malentendido más extendido del tema: la opción de desarrollador que no conserva actividades. Esa opción destruye cada actividad en cuanto el usuario sale de ella, y es una prueba excelente de recreación, pero el proceso sigue vivo. Los objetos únicos siguen ahí, las cachés en memoria siguen ahí, y aunque el ViewModel se limpie con la actividad, cualquier estado que hayas colgado del proceso seguirá tapando el agujero. Una app puede pasar esa prueba con nota y romperse en la muerte real. Úsala como primer filtro y nunca como certificado.

ℹ️
La restauracion tambien puede fallar por el otro extremo

Un fallo menos comentado que la pérdida de estado es su opuesto: restaurar demasiado. Si al volver el sistema recrea una pantalla profunda de tu jerarquía y esa pantalla depende de datos que se cargaron en una anterior, aparece un estado imposible que ningún flujo de navegación normal produce, y que se manifiesta como un valor nulo o una pantalla vacía. La comprobación que hay que hacer no es solo si sobrevive el estado, sino si cada pantalla es capaz de arrancar por sí sola con lo que hay en su propio estado guardado. Si alguna necesita que otra haya corrido antes, esa dependencia oculta se romperá exactamente aquí.

Programar para un final que no puedes observar

La muerte del proceso obliga a algo que casi ninguna otra plataforma exige y que, una vez asumido, reordena la manera de pensar todo el programa: escribir código para un desenlace del que nunca serás testigo. En un sistema normal, la salida es un evento que ocurre dentro de tu programa y del que puedes enterarte, registrar y responder. Aquí el final ocurre fuera, lo decide otro, y la primera noticia que tendrás de él llegará en un proceso distinto que ya no recuerda nada. No hay callback posible porque no hay tiempo que asignar: un sistema que necesita memoria ahora no puede permitirse esperar a que un proceso desalojado ejecute su despedida, y menos aún fiarse de que sea breve. La ausencia de aviso no es un olvido de diseño, es la única política que hace que el desalojo sea instantáneo y por tanto útil. Lo interesante es adónde te empuja esa restricción. Si no puedes reaccionar al final, la única defensa es que en cada instante anterior tu estado importante ya esté fuera de tu proceso. Eso convierte la persistencia en una obligación continua en lugar de un acto final, y cambia la pregunta que uno le hace al código: no cuándo guardo, sino si en este preciso momento perder toda la memoria me costaría algo. Escrita así, la pregunta es la misma que se hacen los sistemas distribuidos ante la caída de un nodo, y las respuestas son igual de familiares: identifica una única fuente de verdad duradera, trata la memoria como una caché reconstruible, y haz que reanudar sea idempotente porque va a ocurrir muchas veces. Hay una consecuencia final que conviene aceptar con humildad. Como esta clase de fallo es invisible en el dispositivo del desarrollador y frecuente en el del usuario, la calidad de una app en este terreno no se mide con lo que ves sino con lo que decides comprobar deliberadamente. La muerte del proceso es la única categoría de error de Android que exige un acto de imaginación disciplinada: asumir que ya está ocurriendo ahora mismo, en miles de dispositivos, a usuarios que jamás lo reportarán porque no saben que existe un nombre para lo que les pasó.

⚔️ Mata tu app y mira qué se lleva
  1. Reproduce el ciclo completo: llena una pantalla de estado, ve al escritorio, mata el proceso desde la terminal y vuelve desde recientes. Anota exactamente qué perdiste.
  2. Repite el mismo experimento deslizando la app fuera de recientes y compara. Explica por escrito por qué los dos resultados difieren.
  3. Coloca un objeto único de tu grafo con un contador en memoria y demuestra que sobrevive a la opción de no conservar actividades pero no a la muerte del proceso.
  4. Inspecciona la importancia de tu proceso mientras la app está en primer plano, visible en pantalla dividida y en segundo plano. Relaciona cada valor con la jerarquía.
  5. Elige la operación más larga de tu app y responde: si el proceso muere justo a la mitad, qué ve el usuario al volver y qué habría que haber guardado para que no lo notara.