SavedStateHandle: sobrevivir a la muerte del proceso
El ViewModel resuelve la rotación, pero hay una segunda muerte que no cubre: la del proceso entero, cuando el sistema decide reclamar memoria mientras tu app está en segundo plano. Esta lección separa con precisión las dos muertes, explica por qué la segunda es invisible para el usuario y devastadora para el estado en memoria, y presenta SavedStateHandle como el único canal que las atraviesa ambas. Recorre su mecanismo real —el registro de estado guardado, el Bundle y su presupuesto de Binder—, su API observable con getStateFlow, y sobre todo el criterio que decide qué merece guardarse: la semilla mínima e identificadora desde la que el estado puede regenerarse, nunca el estado derivado ni los eventos de una sola vez.
Hay una escena que todo desarrollador de Android ha vivido y casi ninguno ha depurado a tiempo. El usuario está a mitad de un formulario, sale a responder un mensaje, tarda diez minutos y vuelve. La aplicación reaparece exactamente donde la dejó… o reaparece vacía, en la pantalla de inicio, como si nunca hubiera existido. La diferencia entre ambos finales no depende del ViewModel, porque el ViewModel ya no está: el sistema mató el proceso entero para dar memoria a otra cosa y luego reconstruyó la pantalla desde cero para mantener la ilusión de continuidad. SavedStateHandle es el único hilo que atraviesa esa aniquilación, y usarlo bien exige entender que no es un almacén más, sino un canal estrechísimo por donde solo caben las semillas del estado.
- Separar con precisión la muerte por cambio de configuración de la muerte del proceso.
- Entender el mecanismo real: registro de estado guardado,
Bundley presupuesto de Binder. - Manejar la API de
SavedStateHandle, incluida su lectura observable congetStateFlow. - Aplicar el criterio de la semilla mínima para decidir qué se guarda y qué no.
Dos muertes que no se parecen
La lección primera te dejó una frontera dibujada: el ViewModel sobrevive a los cambios de configuración porque su ViewModelStore viaja en memoria de una instancia de pantalla a la siguiente. Esa frase encierra la limitación entera. En memoria. Si la memoria desaparece, el ViewModelStore desaparece con ella, y eso es exactamente lo que ocurre cuando el sistema mata el proceso de tu aplicación.
Conviene precisar por qué esta muerte es distinta de todas las demás: es invisible. Cuando la Activity se destruye por una rotación, el usuario ha hecho algo y espera una transición. Cuando el proceso muere en segundo plano, el usuario no ha hecho nada, no ha visto nada y no tiene ningún modelo mental de lo ocurrido: para él simplemente volvió a una app que ya estaba abierta. Cualquier pérdida que ocurra en ese punto se percibe como un fallo del programa, nunca como una consecuencia razonable, y por eso no hay margen para explicarla con un mensaje.
Y lo hace a menudo. Android no pide permiso: cuando la presión de memoria sube, ordena los procesos por importancia y sacrifica los de menor prioridad, que son precisamente las aplicaciones que el usuario no está mirando. Tu app puede estar en segundo plano tres minutos y ya no existir. Además, la muerte del proceso no es un caso raro reservado a dispositivos viejos con poca memoria. Ocurre en cualquier gama en cuanto el usuario abre la cámara, entra en un juego o encadena varias aplicaciones pesadas, y ocurre con más frecuencia cuanto más tiempo permanezca tu app en segundo plano. Diseñar como si no fuera a pasar equivale a apostar a que el usuario nunca se distraerá, que es la apuesta menos razonable posible en un teléfono.
Lo que sí conserva el sistema es el rastro de la tarea: la pila de actividades, sus intents y los Bundle que cada una entregó al guardar su estado de instancia. Cuando el usuario vuelve, Android crea un proceso nuevo, reconstruye la última pantalla y le devuelve ese Bundle. Todo objeto que vivía en memoria —incluido cada ViewModel— ya no está.
Este fallo no aparece en el emulador durante el desarrollo porque tu app siempre es la prioritaria. Hay que provocarlo: activa la opción de desarrollador que no conserva actividades para simular la destrucción, y sobre todo usa la herramienta de línea de comandos que mata el proceso en segundo plano de forma equivalente a como lo haría el sistema. La diferencia importa: la primera opción destruye la actividad pero mantiene vivo el proceso y con él los ViewModel; solo la segunda reproduce el escenario real donde todo lo que estaba en memoria se ha perdido.
flowchart TD E1[Rotacion o cambio de configuracion] --> M1[Muere la Activity] M1 --> S1[Sobrevive el ViewModel en memoria] E2[El sistema reclama memoria] --> M2[Muere el proceso entero] M2 --> P1[Se pierde todo el ViewModel] M2 --> P2[Sobrevive el Bundle del sistema] P2 --> R[SavedStateHandle rehidrata la semilla] S1 --> UI[La pantalla se restaura] R --> UI style S1 fill:#a6e3a1,color:#11111b style P1 fill:#f38ba8,color:#11111b style R fill:#f9e2af,color:#11111b style UI fill:#89b4fa,color:#11111b
Cómo funciona SavedStateHandle
SavedStateHandle es un mapa de claves a valores que la librería de ciclo de vida inyecta en tu ViewModel y conecta con el registro de estado guardado del dueño de la pantalla. Cuando el sistema pide guardar el estado de instancia, ese registro recorre a sus contribuyentes, obtiene el contenido del mapa y lo escribe en el Bundle que viaja al proceso del sistema. Cuando la pantalla se reconstruye tras la muerte del proceso, el mapa llega ya poblado, incluso antes de que se ejecute la primera línea de tu lógica. Los argumentos de navegación con los que se abrió la pantalla también aparecen ahí, lo que evita tener que guardarlos a mano.
@HiltViewModel
class BusquedaViewModel @Inject constructor(
private val handle: SavedStateHandle,
private val repo: Repo,
) : ViewModel() {
// Argumento de navegación: llega solo, sin escribirlo nunca
private val categoriaId: String = checkNotNull(handle["categoriaId"])
// Lectura observable: emite el valor guardado y cada cambio posterior
val consulta: StateFlow<String> = handle.getStateFlow("consulta", "")
fun escribir(texto: String) {
handle["consulta"] = texto // se guardará en el próximo Bundle
}
}
La lectura observable merece un comentario aparte, porque cambia la forma de escribir la clase. getStateFlow no devuelve una fotografía sino un flujo que emite el valor guardado y cada modificación posterior de esa clave, lo que permite tratar el SavedStateHandle como una fuente reactiva más y no como un almacén que hay que consultar en el arranque y luego recordar sincronizar. Con eso, la restauración deja de ser un caso especial: el flujo emite lo mismo tanto si el valor venía del Bundle como si lo acabas de escribir tú.
El fallo más común no es olvidarse de leer, sino olvidarse de escribir. Si el texto del buscador se actualiza en el estado pero nadie lo copia al SavedStateHandle, al restaurar aparecerá vacío y nadie entenderá por qué. La disciplina que lo evita es tratar cada campo semilla como si tuviera dos destinos: el estado, para pintar ahora, y el handle, para poder volver. Escribir ambos en la misma función mantiene la correspondencia visible en una sola línea de código y no repartida por la clase.
Dos detalles técnicos condicionan todo lo demás. El primero es el tipo: solo entra lo que quepa en un Bundle —primitivas, cadenas, arrays, Parcelable y Serializable—, así que un objeto de dominio arbitrario no vale sin prepararlo. Ese requisito de tipo tiene una consecuencia práctica útil: te empuja hacia valores simples. Guardar un identificador, una cadena o un entero no exige preparación alguna, mientras que guardar un objeto de dominio te obliga a hacerlo Parcelable o serializable, un trabajo que actúa como fricción deliberada y que casi siempre indica que estabas a punto de guardar algo que no debías.
El segundo es el tamaño: ese Bundle cruza Binder hacia el proceso del sistema, y la transacción completa comparte un presupuesto del orden de un megabyte para toda la actividad. Rebasarlo no degrada el rendimiento: revienta con una excepción difícil de reproducir, porque depende de cuánto hayan guardado también las demás piezas de la pantalla. El límite práctico saludable son unos pocos kilobytes por pantalla.
Conviene no confundir SavedStateHandle con la base de datos. Lo guardado ahí vive mientras el sistema conserve la tarea del usuario; si este desliza la app fuera de la lista de recientes o reinicia el dispositivo, desaparece. Su propósito no es recordar datos entre sesiones, sino mantener la ilusión de que la sesión nunca se interrumpió. Si un dato debe seguir ahí mañana, su sitio es el disco; si solo debe seguir ahí cuando el usuario vuelva dentro de diez minutos, su sitio es este.
Qué guardar y qué no
Con el canal caracterizado, el criterio se deduce solo: guarda la semilla, no la cosecha. La semilla es el conjunto mínimo de datos que identifica qué estaba haciendo el usuario y desde el cual todo lo demás puede regenerarse: el identificador de la entidad abierta, el texto que había escrito en el buscador, la pestaña seleccionada, los filtros aplicados, el paso del asistente, el ancla de desplazamiento, el borrador de un formulario que le costó trabajo teclear. Son datos pequeños, propios de la intención del usuario, imposibles de recalcular porque solo existían en su cabeza.
Guarda la semilla
Identificadores, texto introducido a mano, selecciones, filtros, posición en un flujo de varios pasos. Poco volumen y alto valor: sin ellos, la pantalla no sabe qué era.
No guardes la cosecha
Listas descargadas, respuestas de red, imágenes, objetos derivados. Ocupan mucho, caducan y siempre pueden volver a obtenerse a partir de la semilla.
No guardes lo efímero
Indicadores de carga, mensajes de una sola vez, órdenes de navegación. Restaurarlos reproduce el bug del aviso que reaparece solo, que ya estudiaste con los side effects.
El criterio se afila con una pregunta única: si perdiera este dato, ¿podría volver a obtenerlo sin molestar al usuario? Los resultados de una búsqueda se pueden volver a pedir a la red; el texto que el usuario tecleó, no, porque solo existía en su cabeza y en la pantalla. Todo lo recuperable por medios propios se queda fuera del Bundle; todo lo que exigiría pedirle al usuario que lo repita, entra. Esa frontera separa además lo barato de lo caro con una precisión sorprendente, porque lo aportado por una persona siempre ocupa poco y lo obtenido por una máquina siempre ocupa mucho.
Guardar la cosecha no solo agota el presupuesto de la transacción: reintroduce datos caducos. Al volver, el usuario vería precios de hace una hora, un carrito que ya no existe o un estado de pedido superado, sin ningún indicio de que está mirando el pasado. Y hay un riesgo añadido de compatibilidad: el Bundle puede haber sido escrito por la versión anterior de tu app y restaurado por la nueva, así que cuanto más rico sea lo que guardas, más probable es que un cambio de esquema haga estallar la restauración justo después de una actualización.
Una sola ruta de arranque
La regla tiene un corolario que ordena el arranque de cualquier pantalla: al construirse, el ViewModel lee la semilla del SavedStateHandle, la usa como estado inicial y dispara desde ella la recarga de todo lo derivado. Si el proceso murió, la semilla llega del Bundle; si no murió, ni siquiera hizo falta. La lógica de arranque es idéntica en ambos casos, y esa uniformidad es lo que hace que el código sea sencillo pese a cubrir dos escenarios muy distintos.
Hay una comprobación mental que resuelve casi todos los casos dudosos en un segundo: imagina que el usuario vuelve tras diez minutos y pregúntate qué le molestaría tener que hacer otra vez. Volver a escribir su búsqueda le molestaría; esperar dos segundos a que la lista se recargue, no. Volver a elegir los filtros le molestaría; ver un indicador de carga, no. Lo que aparece en la primera lista es la semilla; lo que aparece en la segunda es cosecha, y por tanto no cruza.
En un ViewModel con container de Orbit la traducción es directa: el estado inicial deja de ser una constante y pasa a construirse desde el handle. La fábrica de orbit-viewmodel incluso admite recibir el propio SavedStateHandle para custodiar el estado completo cuando este es serializable en un Bundle, pero conviene no tomar ese atajo a la ligera: un estado MVI real contiene listas, resultados y banderas de carga, justo lo que no debe cruzar Binder. La versión disciplinada guarda solo los campos semilla y reconstruye el resto.
override val container = container<BusquedaState, BusquedaEfecto>(
initialState = BusquedaState(
categoriaId = handle["categoriaId"] ?: "",
consulta = handle["consulta"] ?: "",
// resultados, cargando y error arrancan vacíos y se recalculan
),
) {
cargarResultados()
}
Fíjate en que el bloque de creación es el mismo en ambos escenarios. Si el proceso nunca murió, el container ya existía y ese bloque no vuelve a ejecutarse, porque el ViewModel es el mismo objeto de siempre. Si el proceso murió, todo se construye de cero, la semilla llega del Bundle y la carga arranca. No hay una rama para restaurar y otra para empezar: hay una función que toma unos pocos datos identificadores y produce la pantalla entera, invocada exactamente igual venga de donde venga.
Queda un matiz sobre lo derivado que conviene no pasar por alto: recargarlo no siempre es gratis ni instantáneo, así que la pantalla restaurada debe comportarse como cualquier pantalla que carga. Es decir, el estado inicial reconstruido desde la semilla incluye su indicador de carga en marcha y su lista vacía, y la interfaz muestra lo que muestre siempre en esa situación. La continuidad que buscamos no consiste en engañar al usuario haciéndole creer que nada pasó, sino en devolverlo al sitio correcto sin obligarle a repetir sus decisiones.
Esa uniformidad también simplifica la verificación. Probar la restauración deja de requerir un guion elaborado: basta con construir el ViewModel pasándole un SavedStateHandle poblado a mano, como si viniera del sistema, y comprobar que el estado inicial y la carga son los esperados. Si tu pantalla necesitara caminos distintos para arrancar y para restaurar, esa prueba sería el doble de larga y cubriría la mitad de los casos.
Aquí hay una idea que trasciende a Android y llega hasta el diseño de cualquier sistema que deba sobrevivir a su propia interrupción. La tentación natural, ante la posibilidad de morir, es guardarlo todo: si conservo cada byte del estado, al despertar seré idéntico a como era. Esa intuición es errónea por tres motivos que conviene tener presentes a la vez. Primero, es cara: el estado completo de una pantalla moderna no cabe en el canal disponible, y forzarlo produce fallos que solo aparecen en dispositivos ajenos y bajo presión de memoria. Segundo, es frágil: el estado guardado es una fotografía de un momento y del formato de una versión, así que al restaurarla puedes estar resucitando datos ya caducados, precios viejos o un esquema que tu código nuevo ya no entiende. Y tercero, y más profundo, es innecesario, porque la mayor parte de lo que llamamos estado no es información original sino consecuencia. La lista de resultados es una consecuencia de la consulta. El nombre del perfil es una consecuencia del identificador. El indicador de carga es una consecuencia de que haya una petición en vuelo. Nada de eso merece atravesar la frontera, porque todo eso se puede volver a deducir. Lo único irrepetible es lo que el usuario aportó y el sistema no puede inventar: qué eligió, qué escribió, dónde estaba. Guarda eso —la semilla— y deja que el resto vuelva a crecer al arrancar. La consecuencia práctica es que la restauración deja de ser un mecanismo aparte y se convierte en el mismo camino de siempre: no hay una ruta para el arranque normal y otra para el arranque tras morir, hay una sola función que toma unos pocos datos identificadores y construye la pantalla entera a partir de ellos. Un sistema que sabe reconstruirse desde su semilla no necesita recordar; le basta con saber quién era.
- Enumera las diferencias observables entre matar la actividad conservando el proceso y matar el proceso entero, y explica cuál de las dos rompe un
ViewModel. - Toma una pantalla real y reparte todos sus datos en tres cubos: semilla, cosecha y efímero. Justifica cada colocación en una frase.
- Calcula de forma aproximada cuántos kilobytes ocuparía guardar la lista completa de resultados y argumenta por qué no es una decisión aceptable.
- Reescribe el estado inicial de un
containerde Orbit para que lea sus campos semilla delSavedStateHandley recargue el resto en el arranque. - Explica qué bug reaparece si guardas en el estado restaurado un mensaje de una sola vez, y con qué mecanismo del nivel anterior debería viajar en su lugar.