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

Cambios de configuración: qué se recrea y qué sobrevive

Girar el dispositivo no es un accidente que haya que evitar: es el mecanismo por el que Android vuelve a preguntar qué recursos corresponden al entorno actual. Esta lección explica el objeto Configuration y por qué la respuesta del sistema es destruir y reconstruir la Activity entera, recorre la secuencia exacta de callbacks que se ejecutan durante la recreación y precisa qué se pierde en ella. Distingue tres capas de supervivencia con reglas distintas: lo que muere siempre, lo que se retiene por el mecanismo de instancia no configuracional que sostiene al ViewModel, y lo que sobrevive por la simple razón de que el proceso sigue vivo. Cataloga los cambios que nadie ve venir, desde el tema oscuro y el idioma por aplicación hasta el tamaño de fuente y el redimensionado de ventana, y cierra explicando por qué declarar configChanges casi nunca es la solución y qué obligación real asume quien lo hace.

⏱ 17 min

La rotación tiene mala fama porque se enseña como un problema, cuando en realidad es la parte más honesta del diseño de Android. Todo lo que tu app muestra depende de un entorno que puede cambiar sin avisar: el ancho disponible, la densidad de la pantalla, si el sistema está en modo claro u oscuro, qué idioma prefiere el usuario, cuánto ha agrandado el tamaño de letra por accesibilidad. Esos datos viven en un objeto Configuration y los recursos que carga tu app se eligen a partir de él. Cuando uno de esos valores cambia, la respuesta por defecto del sistema es radical y sensata a la vez: tira la pantalla y la vuelve a construir desde cero, para garantizar que absolutamente todo lo que se muestre corresponde al entorno nuevo y no al viejo.

🎯 Al terminar esta lección sabrás
  • Entender qué es Configuration y por qué un cambio en él invalida los recursos ya resueltos.
  • Seguir la secuencia exacta de callbacks de una recreación y saber qué se pierde en cada paso.
  • Separar las tres capas de supervivencia: lo que muere, lo retenido y lo que vive porque el proceso vive.
  • Reconocer los cambios de configuración menos evidentes y decidir con criterio si declarar configChanges.

El sistema prefiere reconstruir a remendar

Un cambio de configuración es cualquier modificación del entorno que pueda alterar qué recursos debe usar tu app. La orientación es el ejemplo obvio, pero la lista real es larga: densidad, ancho y alto disponibles en unidades independientes de densidad, modo claro u oscuro a través de uiMode, lista de idiomas, dirección de escritura, escala de fuente, teclado físico conectado. Cada uno de ellos participa en la selección de recursos mediante calificadores de carpeta, y esa selección se resolvió una sola vez, cuando la pantalla se construyó.

Ahí está el motivo del comportamiento por defecto. Un texto ya medido, una imagen ya elegida por densidad, un color ya resuelto del tema, un cálculo de columnas ya hecho: todo eso quedó grabado en objetos que no tienen forma de saber que el entorno cambió. Actualizar quirúrgicamente cada uno sería un problema sin solución general, porque el sistema desconoce qué guardaste tú y dónde. Destruir y reconstruir, en cambio, es una operación con una garantía absoluta: nada del entorno viejo puede sobrevivir por descuido, porque no sobrevive nada en absoluto.

flowchart TD
A[Cambia la configuracion del entorno] --> B[onPause]
B --> C[Se guarda el estado de instancia]
C --> D[onStop]
D --> E[onDestroy con isChangingConfigurations verdadero]
E --> F[Se retiene el ViewModelStore]
F --> G[Nueva instancia de Activity]
G --> H[onCreate recibe el Bundle guardado]
H --> I[Se recupera el mismo ViewModel retenido]
I --> J[onStart y onResume con recursos nuevos]
style F fill:#a6e3a1,color:#11111b
style E fill:#f9e2af,color:#11111b

