Restaurar el estado: SavedStateHandle, rememberSaveable y las dos muertes
Sobrevivir a una rotación y sobrevivir a la muerte del proceso son propiedades distintas que se consiguen con mecanismos distintos, y confundirlas produce la clase de fallo que solo aparece en el dispositivo del usuario. Esta lección separa las dos muertes y asigna a cada una su herramienta: la retención en memoria que sostiene al ViewModel frente al estado guardado que viaja serializado hasta el sistema. Explica el registro de estado guardado que hay bajo todo el mecanismo, por qué el buzón es estrecho y qué ocurre cuando se desborda el límite de la transacción, y por qué la regla profesional es guardar identificadores y no datos. Detalla SavedStateHandle en el ViewModel con su acceso como flujo observable y su relación con los argumentos de navegación, y rememberSaveable en Compose con sus guardadores personalizados, la clave que se deduce de la posición en la composición y las entradas que fuerzan un reinicio deliberado.
Las dos lecciones anteriores dejaron dos formas de morir sobre la mesa y conviene no mezclarlas nunca más. En la primera, el cambio de configuración, la Activity se destruye pero el proceso sigue vivo, y por eso basta con que alguien guarde una referencia en memoria para que el estado aparezca intacto al otro lado. En la segunda, la muerte del proceso, no queda memoria donde guardar nada, así que lo único que puede sobrevivir es lo que ya estaba escrito fuera. Son dos problemas con dos soluciones, y casi todo el sufrimiento en este terreno nace de aplicar la solución del primero al segundo, comprobarlo girando el dispositivo, ver que funciona y dar el asunto por cerrado.
- Separar con precisión la retención en memoria del estado guardado, y saber a qué muerte responde cada uno.
- Entender el registro de estado guardado, el límite real del buzón y por qué se guardan referencias y no contenido.
- Usar
SavedStateHandleen elViewModel, incluido su acceso observable y su relación con los argumentos de navegación. - Usar
rememberSaveableen Compose con guardadores propios, claves explícitas y entradas de reinicio.
Dos muertes, dos mecanismos
El estado retenido es memoria que cambia de dueño. Cuando la Activity se destruye por configuración, el sistema le pide su objeto de instancia no configuracional, que contiene el almacén de ViewModel, y se lo entrega a la instancia siguiente. Nada se serializa, nada se escribe, los mismos objetos siguen en el mismo montón de memoria. Es rápido, admite cualquier tipo y no tiene límite de tamaño más allá de la memoria disponible. Y desaparece por completo en cuanto el proceso muere, porque su único soporte era ese proceso.
El estado guardado es información que sale de tu proceso. Antes de detener la actividad, la plataforma recorre un registro de estado guardado pidiendo a cada participante su contribución, empaqueta todo en un Bundle y se lo entrega al gestor de actividades, que lo custodia junto a la tarea. Al recrear la actividad en un proceso nuevo, ese paquete vuelve. Sobrevive a las dos muertes, pero a cambio impone tres restricciones severas: solo admite tipos que se puedan empaquetar, cruza una frontera entre procesos con un límite de tamaño estricto, y tarda lo que tarde en serializarse mientras el usuario espera.
flowchart TD
A[Estado de tu pantalla] --> B{Sobrevive a que}
B -->|solo a la rotacion| C[Retenido en memoria dentro del ViewModel]
B -->|tambien a la muerte del proceso| D[Estado guardado que se serializa]
B -->|debe durar entre sesiones| E[Disco con Room o DataStore]
D --> F[SavedStateHandle en el ViewModel]
D --> G[rememberSaveable en la interfaz]
style C fill:#f9e2af,color:#11111b
style D fill:#a6e3a1,color:#11111b
style E fill:#89b4fa,color:#11111bEl buzón es estrecho y hay que respetarlo
El Bundle guardado no se queda en tu memoria: viaja por el mecanismo de comunicación entre procesos hasta el servicio del sistema que custodia las tareas. Ese canal tiene un búfer de transacción de alrededor de un megabyte compartido por todas las transacciones en vuelo del proceso, y superarlo produce un fallo con nombre propio, TransactionTooLargeException, que además suele aparecer lejos del sitio culpable y en dispositivos concretos. La cifra que hay que tener en la cabeza no es el megabyte teórico sino la práctica sana: unos pocos kilobytes.
El mecanismo que recoge esas contribuciones es el registro de estado guardado, y conviene conocerlo porque explica por qué todas las herramientas de esta lección se comportan igual: SavedStateHandle, rememberSaveable y el estado de las vistas clásicas son tres clientes del mismo registro. Cualquier componente propio puede sumarse registrando un proveedor con una clave, y recuperando su aportación cuando la pantalla vuelve a construirse. Es la vía correcta para que una librería o un componente reutilizable sobreviva sin obligar a la pantalla que lo aloja a saber nada de su estado interno.
class ControlDeZoom(propietario: SavedStateRegistryOwner) {
private var factor = 1f
init {
val registro = propietario.savedStateRegistry
registro.registerSavedStateProvider(CLAVE) {
bundleOf("factor" to factor) // que aporto al paquete
}
factor = registro.consumeRestoredStateForKey(CLAVE)?.getFloat("factor") ?: 1f
}
private companion object { const val CLAVE = "zoom" }
}
De ahí sale la regla más rentable de toda la lección, y es una regla de diseño antes que de API. No guardes datos, guarda cómo volver a encontrarlos. El identificador del artículo que se estaba viendo, el texto del campo de búsqueda, la posición del desplazamiento, la pestaña seleccionada, el filtro activo. Todo eso cabe en unos cientos de bytes y basta para reconstruir la pantalla, porque los datos de verdad deben estar en la fuente de verdad duradera, que es el disco. Guardar la lista de resultados en el Bundle es duplicar en el peor sitio posible una información que ya sabías recuperar.
Aunque no llegues al límite, el guardado ocurre en el hilo principal justo antes de detener la actividad, es decir, mientras el usuario está haciendo el gesto de salir o de girar. Cada objeto que empaquetas se serializa ahí. Una estructura de unos pocos cientos de kilobytes convierte una transición fluida en un tirón perceptible, y lo hace precisamente en los dispositivos más lentos, que son los que más recreaciones sufren. Si al revisar código encuentras listas completas dentro del estado guardado, has encontrado a la vez una excepción latente y un problema de fluidez.
SavedStateHandle: el ViewModel que también sobrevive al proceso
El ViewModel por sí solo solo resuelve la primera muerte. SavedStateHandle es la pieza que lo conecta al registro de estado guardado y le permite resolver también la segunda. Se recibe como parámetro del constructor y la fábrica lo inyecta sin que hagas nada especial; por dentro es un mapa con clave de texto cuyos valores se empaquetan cuando toca guardar y se restauran cuando el ViewModel se crea de nuevo en un proceso distinto.
Lo importante es que además es observable, lo que lo convierte en fuente de estado y no en un mero almacén que hay que consultar a mano. getStateFlow devuelve un flujo que emite el valor actual y cada cambio, de modo que el mismo dato sirve simultáneamente para pintar la interfaz y para sobrevivir al reciclado del proceso, sin duplicaciones ni sincronizaciones manuales entre dos copias del mismo valor.
class BusquedaViewModel(
private val repo: Repo,
private val handle: SavedStateHandle,
) : ViewModel() {
// sobrevive a la rotacion y a la muerte del proceso
val consulta: StateFlow<String> = handle.getStateFlow(CLAVE, "")
fun escribir(texto: String) { handle[CLAVE] = texto }
val resultados: StateFlow<List<Item>> = consulta
.debounce(300)
.flatMapLatest { texto -> repo.buscar(texto) } // se reconstruye, no se guarda
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
private companion object { const val CLAVE = "consulta" }
}
Repara en la división del ejemplo, porque es el patrón entero condensado: la consulta se guarda, los resultados no. La consulta es una decisión del usuario, es diminuta y es irrecuperable si se pierde; los resultados son grandes y derivables, así que se vuelven a pedir. Ese criterio, guardar lo que el usuario decidió y recalcular lo que se deduce de ello, resuelve la inmensa mayoría de los casos sin necesidad de pensar dos veces.
Existe también una forma de usarlo desde Compose sin duplicar la clave a mano, mediante la extensión que delega una propiedad guardable sobre el propio handle. Es cómoda, pero no elimina la decisión de fondo: sigue siendo tuya la responsabilidad de que lo delegado sea pequeño y empaquetable. La comodidad de la API nunca sustituye al criterio sobre qué merece guardarse.
Hay una lectura equivocada de SavedStateHandle que conviene desactivar: creer que como sobrevive a todo, es el sitio donde poner los datos de la aplicación. No lo es. Su papel es conservar el contexto de una pantalla concreta, es decir, lo que distingue esta visita de cualquier otra. Los datos del dominio pertenecen al disco, y su llegada a la pantalla debe ser un flujo observable que se reconstruye solo. Cuando un handle empieza a llenarse de entidades del negocio, el problema real casi nunca es de guardado: es que falta una capa de persistencia y alguien está intentando suplirla con el buzón del sistema.
Los argumentos de navegación entran por la misma puerta: cuando se navega a un destino con parámetros, esos parámetros aparecen en el SavedStateHandle del ViewModel de ese destino, y sobreviven a la muerte del proceso porque la pila de retroceso también se guarda. Por eso leer el identificador de la pantalla desde el handle en el constructor es correcto y seguro, mientras que pasarlo por un método de inicialización que alguien tiene que acordarse de llamar es un fallo esperando su turno.
rememberSaveable y la clave que se deduce sola
En Compose el equivalente directo es rememberSaveable, que se comporta como remember con una diferencia decisiva: además de recordar durante la composición, escribe su valor en el registro de estado guardado de la pantalla. Admite sin ceremonia los tipos que el Bundle sabe empaquetar y los que implementan Parcelable; para el resto hay que explicarle cómo convertir el objeto a algo empaquetable mediante un guardador, y listSaver y mapSaver cubren casi todos los casos con muy poco código.
data class Filtro(val texto: String, val soloFavoritos: Boolean)
val FiltroSaver = listSaver<Filtro, Any>(
save = { filtro -> listOf(filtro.texto, filtro.soloFavoritos) },
restore = { lista -> Filtro(lista[0] as String, lista[1] as Boolean) },
)
@Composable
fun Panel() {
var filtro by rememberSaveable(stateSaver = FiltroSaver) {
mutableStateOf(Filtro("", false))
}
var expandido by rememberSaveable { mutableStateOf(false) }
// ...
}
Dos detalles gobiernan su comportamiento y ambos sorprenden la primera vez. El primero es que la clave de almacenamiento se deduce de la posición de la llamada dentro del árbol de composición, no del nombre de la variable. Eso funciona sin esfuerzo en el caso normal, pero falla cuando el mismo composable se repite en un bucle o cambia de sitio: ahí hay que pasar una clave explícita para que cada instancia recupere lo suyo.
@Composable
fun ListaDeChats(chats: List<Chat>) {
chats.forEach { chat ->
// sin clave, dos chats distintos pueden reclamar el mismo hueco guardado
var borrador by rememberSaveable(key = chat.id) { mutableStateOf("") }
FilaDeChat(chat, borrador, alEscribir = { borrador = it })
}
}
El segundo es que los argumentos posicionales actúan como entradas de reinicio: si cambian, el valor guardado se descarta y el bloque se vuelve a evaluar. Es la forma idiomática de decir que el estado de la pantalla pertenece a un elemento concreto y debe empezar de cero al cambiar de elemento.
Queda por resolver dónde poner cada cosa cuando conviven las dos herramientas, y la frontera es más nítida de lo que parece. rememberSaveable sirve para estado que es exclusivamente de presentación y que nadie fuera de la interfaz necesita conocer: si un panel está desplegado, qué pestaña se ve, la posición del desplazamiento. SavedStateHandle sirve para el estado que participa en la lógica y del que dependen otras cosas: el identificador de lo que se está editando, el filtro que determina la consulta, el texto de búsqueda que dispara peticiones. La pregunta que decide es simple: si este valor cambia, hay que recalcular algo fuera de la pantalla. Si la respuesta es sí, pertenece al ViewModel.
Sobrevive a la rotación
Estado retenido en el ViewModel. Rápido, sin límite de tamaño y sin serializar, pero desaparece con el proceso.
Sobrevive al proceso
SavedStateHandle y rememberSaveable. Diminuto, empaquetable, custodiado por el sistema junto a la tarea.
Sobrevive a todo
Disco. La fuente de verdad real de los datos del dominio, con Room o DataStore según su forma.
Sobrevive con la pila
Argumentos de navegación. Llegan al handle del destino y se restauran con la pila de retroceso.
Detrás de la elección entre memoria retenida, estado guardado y disco hay una pregunta que no es técnica sino epistemológica, y formularla bien resuelve el problema antes de escribir código: de dónde vino este dato y quién podría volver a producirlo. Un dato que vino del usuario, un texto tecleado, una casilla marcada, una posición de desplazamiento, no lo puede reproducir nadie más; si se pierde, se pierde información que solo existía en la cabeza de una persona, y esa persona percibirá la pérdida como una falta de respeto por su tiempo. Un dato que vino de la red o de la base de datos, en cambio, tiene un productor que sigue existiendo y sabe fabricarlo otra vez; guardarlo es crear una segunda copia de algo que ya tiene dueño, con todos los problemas de coherencia que eso arrastra y sin ninguna ganancia real, porque recalcularlo cuesta menos que mantenerlo sincronizado. Con ese único criterio, la frontera entre lo que se guarda y lo que se recalcula deja de ser una cuestión de gusto y pasa a ser una consecuencia de dónde nace cada valor. Vale la pena mirar el mecanismo entero con esa lente, porque entonces se vuelve transparente. Los límites estrictos del buzón no son una limitación arbitraria de la plataforma: son la forma en que el sistema hace física una regla de diseño que de otro modo nadie respetaría. Si el estado guardado fuera ilimitado, todo el mundo metería ahí sus listas completas y la restauración se convertiría en una carrera de serialización que castigaría a los dispositivos más humildes, exactamente los que más restauraciones sufren. Al hacerlo estrecho, la plataforma fuerza la conversación correcta: qué es imprescindible, qué es derivable, qué tiene otro dueño. Y hay una última consecuencia que conviene enunciar sin adornos. Una restauración perfecta no es aquella que reconstruye byte a byte lo que había, sino aquella que el usuario no percibe. Nadie nota que la lista se volvió a pedir a la red si aparece en el mismo sitio con la misma posición de desplazamiento y el mismo elemento seleccionado. La medida de calidad aquí no es la fidelidad del estado sino la continuidad de la experiencia, y confundir ambas lleva a guardar demasiado, a restaurar despacio y a fallar en el único dispositivo en el que importaba.
- Coloca cuatro valores en una pantalla: uno en
remember, uno enrememberSaveable, uno en unViewModelnormal y uno enSavedStateHandle. Somete la pantalla a rotación y a muerte del proceso, y construye la tabla de resultados. - Guarda deliberadamente una lista grande en el estado guardado hasta provocar el fallo de transacción demasiado grande. Después sustitúyela por un identificador y mide la diferencia en tiempo de transición.
- Convierte una clase propia en guardable con
listSavery comprueba que sobrevive. Repite conmapSavery decide cuál lees mejor dentro de seis meses. - Pon un
rememberSaveabledentro de un elemento repetido de una lista y demuestra el cruce de valores. Arréglalo con una clave explícita. - Recorre una pantalla real tuya y clasifica cada valor según de dónde vino: del usuario, de la red o del disco. Mueve cada uno al mecanismo que le corresponde y justifica los que no cambian de sitio.