Saltar al contenido
K
Android: la plataformaPaso a paso40 min de lectura · 4 min de máquina Intermedio

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 vuelvesNo (onStop y onRestart)NoSe conserva
Cambio de configuración (rotar, entrar en multiventana…)Sí, y se crea otraNoSe 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.

ejemplo mínimo: guardar y recuperar (con comentarios)
// 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».

con el bug: solo vive en la instancia
private var goles by mutableIntStateOf(0)   // vuelve a 0 al rotar: es una propiedad de una instancia que se destruye
corregido: guardado en el Bundle
private 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.marcador

am 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 = … }: el companion object y el const val de object, companion y vararg; private lo deja visible solo dentro de la clase. Ahí va la clave del Bundle.
  • mutableIntStateOf(0): un Int observable por la pantalla; al cambiar, la pantalla se repinta. Con by se usa como una variable normal. Necesita los imports getValue y setValue. 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) y savedInstanceState?.getInt(clave) ?: 0: guardar y leer un entero del Bundle; el operador ?: da 0 si es null.

Así se escriben juntas esas piezas, con un contador de tarjetas en lugar de goles (no es el código de Marcador):

ejemplo mínimo: un botón con contador y una constante (con comentarios)
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: 3

Si 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:

  1. En app → kotlin+java, clic derecho sobre com.sebastiannarvaez.marcador (el que no lleva (test) ni (androidTest)) → New → Package.
  2. 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_creaciones al final (con el punto delante) y pulsa Intro. Android Studio crea las dos carpetas de una vez. Mientras ejercicios tenga 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 en ejercicios con una hija por ejercicio.
  3. Clic derecho sobre esa carpeta → New → Kotlin Class/File → File, escribe ContadorDeCreaciones y 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 (un Bundle?, que puede ser null): 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 (un Bundle) 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 3

Cada 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
app/src/main/java/com/sebastiannarvaez/marcador/ejercicios/contador_de_creaciones/ContadorDeCreaciones.kt
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):

MainActivity.kt: las líneas temporales del ejercicio
// 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 terminar

Antes 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.

app → kotlin+java → com.sebastiannarvaez.marcador
MainActivity.ktse modifica
Un solo fichero cambia, tal como lo ves en la vista Android.

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 import van arriba, con los demás (el orden de los import no importa).
  • Las dos propiedades van dentro de la clase, antes de onCreate.
  • En onCreate, la lectura del Bundle va justo después de anotar("onCreate …"), y los dos Button dentro de la Column, tras Text("Marcador") y antes de tabla.forEach, que se queda donde está (la tabla sigue en pantalla).
  • En onSaveInstanceState, la línea con putInt va entre super.onSaveInstanceState(outState) y anotar(...), con su comentario.
  • La constante CLAVE_GOLES va en un private companion object, dentro de la clase y justo antes de anotar, 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): 2

Cuando termines, abre los bloques y compara.

Intenta primero. Cuando termines, compara con la solución.Mostrar el código
app/src/main/java/com/sebastiannarvaez/marcador/MainActivity.kt
import androidx.compose.material3.Button
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.setValue

Las dos propiedades, con el comentario que explica cómo reproducirlo:

app/src/main/java/com/sebastiannarvaez/marcador/MainActivity.kt
    // 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:

app/src/main/java/com/sebastiannarvaez/marcador/MainActivity.kt
        // Si `savedInstanceState` no es null, Android nos devuelve lo que guardamos.
        golesQueSobreviven = savedInstanceState?.getInt(CLAVE_GOLES) ?: 0
app/src/main/java/com/sebastiannarvaez/marcador/MainActivity.kt
                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:

app/src/main/java/com/sebastiannarvaez/marcador/MainActivity.kt
    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")
    }
app/src/main/java/com/sebastiannarvaez/marcador/MainActivity.kt
    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.

  1. 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».
  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): 0 y Gol (sobrevive): 2. Pulsa el otro botón de giro para dejar el móvil en vertical.
  3. 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.marcador

El ú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 onResume

Rotació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.kt se 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 0 en el primer botón y 2 en el segundo.
  • Tras am kill y volver, pidof da otro número y Logcat (tag:Ciclo) muestra onCreate (estado guardado: si) y onRestoreInstanceState.
  • Los dos contadores acaban en 0 y 2 también tras matar el proceso.
  • El emulador vuelve a estar en vertical tras girarlo con sus botones.
  • MainActivity.kt no contiene la palabra ejercicios (la búsqueda con Cmd+F no da resultados).

Comprueba lo que entendiste

5 preguntas · necesitas 4 para completar la lección

  1. 1.Tienes private var goles by mutableIntStateOf(0) en la Activity, sin guardarlo en el Bundle. Sumas 3 goles y giras el móvil. ¿Cuánto vale goles?

  2. 2.¿En qué callback guardas un dato en el Bundle y dónde lo recuperas?

  3. 3.Pulsas Home y ejecutas adb shell am kill del paquete de la app. Al volver, onCreate dice estado guardado: si. ¿Por qué?

  4. 4.Quieres guardar en el Bundle la lista completa de partidos de un torneo grande, con todos sus eventos. ¿Qué haces?

  5. 5.¿Cuándo se llama a onConfigurationChanged de una Activity al rotar el móvil?

Faltan 5 de 5