Merece la pena fijarse en el orden exacto, porque contiene una garantía y una trampa. La garantía es que el guardado de estado ocurre antes de que la pantalla deje de verse, de modo que en ese instante todavía puedes leer con seguridad lo que haya en la interfaz. La trampa es que a partir de ese punto la actividad se considera con el estado ya guardado, y cualquier operación que intente modificar lo guardado después falla: es el origen del error clásico que se produce al mostrar un diálogo o completar una transacción de interfaz desde el resultado de una operación asíncrona que llegó tarde.

override fun onDestroy() {
    super.onDestroy()
    if (isChangingConfigurations) {
        // adios tecnico: habra otra instancia inmediatamente
    } else {
        // adios real: el usuario se ha ido o el sistema esta terminando la tarea
    }
}

La bandera isChangingConfigurations es la pieza que hace legible todo el proceso. Durante una recreación por configuración, el onDestroy que ves no significa que el usuario se haya ido; significa que la instancia va a ser reemplazada de inmediato. Consultarla te permite distinguir el adiós real del adiós técnico, y es exactamente lo que hace la plataforma por dentro para decidir si conserva o descarta lo retenido.

Tres capas de supervivencia con reglas distintas

Lo que la gente llama sobrevivir a la rotación son en realidad tres mecanismos independientes, con alcances distintos, y confundirlos es el origen de la mayoría de los errores de estado en Android. La primera capa es la que muere siempre: la instancia de Activity, las vistas o el árbol de composición, y todo lo que declaraste con remember en Compose. Nada de eso se conserva y no hay forma de conservarlo, porque su desaparición es precisamente la garantía que la recreación compra.

La segunda capa es el estado retenido, y es la que sostiene al ViewModel. Antes de destruir la instancia, el sistema le pide a la Activity que le entregue un objeto de instancia no configuracional; ese objeto guarda el ViewModelStore completo y se transfiere intacto a la instancia nueva, que lo reclama en su onCreate. Por eso el mismo ViewModel, con los mismos objetos en memoria, aparece al otro lado de la rotación sin que hayas escrito una sola línea. Y por eso su alcance es exactamente ese y ni un milímetro más: es memoria del proceso pasada de mano en mano, no un guardado.

La tercera capa es la más silenciosa y la que engaña con más frecuencia: lo que sobrevive simplemente porque el proceso no ha muerto. Objetos únicos de tu grafo de dependencias, cachés en memoria, propiedades de nivel superior, cualquier cosa estática. Todo eso atraviesa la rotación intacto sin que ningún mecanismo de Android participe, por la razón trivial de que nadie lo tocó. Esa capa es la que produce apps que parecen funcionar durante meses y se desmoronan cuando el sistema recicla el proceso, que es el asunto de la lección siguiente.

@Composable
fun Formulario(vm: FormularioViewModel = viewModel()) {
    var borrador by remember { mutableStateOf("") }          // muere en la rotacion
    var titulo by rememberSaveable { mutableStateOf("") }    // sobrevive a la rotacion
    val estado by vm.estado.collectAsStateWithLifecycle()    // retenido en el ViewModel
    // ...
}
💀

Muere siempre

La instancia de Activity, el árbol de composición y todo lo declarado con remember. Su desaparición es la garantía que compra la recreación.

♻️

Retenido en memoria

El almacén de ViewModel, transferido intacto de una instancia a la siguiente. Sobrevive a la configuración y a nada más.

👻

Vive porque vive el proceso

Objetos únicos, cachés y propiedades estáticas. Nadie los toca, así que parecen a salvo hasta que el sistema recicla el proceso.

💾

Guardado de verdad

El paquete de estado de instancia y el disco. Es lo único que atraviesa las dos muertes, y es el tema de las dos lecciones siguientes.

💡
La prueba que separa las tres capas en un minuto

