SavedStateHandle
Al terminar: Conservar estado a través de la muerte del proceso con SavedStateHandle.
Al terminar esta lección, en Marcador puedes tocar un partido de la lista y la pestaña «Marcador» pasa a mostrarlo. Después matas el proceso de la app con adb y, al volver, el partido elegido sigue siendo el mismo. El minuto y los goles en vivo, en cambio, empiezan de cero: es lo esperado, y la lección explica por qué.
Importa porque un ViewModel sobrevive a rotar, pero no a la muerte del proceso, y el usuario no distingue entre «la app se cerró sola en segundo plano» y «la app se rompió». Temas: SavedStateHandle y la muerte del proceso.
Qué es SavedStateHandle
Un SavedStateHandle es un mapa de clave a valor que Android guarda en ese mismo Bundle. Lo pides como parámetro del constructor del ViewModel y, cuando el proceso muere, el sistema te devuelve lo que dejaste ahí.
La regla práctica es de tres niveles:
| Qué es | Dónde va | Ejemplo en Marcador |
|---|---|---|
| Lo poco y barato que reconstruye la pantalla | SavedStateHandle | el id del partido elegido |
| Estado de la pantalla que se puede rehacer | ViewModel (se pierde con la muerte del proceso) | minuto, marcador en vivo, goles a mano |
| Datos que no se pueden perder | una base de datos | los partidos jugados |
viewModel() sabe pasarle un SavedStateHandle al constructor de tu ViewModel sin que hagas nada más: basta con declararlo como parámetro.
class DetalleViewModel(private val estadoGuardado: SavedStateHandle) : ViewModel() { // viewModel() le pasa el handle solo
// Un StateFlow que lee del handle: cambia cada vez que se escribe la clave.
val partidoId: StateFlow<Int> = estadoGuardado.getStateFlow("partidoId", 1) // clave y valor si aún no hay nada
fun elegir(id: Int) {
estadoGuardado["partidoId"] = id // el sistema lo conserva si el proceso muere
}
}Error típico: guardar el id en una propiedad normal
class PartidoViewModel : ViewModel() {
var partidoId = 1 // sobrevive a rotar, pero no a la muerte del proceso
fun elegir(id: Int) { partidoId = id }
}class PartidoViewModel(private val estadoGuardado: SavedStateHandle) : ViewModel() {
val partidoId: StateFlow<Int> = estadoGuardado.getStateFlow("partidoId", 1)
fun elegir(id: Int) { estadoGuardado["partidoId"] = id }
}El primero se comporta bien al girar el móvil, así que parece correcto. Falla cuando el sistema mata el proceso: el ViewModel nuevo arranca con el valor por defecto y el usuario vuelve a ver el partido 1 sin haberlo pedido. El segundo guarda el id fuera del proceso.
Un parámetro con valor por defecto
En Kotlin, un parámetro puede llevar un valor por defecto con = valor: si quien llama no pasa nada, se usa ese. Una pantalla puede hacer lo mismo con su ViewModel: lector: LectorViewModel = viewModel() quiere decir «si no me das uno, lo pido yo». Quien quiera darle otro (otra pantalla que ya tiene el suyo, o un ensayo) lo pasa y listo. A la izquierda del = está el nombre del parámetro; a la derecha, la función viewModel() de la lección anterior.
Reto · Ejercicio: el lector que no pierde la página
Vas a comprobar qué guarda el estado guardado y qué no. Para mirar la pantalla en el emulador usa Run Preview, la vista previa ejecutada que presentó Estado en Compose (el triángulo verde junto a @Preview).
Crea los ficheros. Crea la subcarpeta como en ViewModel: qué sobrevive: clic derecho sobre com.sebastiannarvaez.marcador → New → Package → añade .ejercicios.lector al final del nombre que aparece ya escrito. Dentro, New → Kotlin Class/File → Class → LectorViewModel, y otra vez → File → PantallaLector.
El ViewModel recibe su estado guardado al crearse:
- la página sale de ese estado guardado, con la clave «pagina», y es 1 si todavía no hay nada;
- una función,
pasarPagina, la sube en uno escribiendo en el estado guardado; - los minutos leyendo viven solo en el ViewModel: empiezan en 0, solo él los cambia y desde fuera solo se leen;
- una función,
sumarMinuto, los sube en uno.
La pantalla recibe el ViewModel como parámetro con valor por defecto: si no se lo pasan, lo pide. Recoge las dos cosas con el ciclo de vida y muestra, uno debajo de otro:
- «Página N»;
- un botón «Pasar página»;
- «Minutos leyendo: N»;
- un botón «Sumar minuto».
Añade su vista previa.
Lo que debes ver.
- El panel de vistas previas mostrará «Render Problem»: un ViewModel que recibe estado guardado solo lo puede crear una pantalla de verdad. Ejecútala con Run Preview.
- Pulsa «Pasar página» dos veces y «Sumar minuto» tres: «Página 3» y «Minutos leyendo: 3». Gira el emulador: sigue igual.
- Pulsa Home y, en la Terminal de Android Studio, mata el proceso con las tres órdenes de abajo (
adb shelllas ejecuta dentro del emulador y se explican en Rotación y muerte del proceso;com.sebastiannarvaez.marcador.debuges el id de la variantegratisDebug, como viste en Gradle: flavors y variantes):
adb shell pidof com.sebastiannarvaez.marcador.debug
adb shell am kill com.sebastiannarvaez.marcador.debug
adb shell pidof com.sebastiannarvaez.marcador.debugLa primera imprime el número de proceso; tras am kill, la tercera no imprime nada: el proceso murió. am kill solo mata procesos en segundo plano, por eso hace falta Home antes.
4. Vuelve desde la lista de recientes del emulador: «Página 3» y «Minutos leyendo: 0».
No cierres la app deslizándola fuera de recientes: el estado se descartaría y arrancaría limpia. Al terminar, elige otra vez app en el selector de ejecución de arriba.
Ver solución
package com.sebastiannarvaez.marcador.ejercicios.lector
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
class LectorViewModel(private val estadoGuardado: SavedStateHandle) : ViewModel() {
// La página va al handle: sobrevive a rotar Y a la muerte del proceso.
val pagina: StateFlow<Int> = estadoGuardado.getStateFlow(CLAVE_PAGINA, 1)
// Los minutos viven solo en el ViewModel: sobreviven a rotar, no a la muerte del proceso.
private val minutosActuales = MutableStateFlow(0)
val minutos: StateFlow<Int> = minutosActuales.asStateFlow()
fun pasarPagina() {
estadoGuardado[CLAVE_PAGINA] = pagina.value + 1
}
fun sumarMinuto() {
minutosActuales.value += 1
}
companion object {
const val CLAVE_PAGINA = "pagina"
}
}package com.sebastiannarvaez.marcador.ejercicios.lector
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
@Composable
fun PantallaLector(modifier: Modifier = Modifier, lector: LectorViewModel = viewModel()) {
val pagina by lector.pagina.collectAsStateWithLifecycle()
val minutos by lector.minutos.collectAsStateWithLifecycle()
Column(modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Página $pagina")
Button(onClick = lector::pasarPagina) { Text("Pasar página") }
Text("Minutos leyendo: $minutos")
Button(onClick = lector::sumarMinuto) { Text("Sumar minuto") }
}
}
@Preview(showBackground = true)
@Composable
private fun PantallaLectorPreview() {
MaterialTheme { PantallaLector() }
}Sobre el «Render Problem». La vista previa del panel crea los ViewModels con un dueño falso que no sabe entregar un estado guardado, así que no puede crear este. En Run Preview, la pantalla es una de verdad (una ComponentActivity) y sí lo entrega, y lo restaura tras am kill.
Paso · elegir un partido y que sobreviva a am kill
Objetivo. La lista de partidos se puede tocar; al tocar una fila, el ViewModel cambia de partido y la app salta a la pestaña «Marcador», que dice «Partido n.º 2». Con am kill, el partido sigue siendo el 2.
Nada que instalar. SavedStateHandle vive en la librería lifecycle-viewmodel-savedstate, que ya está en tu proyecto: la trae la Activity de Compose, igual que trajo la clase ViewModel en ViewModel: qué sobrevive. No hay nada de Gradle que tocar, así que no hay Sync Now.
Rutas de los bloques. El título de cada bloque de abajo es la ruta del fichero en tu proyecto (app/src/main/java/…/feature/marcador/ui/…, …/feature/partidos/ui/… o …/ui/…); en la vista Android lo encuentras en app → kotlin+java → com.sebastiannarvaez.marcador, en el paquete que dice la ruta.
Dónde va cada trozo (obligatorio; el código está debajo):
| Fichero | Qué hacer |
|---|---|
PartidoViewModel.kt | El constructor y las primeras propiedades sustituyen a la cabecera de la clase; trabajos va después de marcador y antes de golAMano; seleccionar va antes de empezar; empezar se sustituye entera; guionDe va justo antes del companion object y, dentro del companion object que ya tienes, se añade solo la línea CLAVE_PARTIDO. |
Listas.kt | El import va tras el package; la firma de PantallaPartidos se sustituye por la nueva, y en la llamada a FilaDePartido de cada fila se añade el Modifier.clickable. |
AppMarcador.kt | Dentro de la función AppMarcador, donde están pestana, titulos y torneo al principio, la línea val viewModel… va justo después de val torneo = …; en el when (pestana), la rama 0 -> recibe viewModel como tercer argumento, y en la rama 1 -> la llamada PantallaPartidos(…), desde ) { interior -> PantallaPartidos( hasta su ) } final, se sustituye por la del bloque. |
PantallaMarcador.kt | La firma y las dos primeras líneas del cuerpo (val viewModel… y val partido…) se sustituyen por las del bloque, y el Text va como primera línea dentro de extras = { … }. |
Piezas nuevas de este paso:
SavedStateHandlecomo parámetro del constructor,handle.getStateFlow(clave, inicial)yhandle[clave] = valor: arriba.Jobycancel()(los de Alcance, Job y cancelación): elJobes lo que devuelvelaunch; el ViewModel guarda el suyo para poder pararlo al cambiar de partido: si no lo cancelaras, el reloj del partido anterior seguiría contando y mezclaría sus goles con los del partido nuevo. Se guarda en una lista (List<Job>) porqueforEach { it.cancel() }cancela «todos los que haya» y sirve igual con uno, con varios o con ninguno.Modifier.clickable { … }: hace que un elemento responda al toque.DatosDeEjemplo.partidos.first { it.id == id }: busca en la lista de 18 partidos de Listas con LazyColumn el que tiene eseid. Hasta la lección de Room, esa lista hace de base de datos.viewModel: PartidoViewModel = viewModel(): el parámetro por defecto de antes del ejercicio.AppMarcadorle pasa el suyo para que se vea con claridad que lista y pantalla comparten el ViewModel (no haría falta:viewModel()ya devuelve la misma instancia dentro de la misma Activity).
Esas piezas, en un reproductor de música inventado (otra clave, otros datos; no es PartidoViewModel):
data class Cancion(val id: Int, val titulo: String)
val canciones = listOf(Cancion(1, "Vamos Rayo"), Cancion(2, "Toros al ataque")) // los datos de mentira del ejemplo
class ReproductorViewModel(private val estadoGuardado: SavedStateHandle) : ViewModel() {
val cancionId: StateFlow<Int> = estadoGuardado.getStateFlow(CLAVE_CANCION, 1)
private var trabajos: List<Job> = emptyList() // los Job que devuelve launch, para poder pararlos
fun elegir(id: Int) {
if (id == cancionId.value) return // la misma: no hay nada que hacer
trabajos.forEach { it.cancel() } // para todo lo que estuviera sonando
trabajos = emptyList()
estadoGuardado[CLAVE_CANCION] = id // guarda el id: sobrevive a am kill
}
fun reproducir() {
trabajos = listOf(viewModelScope.launch { delay(1_000) }) // launch devuelve un Job; se guarda en la lista
}
fun tituloDe(id: Int): String = canciones.first { it.id == id }.titulo // first: el primero que cumple
companion object {
const val CLAVE_CANCION = "cancionId" // la clave, escrita una sola vez
}
}
@Composable
fun ListaDeCanciones(
onElegir: (Int) -> Unit,
modifier: Modifier = Modifier,
reproductor: ReproductorViewModel = viewModel(), // parámetro con valor por defecto: si no te lo pasan, lo pide
) {
val elegida by reproductor.cancionId.collectAsStateWithLifecycle() // la canción guardada en el handle
Column(modifier) {
canciones.forEach { cancion -> // una fila por canción
Text(cancion.titulo, Modifier.clickable { onElegir(cancion.id) }) // clickable: la fila responde al toque
}
Text("Suena: ${reproductor.tituloDe(elegida)}")
}
}Intenta escribirlo tú.
PartidoViewModelrecibe su estado guardado y expone, para leer, el id del partido elegido (que sale de ese estado guardado, con la clavepartidoId, y es 1 si no hay ninguno) y el partido, cuyo guion se busca por id en la lista de partidos de ejemplo.- Una función,
seleccionar, que recibe un id y, si es otro partido, para el reloj, pone a cero el minuto, el aviso («ninguno») y los dos marcadores, cambia el guion y guarda el id en el estado guardado.empezarjuega ahora el guion elegido y guarda su trabajo para poder pararlo. - En
Partidos, tocar una fila elige ese partido y salta a la pestaña del marcador; y la pantalla del marcador comparte elViewModelcon la lista y enseña debajo qué partido se eligió. Debes ver «Partido n.º 2 (elegido en la pestaña Partidos)» bajo el marcador. Una cosa cambia a partir de ahora: la pantalla ya no enseñaEjemplo.rayoContraTorospor defecto, sino el partido 1 de la lista (también Rayo contra Toros, pero con otro guion de goles).
Intenta primero. Cuando termines, compara con la solución.Mostrar el código
PartidoViewModel.kt. Imports: el de Ejemplo deja de usarse (puedes borrarlo) y se añaden estos cuatro (DatosDeEjemplo, la lista de partidos de la feature partidos, es de otro paquete y por eso lleva import):
import androidx.lifecycle.SavedStateHandle
import com.sebastiannarvaez.marcador.feature.partidos.data.DatosDeEjemplo
import com.sebastiannarvaez.marcador.torneo.Partido
import kotlinx.coroutines.JobLa cabecera de la clase y sus primeras propiedades (sustituyen a class PartidoViewModel : ViewModel() {, a val partido = … y a golesEnVivo):
class PartidoViewModel(private val estadoGuardado: SavedStateHandle) : ViewModel() {
// Un StateFlow LEIDO DEL HANDLE: cada vez que se escribe `estadoGuardado[CLAVE]`, cambia.
val partidoId: StateFlow<Int> = estadoGuardado.getStateFlow(CLAVE_PARTIDO, 1)
private val guion = MutableStateFlow(guionDe(partidoId.value))
private val golesEnVivo = MutableStateFlow(Marcador(0, 0))
val partido: StateFlow<Partido> = guion.asStateFlow()Después de marcador y antes de golAMano, la lista de trabajos; y seleccionar, justo antes de empezar:
private var trabajos: List<Job> = emptyList() // Elegir otro partido: para el directo, lo pone a cero y GUARDA el id en el handle.
fun seleccionar(id: Int) {
if (id == partidoId.value) return
trabajos.forEach { it.cancel() }
trabajos = emptyList()
minutoActual.value = 0
enMarcha.value = false
avisoActual.value = "ninguno"
golesAMano.value = Marcador(0, 0)
golesEnVivo.value = Marcador(0, 0)
guion.value = guionDe(id)
estadoGuardado[CLAVE_PARTIDO] = id
}empezar (sustituye a la anterior; ahora lanza en una lista de trabajos y usa el guion elegido):
fun empezar() {
if (enMarcha.value || minutoActual.value > 0) return
enMarcha.value = true
val partidoActual = guion.value
trabajos = listOf(
viewModelScope.launch {
while (minutoActual.value < MINUTOS_DEL_PARTIDO) {
delay(MS_POR_MINUTO)
val nuevo = minutoActual.value + 1
golesEnVivo.update { antes ->
partidoActual.eventos.filter { it.minuto == nuevo }.fold(antes) { m, evento -> m.despuesDe(evento, partidoActual) }
}
minutoActual.value = nuevo
if (nuevo % 15 == 0) {
val vivo = golesEnVivo.value
val mano = golesAMano.value
avisoActual.value = "minuto $nuevo con ${Marcador(vivo.local + mano.local, vivo.visitante + mano.visitante)}"
}
}
enMarcha.value = false
},
)
}Justo antes del companion object, guionDe:
private fun guionDe(id: Int): Partido = DatosDeEjemplo.partidos.first { it.id == id }.partidoY, dentro del companion object que ya tienes (el que contiene MS_POR_MINUTO), añade solo esta línea; no pegues otro companion object, porque una clase solo puede tener uno:
const val CLAVE_PARTIDO = "partidoId"Listas.kt: el import va arriba, tras el package; PantallaPartidos recibe onElegir; y cada fila se hace clicable:
import androidx.compose.foundation.clickablefun PantallaPartidos(
partidos: List<PartidoDeLista>,
onElegir: (Int) -> Unit,
modifier: Modifier = Modifier,
) { FilaDePartido(
item.partido.local.nombre,
item.partido.visitante.nombre,
item.partido.marcador().toString(),
Modifier.clickable { onElegir(item.id) },
)AppMarcador.kt: el ViewModel se pide en la raíz, se le pasa a la pantalla del marcador, y la lista, al elegir, llama a seleccionar y salta a la pestaña 0. Los dos import van arriba con los demás (PartidoViewModel vive en otra carpeta de la app, feature → marcador → ui):
import androidx.lifecycle.viewmodel.compose.viewModel
import com.sebastiannarvaez.marcador.feature.marcador.ui.PartidoViewModel val viewModel: PartidoViewModel = viewModel() 0 -> PantallaMarcador(onVerDemos, Modifier.padding(paddingValues), viewModel) ) { interior -> PantallaPartidos(
DatosDeEjemplo.partidos,
onElegir = { id ->
viewModel.seleccionar(id)
pestana = 0
},
modifier = Modifier.padding(interior),
) }PantallaMarcador.kt: la función recibe el ViewModel como parámetro y recoge partido y partidoId (sustituye a la firma y a val viewModel… / val partido = …):
fun PantallaMarcador(
onVerDemos: () -> Unit,
modifier: Modifier = Modifier,
viewModel: PartidoViewModel = viewModel(),
) {
val partido by viewModel.partido.collectAsStateWithLifecycle()
val partidoId by viewModel.partidoId.collectAsStateWithLifecycle()Y, dentro de extras = { … }, como primera línea, el texto que enseña qué partido hay elegido:
Text("Partido n.º $partidoId (elegido en la pestaña Partidos)")Qué hace cada pieza
getStateFlowconvierte una clave del handle en unStateFlow: al escribirestadoGuardado[CLAVE_PARTIDO] = id, los que lo recogen ven el valor nuevo y el sistema lo guarda fuera del proceso.seleccionarlo rehace todo. Cancela los trabajos del reloj y pone a cero minuto, aviso y goles, porque un partido nuevo empieza de cero y no se arrastra nada del anterior.- Solo va el id al handle. Un partido entero no cabe en un
Bundle, y no hace falta: con el id,guionDelo reconstruye.
Mata el proceso y comprueba
Ejecuta la app con Run, con la variante gratisDebug elegida en View → Tool Windows → Build Variants y el emulador arrancado. En la pestaña Partidos toca la segunda fila; la pestaña Marcador dice «Partido n.º 2». Pulsa el botón Home del emulador y mata el proceso con las tres órdenes del ejercicio (pidof, am kill y pidof otra vez, con el id com.sebastiannarvaez.marcador.debug). Abre la app desde la lista de recientes del emulador. Verás «Partido n.º 2» y el minuto en 0'. Si pides el pid otra vez, es otro número: es un proceso nuevo con un ViewModel nuevo, que ha leído el id del handle.
Punto de control
- La app compila y se ejecuta sin errores en rojo (si prefieres comprobarlo en la Terminal de Android Studio,
./gradlew :app:assembleGratisDebugtermina enBUILD SUCCESSFUL). - Tocar la segunda fila de «Partidos» lleva a «Marcador» con «Partido n.º 2» y el marcador en 0-0.
- Tras Home,
am killy volver desde recientes, sigue diciendo «Partido n.º 2», con el minuto en 0'. - En
ejerciciostienes la subcarpetalectorcon sus dos ficheros, y trasam killmostraba «Página 3» y «Minutos leyendo: 0». PartidoViewModel.kttieneSavedStateHandleen su constructor ybuild.gradle.kts (Module :app)no ha cambiado.
Comprueba lo que entendiste
5 preguntas · necesitas 4 para completar la lección
1.Tras un
am killcon la app en segundo plano, ¿qué pasa con elViewModelque tenía la app?2.¿Qué es lo adecuado para guardar en un
SavedStateHandle?3.Un ViewModel guarda el partido elegido en una propiedad normal,
var partidoId = 1(el partido inicial). El usuario elige el partido 2, gira el móvil y luego el sistema mata el proceso. ¿Qué ve en cada situación?4.¿Cómo obtiene un ViewModel su
SavedStateHandlecuando se pide conviewModel()?5.Con la app en segundo plano matas el proceso con
am kill. ¿Cuál de estos datos de Marcador no se recupera al volver?