Rotación y muerte del proceso
Al terminar: Ver qué se pierde al rotar y al matar el proceso, y guardar estado con onSaveInstanceState.
Al terminar esta lección, la app que ejecutaste en el emulador tendrá dos contadores de goles en pantalla que reciben el mismo trato hasta que giras el móvil: uno vuelve a 0 y el otro conserva su valor. Habrás provocado el fallo con tus manos, lo habrás leído en Logcat y sabrás guardar estado con onSaveInstanceState. Además matarás el proceso de la app con adb y comprobarás qué sobrevive.
Importa porque un contador que se reinicia al girar el móvil es el primer fallo que ve cualquier usuario de una app mal hecha. Temas: rotación, cambios de configuración, muerte del proceso y onSaveInstanceState; ciclo de vida del Fragment (recuadro).
Qué destruye a tu estado, y qué no
Estado es cualquier dato que la pantalla necesita recordar: un contador, un texto escrito, un scroll. Si vive solo en una propiedad de la Activity, su vida es la de esa instancia. Hay tres situaciones distintas:
| Situación | ¿Se destruye la Activity? | ¿Muere el proceso? | Qué pasa con una propiedad |
|---|---|---|---|
| Pulsas Home y vuelves | No (onStop y onRestart) | No | Se conserva |
| Cambio de configuración (rotar, entrar en multiventana…) | Sí, y se crea otra | No | Se pierde |
| Muerte del proceso (el sistema necesita memoria) | Sí | Sí | Se pierde |
Un cambio de configuración es cualquier cambio del entorno del dispositivo que afecta a la pantalla, como la orientación. Por defecto, Android lo resuelve destruyendo y recreando la Activity.
onSaveInstanceState(outState: Bundle) es el callback donde el sistema te ofrece ese sobre. Un Bundle es un mapa pequeño de clave a valor que el sistema guarda fuera de tu proceso. Al recrear la Activity te lo devuelve como parámetro de onCreate (savedInstanceState); si es la primera vez, es null.
// Guardar: el sistema llama aquí antes de destruir la Activity.
override fun onSaveInstanceState(outState: Bundle) { // outState: el sobre vacío que te ofrece el sistema
super.onSaveInstanceState(outState) // primero, lo que guarda la clase madre
outState.putInt("goles", goles) // mete un Int en el sobre con la clave "goles"
}
// Recuperar: en onCreate; el Bundle es null si es la primera vez.
override fun onCreate(savedInstanceState: Bundle?) { // Bundle?: puede llegar null
super.onCreate(savedInstanceState)
goles = savedInstanceState?.getInt("goles") ?: 0 // ?. lee solo si hay sobre; ?: da 0 si no lo hay
}Error típico: un contador en una propiedad que «debería» sobrevivir
Los dos bloques de abajo declaran el contador con by mutableIntStateOf(0). No hace falta entenderlo hoy: es una variable especial de Compose, la librería con la que se dibuja la pantalla, que avisa a la pantalla para que se repinte cuando cambia su valor (lo verás con calma en Estado en Compose). Para esta lección basta con leerlo como «un Int que empieza en 0».
private var goles by mutableIntStateOf(0) // vuelve a 0 al rotar: es una propiedad de una instancia que se destruyeprivate var goles by mutableIntStateOf(0)
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
outState.putInt("goles", goles) // el sistema guarda el sobre
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
goles = savedInstanceState?.getInt("goles") ?: 0 // y lo devuelve aquí
}Falla porque la instancia nueva se crea con los valores iniciales de sus propiedades; nadie copia los de la anterior. En la lección de hoy lo verás con dos contadores lado a lado, uno con cada versión.
Y onConfigurationChanged no es la solución: solo se llama si el Manifest declara android:configChanges para ese cambio; sin esa declaración, no llega nunca (en el Logcat de la lección anterior no aparecía).
La muerte del proceso
Si tu app está en segundo plano y el sistema necesita memoria, puede terminar su proceso entero. No cuentes con recibir un aviso de esa muerte: no la anuncia un callback fiable. Lo que sí se conserva es el Bundle, porque lo guardó el sistema en su propio proceso. Al volver a abrir la app, el sistema crea un proceso nuevo y una Activity nueva a la que entrega el Bundle guardado.
Se puede provocar a mano desde la Terminal de Android Studio, sin esperar a que falte memoria. Con la app en segundo plano (pulsa Home antes):
adb shell am kill com.sebastiannarvaez.marcadoram kill solo funciona con la app en segundo plano: con la app delante, no la mata. Tampoco tiene efecto un instante después de pulsar Home, cuando el sistema aún no ha dado la app por oculta. Por eso se comprueba: adb shell pidof com.sebastiannarvaez.marcador imprime el número de proceso de la app y no imprime nada si ya no existe. Si todavía imprime un número, espera unos segundos y repite am kill. Después vuelve a abrirla desde la lista de apps recientes (el botón cuadrado de la barra de navegación del emulador, o deslizar hacia arriba desde abajo y mantener; lo viste en El ciclo de vida de la Activity) o con adb shell am start -n com.sebastiannarvaez.marcador/.MainActivity. Todas estas órdenes, am y pidof incluidos, están en la Chuleta de adb.
Las piezas, en una app de tarjetas
Piezas nuevas de esta lección:
private companion object { const val CLAVE_GOLES = … }: elcompanion objecty elconst valde object, companion y vararg;privatelo deja visible solo dentro de la clase. Ahí va la clave delBundle.mutableIntStateOf(0): unIntobservable por la pantalla; al cambiar, la pantalla se repinta. Conbyse usa como una variable normal. Necesita los importsgetValueysetValue. Se estudia en Estado en Compose.Button(onClick = { … }) { Text("…") }: un botón con dos bloques entre llaves. El primero,onClick, es lo que se ejecuta al pulsarlo; el segundo es lo que se dibuja dentro del botón (aquí, un texto).outState.putInt(clave, valor)ysavedInstanceState?.getInt(clave) ?: 0: guardar y leer un entero delBundle; el operador?:da0si esnull.
Así se escriben juntas esas piezas, con un contador de tarjetas en lugar de goles (no es el código de Marcador):
class TarjetasActivity : ComponentActivity() {
private var tarjetas by mutableIntStateOf(0) // un Int que la pantalla observa; by: se usa como variable normal
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
tarjetas = savedInstanceState?.getInt(CLAVE_TARJETAS) ?: 0 // recupera el valor, o 0 la primera vez
setContent {
Button(onClick = { tarjetas++ }) { // al pulsar, suma 1 y la pantalla se repinta
Text("Tarjetas: $tarjetas") // lo que se lee dentro del botón
}
}
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
outState.putInt(CLAVE_TARJETAS, tarjetas) // guarda el valor con la misma clave con la que se lee
}
private companion object { // un objeto ligado a la clase, visible solo dentro de ella
const val CLAVE_TARJETAS = "tarjetas" // const val: una constante; evita escribir mal la clave dos veces
}
}Lo que se ve en pantalla: un único botón. Si lo pulsas tres veces y giras el móvil, el botón sigue diciendo:
Tarjetas: 3Si borraras onSaveInstanceState, el botón diría Tarjetas: 0 tras girar. La Activity nueva se crea con el valor inicial de sus propiedades y, sin el guardado, no hay nada que onCreate pueda recuperar: savedInstanceState es null o no trae la clave, getInt no encuentra nada y ?: 0 pone cero.
Fíjate en que el Bundle guarda pocos datos sencillos. El límite práctico es de alrededor de 1 MB para todo el proceso de la app, sumando todos los Bundle que el sistema está guardando a la vez, no 1 MB por cada uno (lo dice también el comentario del código de abajo). Por eso solo conviene meter cosas pequeñas. Room (una base de datos) y DataStore (guardar ajustes sencillos), que guardan datos de verdad en el móvil, llegan en módulos posteriores.
Ejercicio · cuenta cuántas veces se crea la pantalla
Hasta aquí, todo lo has leído. Ahora lo ejecutas tú, en tu proyecto, en una carpeta propia para los ejercicios del módulo. Los ejercicios están en el proyecto, pero la app no los usa: viven aparte, no se borran y no cambian nada de lo que ya tienes.
Crea la carpeta ejercicios. En la vista Android:
- En
app → kotlin+java, clic derecho sobrecom.sebastiannarvaez.marcador(el que no lleva(test)ni(androidTest)) → New → Package. - Un paquete es el nombre de la carpeta donde vive un fichero de Kotlin, y cada punto del nombre es una carpeta dentro de la anterior. El campo ya trae
com.sebastiannarvaez.marcador; si viene vacío, escríbelo entero. Escribe.ejercicios.contador_de_creacionesal final (con el punto delante) y pulsa Intro. Android Studio crea las dos carpetas de una vez. Mientrasejerciciostenga una sola subcarpeta, la vista Android las enseña juntas en una fila,ejercicios.contador_de_creaciones; cuando crees una segunda (en otra lección), las separa enejercicioscon una hija por ejercicio. - Clic derecho sobre esa carpeta → New → Kotlin Class/File → File, escribe
ContadorDeCreacionesy pulsa Intro.
En los ejercicios de las lecciones siguientes repetirás los pasos 1 y 2 desde com.sebastiannarvaez.marcador, cambiando el nombre de la subcarpeta, y el paso 3 con el nombre del fichero que diga cada lección.
Cómo se ejecuta un ejercicio de este módulo. La pantalla no enseña nada de estos ejercicios: escriben en Logcat con la etiqueta (tag) Ejercicio, la misma idea del tag:Ciclo de El ciclo de vida de la Activity. Para que Android los ejecute, añades una línea temporal en MainActivity.kt que llama a tu función. La llamas con la ruta completa (el paquete, el nombre del object y el de la función, separados por puntos; por ejemplo, com.sebastiannarvaez.marcador.ejercicios.contador_de_creaciones.ContadorDeCreaciones.alCrear(savedInstanceState)), igual que android.os.Process.myPid() en otras lecciones: así no hace falta ningún import en MainActivity.kt. Ejecutas con Run y miras Logcat con el filtro tag:Ejercicio. Al terminar, y antes del paso, borras la línea.
Reto · Ejercicio: cuántas veces se creó la pantalla
Escribe, en ContadorDeCreaciones.kt, un singleton (object) llamado ContadorDeCreaciones que cuente cuántas veces se ha creado la pantalla y que guarde esa cuenta en el sobre del sistema. Tiene dos funciones.
- La primera,
alCrear, recibe el sobre (unBundle?, que puede sernull): lee la cuenta del sobre (cero si no hay sobre), le suma uno y escribe «creación número N» en Logcat con la etiqueta Ejercicio. - La segunda,
alGuardar, recibe el sobre (unBundle) y guarda en él la cuenta.
Necesitarás los import de android.os.Bundle y android.util.Log arriba del fichero (Option+Enter sobre lo que salga en rojo).
Añade las dos llamadas temporales en MainActivity.kt, con la ruta completa: una en onCreate, justo debajo de la línea que anota onCreate, y otra en onSaveInstanceState, justo debajo de la llamada a super. Cada línea acaba con el comentario // EJERCICIO: quitar al terminar.
Ejecuta la app y gira el móvil dos veces (ida y vuelta). En Logcat, con tag:Ejercicio, debes ver:
D Ejercicio: creación número 1
D Ejercicio: creación número 2
D Ejercicio: creación número 3Cada giro destruye la pantalla y crea otra, pero la cuenta sigue subiendo porque viaja en el sobre. En el paso de abajo matarás el proceso con am kill y comprobarás qué sobrevive.
Pistas, todo ya visto: object (object, companion y vararg); putInt, getInt, ?. y ?: (arriba); private const val para la clave; Log.d (El ciclo de vida de la Activity).
Ver solución
package com.sebastiannarvaez.marcador.ejercicios.contador_de_creaciones
import android.os.Bundle
import android.util.Log
// Cuenta cuántas veces se ha creado la pantalla y guarda la cuenta en el sobre (el Bundle).
object ContadorDeCreaciones {
private const val CLAVE = "creaciones" // la clave con la que se guarda y se lee
private var cuenta = 0 // vive en el proceso: si el proceso muere, vuelve a 0
// Se llama desde onCreate: lee la cuenta del sobre (0 si no hay sobre) y suma una.
fun alCrear(sobre: Bundle?) {
cuenta = (sobre?.getInt(CLAVE) ?: 0) + 1
Log.d("Ejercicio", "creación número $cuenta")
}
// Se llama desde onSaveInstanceState: mete la cuenta en el sobre.
fun alGuardar(sobre: Bundle) {
sobre.putInt(CLAVE, cuenta)
}
}Las dos líneas temporales de MainActivity.kt (sin ruta de fichero a propósito: se quitan al terminar):
// en onCreate, justo debajo de anotar("onCreate …"):
com.sebastiannarvaez.marcador.ejercicios.contador_de_creaciones.ContadorDeCreaciones.alCrear(savedInstanceState) // EJERCICIO: quitar al terminar
// en onSaveInstanceState, justo debajo de super.onSaveInstanceState(outState):
com.sebastiannarvaez.marcador.ejercicios.contador_de_creaciones.ContadorDeCreaciones.alGuardar(outState) // EJERCICIO: quitar al terminarAntes del paso, borra las dos líneas temporales (las que acaban en // EJERCICIO: quitar al terminar). Comprueba que no queda rastro: en MainActivity.kt, Cmd+F (Ctrl+F en Windows y Linux) con la palabra ejercicios no debe dar ningún resultado. Si Android Studio añadió un import al escribirlas, bórralo también (es lo que esa búsqueda detecta). El fichero del ejercicio se queda en su carpeta.
Paso · dos contadores, dos destinos
Objetivo. Añadir a MainActivity dos botones de gol: uno con su contador en una propiedad normal, que se pierde al rotar a propósito, y otro guardado en el Bundle. Al terminar, tras pulsar el primero una vez y el segundo dos veces, rotar deja 0 y 2.
Las piezas nuevas de este paso están explicadas arriba, en Las piezas, en una app de tarjetas. Abre MainActivity.kt con doble clic, en app → kotlin+java → com.sebastiannarvaez.marcador. Esta vez no se sustituye el fichero entero: se añaden trozos al que ya tienes. Dónde va cada trozo (esto es obligatorio; el código está debajo):
- Los cuatro
importvan arriba, con los demás (el orden de losimportno importa). - Las dos propiedades van dentro de la clase, antes de
onCreate. - En
onCreate, la lectura delBundleva justo después deanotar("onCreate …"), y los dosButtondentro de laColumn, trasText("Marcador")y antes detabla.forEach, que se queda donde está (la tabla sigue en pantalla). - En
onSaveInstanceState, la línea conputIntva entresuper.onSaveInstanceState(outState)yanotar(...), con su comentario. - La constante
CLAVE_GOLESva en unprivate companion object, dentro de la clase y justo antes deanotar, que es lo último. El resto del fichero no cambia.
Intenta escribirlo tú: dos contadores de goles que empiezan en 0, golesQueSePierden y golesQueSobreviven, y debajo del título, un botón para cada uno que le suma un gol y muestra su valor. Solo el segundo se guarda al destruirse la pantalla y se recupera al crearla otra vez. Lo que debes ver, tras tocar una vez el primero y dos veces el segundo, y girar el móvil:
Gol (se pierde al rotar): 0
Gol (sobrevive): 2Cuando termines, abre los bloques y compara.
Intenta primero. Cuando termines, compara con la solución.Mostrar el código
import androidx.compose.material3.Button
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.setValueLas dos propiedades, con el comentario que explica cómo reproducirlo:
// Dos contadores, dos destinos distintos al girar la pantalla.
//
// Al rotar, Android DESTRUYE esta Activity y crea otra nueva: las propiedades
// de la clase empiezan otra vez desde su valor inicial. Este contador se pierde
// A PROPOSITO, para que veas el problema.
//
// Para reproducirlo: toca el primer boton, gira el movil
// con los botones de giro del emulador y el numero vuelve a 0. Para la MUERTE DEL PROCESO: pulsa Home y ejecuta
// adb shell am kill com.sebastiannarvaez.marcador
// y vuelve a la app desde recientes: el segundo contador sigue, el primero no.
private var golesQueSePierden by mutableIntStateOf(0)
// Este se guarda en el Bundle de onSaveInstanceState y se restaura en onCreate.
private var golesQueSobreviven by mutableIntStateOf(0)La lectura en onCreate y la pantalla con los dos botones:
// Si `savedInstanceState` no es null, Android nos devuelve lo que guardamos.
golesQueSobreviven = savedInstanceState?.getInt(CLAVE_GOLES) ?: 0 Column(Modifier.padding(24.dp)) {
Text("Marcador")
Button(onClick = { golesQueSePierden++ }) {
Text("Gol (se pierde al rotar): $golesQueSePierden")
}
Button(onClick = { golesQueSobreviven++ }) {
Text("Gol (sobrevive): $golesQueSobreviven")
}
// … tabla.forEach { … } sigue igual, justo debajo …
}El guardado y la constante:
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
// Un Bundle es un mapa pequeno (clave, valor): solo datos sencillos y poco
// peso (limite practico de ~1 MB para TODO el proceso). Es para el ESTADO DE
// LA PANTALLA, no para guardar datos de verdad: eso llega con Room y DataStore.
outState.putInt(CLAVE_GOLES, golesQueSobreviven)
anotar("onSaveInstanceState")
} private companion object {
const val CLAVE_GOLES = "golesQueSobreviven"
}Reprodúcelo
Los comandos de adb van en la Terminal de Android Studio (View → Tool Windows → Terminal), o en cualquier terminal. Si adb no responde, o el emulador no está arrancado, repasa La Activity (ejecutar la app) y El ciclo de vida de la Activity (Logcat); qué hacen adb shell, am y pidof lo dice la Chuleta de adb.
- Ejecuta la app otra vez. Con el emulador arrancado, el selector de dispositivo con el tuyo y el de configuración en
app, pulsa Run. Esta vez Android Studio compila solo lo que ha cambiado, reinstala la app encima de la anterior y la reabre; tarda segundos. Toca el primer botón una vez y el segundo dos: «se pierde: 1» y «sobrevive: 2». - Gira el móvil con los botones de giro del emulador, los de la barra pegada al borde de su ventana (los viste en El ciclo de vida de la Activity). Verás
Gol (se pierde al rotar): 0yGol (sobrevive): 2. Pulsa el otro botón de giro para dejar el móvil en vertical. - Mata el proceso. Toca una vez el primero, para que queden en 1 y 2. Pulsa el botón de inicio del emulador (Home) para que la app quede en segundo plano. Con el primer comando pide su número de proceso (
pid), el que identifica al proceso en el sistema; el segundo vacía Logcat y el tercero mata el proceso:
adb shell pidof com.sebastiannarvaez.marcador
adb logcat -c
adb shell am kill com.sebastiannarvaez.marcador
adb shell pidof com.sebastiannarvaez.marcadorEl último pidof es la comprobación: no debe imprimir nada. Si imprime un número, el proceso sigue vivo (la app aún no cuenta como oculta): espera unos segundos y repite el am kill y el pidof, hasta que no salga nada.
Abre la app otra vez (desde la lista de aplicaciones del emulador, desde la lista de apps recientes (como en el am kill de arriba), o con adb shell am start -n com.sebastiannarvaez.marcador/.MainActivity) y vuelve a pedir el pid con adb shell pidof com.sebastiannarvaez.marcador: es otro número, porque es otro proceso. Los dos contadores tienen el mismo destino que al rotar: 0 y 2. Con adb logcat -d -s Ciclo (o, en la ventana Logcat de Android Studio, el filtro tag:Ciclo) verás algo como esto:
D Ciclo : MainActivity@8c3fcc4 onCreate (estado guardado: si)
D Ciclo : MainActivity@8c3fcc4 onStart
D Ciclo : MainActivity@8c3fcc4 onRestoreInstanceState
D Ciclo : MainActivity@8c3fcc4 onResumeRotación y muerte del proceso dan el mismo resultado porque en ambos casos la Activity nueva recibe el Bundle guardado y nada más. El Bundle es para el estado de la pantalla; los datos de verdad (partidos, resultados) irán a una base de datos. Y hay herramientas que guardan estado por ti: rememberSaveable en Estado en Compose y el ViewModel en ViewModel: qué sobrevive.
Punto de control
MainActivity.ktse abre sin nada en rojo y Run reinstala la app en el emulador sin errores.- Tras 1 y 2 toques y una rotación, la pantalla dice
0en el primer botón y2en el segundo. - Tras
am killy volver,pidofda otro número y Logcat (tag:Ciclo) muestraonCreate (estado guardado: si)yonRestoreInstanceState. - Los dos contadores acaban en
0y2también tras matar el proceso. - El emulador vuelve a estar en vertical tras girarlo con sus botones.
MainActivity.ktno contiene la palabraejercicios(la búsqueda con Cmd+F no da resultados).
Comprueba lo que entendiste
5 preguntas · necesitas 4 para completar la lección
1.Tienes
private var goles by mutableIntStateOf(0)en la Activity, sin guardarlo en elBundle. Sumas 3 goles y giras el móvil. ¿Cuánto valegoles?2.¿En qué callback guardas un dato en el
Bundley dónde lo recuperas?3.Pulsas Home y ejecutas
adb shell am killdel paquete de la app. Al volver,onCreatediceestado guardado: si. ¿Por qué?4.Quieres guardar en el
Bundlela lista completa de partidos de un torneo grande, con todos sus eventos. ¿Qué haces?5.¿Cuándo se llama a
onConfigurationChangedde una Activity al rotar el móvil?