Coloca tres valores en la misma pantalla: uno en remember, uno en rememberSaveable y uno en el ViewModel. Gira el dispositivo y verás sobrevivir dos de los tres. Después activa la opción de desarrollador que no conserva actividades y gira otra vez: seguirán sobreviviendo los mismos dos, porque el proceso sigue vivo. Y por último mata el proceso desde la terminal con la app en segundo plano: solo sobrevivirá uno. Ese experimento de tres pasos enseña más que cualquier tabla, porque hace visible que rotación y muerte de proceso no son grados de lo mismo, sino cosas distintas.

Los cambios que nadie ve venir

Reducir el asunto a la rotación es el error de encuadre más común, y tiene consecuencias porque los cambios menos evidentes son los que se prueban peor. El tema oscuro cambia uiMode y recrea la pantalla, y ocurre además de forma automática en dispositivos con conmutación programada, es decir, sin que el usuario toque nada. El idioma por aplicación introducido en las versiones modernas permite que el usuario cambie el idioma de tu app desde los ajustes del sistema, y también recrea. La escala de fuente de accesibilidad recrea. El redimensionado de ventana en modo escritorio, en pantalla dividida o al desplegar un plegable recrea, y a veces varias veces seguidas mientras el usuario arrastra el divisor.

Ese último caso merece atención porque rompe una suposición implícita en mucho código: que la recreación es un evento raro y aislado. En un plegable o en una ventana redimensionable puede ocurrir decenas de veces en pocos segundos. Si tu onCreate dispara una carga de red, acabas de convertir un gesto del usuario en una ráfaga de peticiones. La defensa correcta no es evitar la recreación sino colocar el trabajo donde la recreación no lo repita, que es dentro del ViewModel retenido, disparado una sola vez y expuesto como estado observable.

class DetalleViewModel(
    private val repo: Repo,
    handle: SavedStateHandle,
) : ViewModel() {

    private val id: String = checkNotNull(handle["id"])

    // se lanza una vez por ViewModel, no una vez por rotacion
    val estado = repo.observar(id)
        .map(::aEstado)
        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), Estado.Cargando)
}

Conviene además desterrar una costumbre heredada del mundo de las vistas: preguntar por la orientación para decidir el diseño. La orientación es un dato pobre porque no dice cuánto espacio tienes, que es lo único que importa; una tableta en vertical tiene más ancho que un móvil en horizontal. El criterio moderno es el espacio disponible expresado en clases de tamaño de ventana, que se calculan a partir de la configuración actual y se recalculan solas cuando cambia. Programar contra el espacio en lugar de contra la orientación hace que el mismo código funcione en móvil, tableta, plegable y ventana redimensionable sin ramas adicionales.

@Composable
fun Contenido() {
    val configuracion = LocalConfiguration.current           // se recompone al cambiar
    val ancho = configuracion.screenWidthDp

    // mejor aun: razonar en clases de tamano, no en pixeles ni en orientacion
    val clase = calculateWindowSizeClass(actividad).widthSizeClass
    if (clase == WindowWidthSizeClass.Expanded) DosPaneles() else UnPanel()
}

Declarar configChanges casi nunca es la respuesta

Existe una salida aparente: declarar en el manifiesto que tu Activity maneja ciertos cambios por su cuenta con el atributo configChanges. El efecto es que el sistema deja de recrearla y en su lugar invoca onConfigurationChanged. Suena a solución hasta que se entiende qué acabas de firmar: has asumido la obligación de volver a resolver a mano todos los recursos afectados, en todas las pantallas, para siempre. Cada color del tema, cada dimensión, cada cadena traducida, cada imagen por densidad.

En una app basada en Compose la tentación es mayor porque la recomposición parece hacerlo gratis, y en muchos casos lo hace, dado que los composables leen la configuración a través de composición local y se recomponen al cambiar. Pero el conjunto de recursos que se actualizan solos no es completo ni está garantizado en todas las versiones, y en cuanto la app mezcla vistas clásicas, diálogos del sistema o librerías de terceros, aparecen las inconsistencias visuales que nadie sabe reproducir. La regla profesional es sobria: no declares configChanges para evitar trabajo, declaralo solo cuando la recreación tenga un coste inaceptable y concreto, como una sesión de cámara abierta o una reproducción de vídeo en curso, y asume entonces el mantenimiento que conlleva.

