ViewModel: qué sobrevive
Al terminar: Saber qué sobrevive un ViewModel, usar viewModelScope y por qué no GlobalScope.
Al terminar esta lección sabes qué es un ViewModel, qué sobrevive y qué no, y lo has visto con tus ojos en un temporizador de cocina: giras el móvil y la cuenta sigue; sales de la pantalla y el ViewModel se limpia. En la lección siguiente usarás todo esto para arreglar el fallo de rotación del partido de Marcador.
Importa porque un ViewModel separa lo que dura una instancia de la Activity de lo que dura el trabajo del usuario en esa pantalla: rotar el móvil destruye la Activity, pero no el trabajo que estaba en marcha. Y porque el alcance de corrutinas de un ViewModel, viewModelScope, completa la familia de alcances de Android que llevas construyendo desde Alcance, Job y cancelación.
Temas: ViewModel, qué sobrevive, onCleared y viewModelScope frente a GlobalScope. Además instalas una librería nueva, lifecycle-viewmodel-compose, con la receta de Los ficheros de Gradle de tu proyecto.
Qué es un ViewModel y qué sobrevive
Un ViewModel es un objeto que guarda el estado de una pantalla y la lógica que lo produce. Android no lo guarda en la composición ni dentro de la Activity, sino en un almacén (ViewModelStore: una especie de armario donde el sistema guarda los ViewModels) que pertenece a la Activity y que el sistema conserva al rotar. Por eso, cuando la Activity nueva pide su ViewModel, recibe la misma instancia que tenía la anterior.
El ViewModelStoreOwner de esa cita es simplemente el dueño del almacén: en Marcador, la Activity. Mientras ella exista como pantalla del usuario (aunque se destruya y se recree al rotar), el almacén y los ViewModels de dentro siguen ahí.
Esta tabla es la que hay que saber de memoria:
| Situación | ¿Sobrevive el ViewModel? |
|---|---|
| Recomposición | Sí |
| Rotar el móvil (cambio de configuración) | Sí, misma instancia |
| Cambiar el tema, el idioma o el tamaño de la ventana | Sí |
El usuario cierra la pantalla definitivamente (pulsa Atrás hasta salir de la app, o el código llama a finish()) | No: se limpia y se llama a onCleared |
| Muerte del proceso (el sistema necesita memoria) | No: al volver hay un ViewModel nuevo, vacío |
onCleared() es el último aviso que recibe un ViewModel: el almacén se vacía porque la pantalla se va para siempre. Sirve para liberar lo que no son corrutinas: un listener registrado (una función que dejaste apuntada en otro objeto para que te avise cuando pase algo, y que hay que borrar al irte) o un fichero abierto. El ViewModel del partido, que crearás en la próxima lección, no lo sobrescribe: no tiene nada así, y las corrutinas ya las cancela viewModelScope, que es lo siguiente.
class ContadorViewModel : ViewModel() { // hereda de ViewModel: vive en el almacén de la Activity
private val golesActuales = MutableStateFlow(0) // private: solo el ViewModel lo cambia
// Solo lectura hacia fuera: únicamente el ViewModel escribe.
val goles: StateFlow<Int> = golesActuales.asStateFlow() // la misma caja, vista como StateFlow de solo lectura
fun marcar() { golesActuales.value += 1 } // el evento que la pantalla puede pedir
// Lo llama el sistema cuando la pantalla se va para siempre.
override fun onCleared() {
// Aquí se liberaría lo que no sean corrutinas.
}
}En Compose se pide con viewModel(): la primera vez lo crea y las siguientes devuelve el mismo. Esa función no viene con Compose: vive en la librería lifecycle-viewmodel-compose, que instalas en la sección «Prepara el proyecto» de más abajo.
@Composable
fun PantallaContador() {
val contador: ContadorViewModel = viewModel() // la 1.ª vez lo crea; tras rotar, devuelve EL MISMO
val goles by contador.goles.collectAsStateWithLifecycle() // StateFlow -> State de Compose, solo mientras se ve
Button(onClick = contador::marcar) { // ::marcar: la función del ViewModel usada como lambda
Text("Goles: $goles")
}
}viewModelScope: el alcance con la vida del ViewModel
Las corrutinas de la lección de alcance y cancelación necesitan un alcance. El del ViewModel viene incluido y se llama viewModelScope.
Lanza sus corrutinas en el hilo principal, el que dibuja la pantalla. Lo que lanzas ahí sigue vivo al rotar y muere cuando la pantalla se va. Es exactamente la vida que necesita un partido en marcha.
Con esto ya tienes los cuatro alcances de Android que aparecen en la guía:
| Alcance | Vive hasta… | Se usa para |
|---|---|---|
lifecycleScope (en El hilo principal y el ANR) | que se destruye la Activity, también al rotar | trabajo atado a esa instancia de Activity |
rememberCoroutineScope() (en Más efectos: scope, derivedStateOf y SideEffect) | que el composable sale de la composición | corrutinas lanzadas desde un onClick |
viewModelScope | que se limpia el ViewModel (no al rotar) | trabajo de la pantalla que debe sobrevivir a rotar |
GlobalScope | nunca | nada: nadie lo cancela |
Error típico: GlobalScope dentro de un ViewModel
class PartidoViewModel : ViewModel() {
private val minuto = MutableStateFlow(0)
fun empezar() {
GlobalScope.launch {
while (true) {
delay(250)
minuto.value += 1
}
}
}
}class PartidoViewModel : ViewModel() {
private val minuto = MutableStateFlow(0)
fun empezar() {
viewModelScope.launch { // alcance del ViewModel: sobrevive a rotar, se cancela en onCleared
while (true) { // el bucle sigue hasta que el alcance se cancele
delay(250) // suspende 250 ms sin bloquear el hilo principal
minuto.value += 1 // escribe en el MutableStateFlow; la pantalla lo recoge
}
}
}
}La primera versión compila y funciona mientras pruebas. Falla después: GlobalScope no tiene Job, así que cuando el ViewModel se limpia la corrutina sigue contando durante toda la vida del proceso. Como el bucle escribe en minuto, una propiedad del ViewModel, mantiene vivo al ViewModel y a todo lo que guarda. Es una fuga de memoria y un consumo de batería que nadie ve. Se explica con más detalle en Un alcance propio.
Prepara el proyecto · Instala lifecycle-viewmodel-compose
La receta de Los ficheros de Gradle de tu proyecto, en corto, como en Efectos y ciclo de vida: DisposableEffect. Hace falta ya, porque el ejercicio de abajo usa viewModel().
1 · Qué instalas. lifecycle-viewmodel-compose trae viewModel(), la función que pide a la Activity su ViewModel. La clase ViewModel y viewModelScope ya las tienes: ya vienen con lo que tienes instalado. Falta el puente con Compose, y es esta librería.
2 · La versión. Nada en [versions]: es de la familia androidx.lifecycle y ya está como lifecycleRuntimeKtx.
3 · La librería. En Gradle Scripts → libs.versions.toml (Version Catalog "libs"), al final de [libraries]:
# version.ref = lifecycleRuntimeKtx (2.11.0): el número de toda la familia lifecycle
androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycleRuntimeKtx" }4 · Un plugin, no. Es una librería: no hay [plugins] ni apply false.
5 · La dependencia. En build.gradle.kts (Module :app), dentro de dependencies { … }, debajo de libs.androidx.lifecycle.runtime.compose:
// implementation: el código de la pantalla la usa al compilar y al ejecutar
implementation(libs.androidx.lifecycle.viewmodel.compose)6 · Sync Now. Debe terminar sin errores rojos (si falla, la caja «Si Sync sale en rojo» de Los ficheros de Gradle de tu proyecto; lo probable es un alias con guiones donde van puntos). La comprobación llega al escribir el ejercicio: si Android Studio reconoce import androidx.lifecycle.viewmodel.compose.viewModel, la librería está. Si no, con Unresolved reference 'compose' en el import o 'viewModel' más abajo, falta la línea de :app o su Sync Now.
Reto · Ejercicio: el temporizador de cocina
Vas a ver con tus ojos qué sobrevive al girar el móvil y cuándo se limpia un ViewModel. Para mirar Logcat no sirve el modo interactivo: usa Run Preview, la vista previa ejecutada en el emulador que presentó Estado en Compose (el triángulo verde junto a @Preview).
Crea los ficheros. En la carpeta ejercicios (la que creaste en La rotación y la muerte del proceso), crea la subcarpeta: clic derecho sobre com.sebastiannarvaez.marcador → New → Package → añade .ejercicios.temporizador al final del nombre que aparece ya escrito. Luego, clic derecho sobre esa carpeta:
- New → Kotlin Class/File → Class →
TemporizadorViewModel; - otra vez, New → Kotlin Class/File → File →
PantallaTemporizador.
El ViewModel:
- guarda los segundos, que empiezan en 0; solo él los cambia y desde fuera solo se leen;
- tiene una función,
arrancar. La primera vez lanza, en el alcance del ViewModel, una cuenta que suma un segundo cada segundo, para siempre, y deja en Logcat con la etiquetaTemporizadorla línea «segundo N». Si ya estaba arrancado, no hace nada; - cuando se limpia, deja en Logcat «onCleared: la pantalla se fue para siempre».
La pantalla pide el ViewModel y recoge los segundos teniendo en cuenta el ciclo de vida. Además, recuerda en la composición (como en Estado en Compose) un contador de toques que empieza en 0. Muestra, uno debajo de otro:
- «Temporizador: N s»;
- un botón «Arrancar»;
- «Toques: N»;
- un botón «Tocar», que suma un toque.
Añade una vista previa con la pantalla.
Lo que debes ver.
- En Split: «Temporizador: 0 s», «Toques: 0» y los dos botones.
- Ejecútala con Run Preview y filtra Logcat con
tag:Temporizador. Pulsa «Arrancar» y «Tocar» tres veces: el temporizador sube cada segundo y pone «Toques: 3». - Gira el emulador: el temporizador sigue por donde iba y Logcat no se corta, pero «Toques» vuelve a 0.
- Pulsa Atrás para salir: Logcat muestra «onCleared: la pantalla se fue para siempre» y no sale ninguna línea «segundo» más.
Al terminar, elige otra vez app en el selector de ejecución de arriba.
Ver solución
package com.sebastiannarvaez.marcador.ejercicios.temporizador
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
class TemporizadorViewModel : ViewModel() {
private val segundosActuales = MutableStateFlow(0) // privado: solo el ViewModel lo cambia
val segundos: StateFlow<Int> = segundosActuales.asStateFlow() // hacia fuera, solo lectura
private var arrancado = false
fun arrancar() {
if (arrancado) return // una sola cuenta, aunque pulses dos veces
arrancado = true
viewModelScope.launch { // sobrevive a rotar; se cancela al limpiar
while (true) {
delay(1_000)
segundosActuales.value += 1
Log.d("Temporizador", "segundo ${segundosActuales.value}")
}
}
}
override fun onCleared() {
Log.d("Temporizador", "onCleared: la pantalla se fue para siempre")
}
}package com.sebastiannarvaez.marcador.ejercicios.temporizador
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.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
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 PantallaTemporizador(modifier: Modifier = Modifier) {
val temporizador: TemporizadorViewModel = viewModel() // tras rotar, el MISMO
val segundos by temporizador.segundos.collectAsStateWithLifecycle()
var toques by remember { mutableIntStateOf(0) } // vive en la composición: se pierde al rotar
Column(modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Temporizador: $segundos s")
Button(onClick = temporizador::arrancar) { Text("Arrancar") }
Text("Toques: $toques")
Button(onClick = { toques++ }) { Text("Tocar") }
}
}
@Preview(showBackground = true)
@Composable
private fun PantallaTemporizadorPreview() {
MaterialTheme { PantallaTemporizador() }
}Por qué se ve en el panel de vistas previas. La vista previa del panel sabe crear un ViewModel que no recibe parámetros, como este. En la lección de SavedStateHandle verás uno que no puede.
Por qué los toques se pierden y los segundos no. Al girar, la Activity y la composición se destruyen y se crean de nuevo. remember vive en la composición, así que empieza otra vez; el ViewModel vive en el almacén de la Activity, que el sistema conserva, y viewModel() devuelve el mismo.
Punto de control
Gradle Scripts → libs.versions.toml (Version Catalog "libs")tiene la líneaandroidx-lifecycle-viewmodel-composeybuild.gradle.kts (Module :app)tieneimplementation(libs.androidx.lifecycle.viewmodel.compose); Sync Now termina sin errores rojos.- En
app → kotlin+java → com.sebastiannarvaez.marcador → ejerciciostienes la subcarpetatemporizadorcon sus dos ficheros, y la app compila. - En Run Preview, el temporizador sigue al girar, «Toques» vuelve a 0 y, al salir con Atrás, Logcat escribe «onCleared».
- Marcador no ha cambiado: en esta lección no tocas ningún fichero de la app fuera de
ejerciciosy de los dos de Gradle.
La lección siguiente, ViewModel en Marcador: el partido, mueve el partido a un PartidoViewModel.
Comprueba lo que entendiste
5 preguntas · necesitas 4 para completar la lección
1.Giras el móvil con un partido en marcha guardado en un
ViewModel. ¿Qué ocurre con el ViewModel?2.¿Cuál de estas situaciones NO sobrevive un
ViewModel?3.¿Cuándo se cancelan las corrutinas lanzadas en
viewModelScope?4.Un compañero lanza el reloj del partido con
GlobalScope.launchdentro del ViewModel. ¿Qué problema tiene?5.¿Para qué sirve sobrescribir
onCleared()en un ViewModel?