<!-- El contrato que firmas: a partir de aqui, esos cambios los resuelves tu -->
<activity
    android:name=".VideoActivity"
    android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout" />

Hay además un detalle que sorprende a quien lo prueba por primera vez: la lista de valores que hay que declarar para que un solo cambio deje de recrear no es una, son varias, y omitir cualquiera de ellas deja la recreación activa en algún dispositivo. Declarar solo la orientación sin declarar el tamaño de pantalla funciona en un móvil y falla en una tableta. Esa fragilidad no es accidental: la plataforma no facilita el camino porque no quiere que sea el camino habitual.

Recrear es una decision de diseno sobre quien carga con la complejidad

Vale la pena preguntarse por qué Android eligió el camino aparentemente más caro. Reconstruir una pantalla entera para que el usuario gire el móvil parece un derroche cuando la alternativa, avisar del cambio y dejar que cada uno actualice lo suyo, existe y es más barata en ciclos de procesador. La respuesta es que el coste no se mide en ciclos sino en clases de error posibles, y ahí la comparación se invierte por completo. Un sistema que notifica el cambio y confía en que cada componente se actualice tiene un número ilimitado de estados incorrectos alcanzables: basta con que un solo componente olvide releer un solo recurso para que quede un texto en el idioma anterior, un color del tema claro sobre fondo oscuro, un ancho calculado para la orientación que ya no es. Y lo peor no es que ocurra, sino que ocurre de forma parcial, silenciosa y dependiente del orden de las notificaciones, que es la categoría de fallo más cara de diagnosticar que existe. Un sistema que destruye y reconstruye tiene exactamente cero estados incorrectos alcanzables por esa vía, porque no queda nada del entorno anterior que pueda estar desactualizado. Se ha pagado un coste de rendimiento a cambio de eliminar una familia entera de errores, y esa es casi siempre la operación correcta cuando el que paga el error es alguien que no puede depurarlo. Pero hay una segunda lectura, más profunda, y es lo que la decisión revela sobre la arquitectura que Android quiere de ti. Al hacer la recreación frecuente e inevitable, el sistema fuerza a que tus pantallas sean funciones de un estado que vive fuera de ellas, porque cualquier otra cosa se rompe el primer día. Es una restricción convertida en pedagogía: lo que parece un castigo por girar el dispositivo es en realidad un examen continuo que comprueba si separaste el estado de su presentación. Las apps que aprueban ese examen no solo sobreviven a la rotación, sobreviven a la muerte del proceso, a la navegación, al multiventana y a los plegables, porque todas esas situaciones piden exactamente la misma propiedad. Quien intenta desactivar el examen con configChanges no ha resuelto el problema; ha apagado la única alarma que le avisaba de que su estado estaba en el sitio equivocado.

⚔️ Provoca todas las recreaciones que puedas
  1. Instrumenta una pantalla y provoca una recreación por cada vía distinta: rotación, tema oscuro, idioma de la app, escala de fuente y redimensionado de ventana. Anota cuáles no conocías.
  2. Registra el valor de isChangingConfigurations dentro de onDestroy y distingue el adiós técnico del adiós real al pulsar atrás.
  3. Coloca los tres valores del experimento de las tres capas y verifica cuál sobrevive a cada situación. Escribe la tabla con tus propios resultados.
  4. Pon una llamada de red dentro de onCreate y arrastra el divisor de la pantalla dividida durante unos segundos. Cuenta las peticiones y después mueve el disparo al ViewModel.
  5. Declara configChanges para la orientación en una pantalla con textos traducidos y cambia el idioma del sistema. Encuentra al menos un elemento que se quede en el idioma anterior.