Saltar al contenido
K
Jetpack y arquitecturaPaso a paso30 min de lectura · 10 min de máquina Intermedio

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ónSí
Rotar el móvil (cambio de configuración)Sí, misma instancia
Cambiar el tema, el idioma o el tamaño de la ventanaSí
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.

ejemplo mínimo: un ViewModel con onCleared (con comentarios)
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.

ejemplo mínimo: pedir el ViewModel y recoger su estado (con comentarios)
@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:

AlcanceVive hasta…Se usa para
lifecycleScope (en El hilo principal y el ANR)que se destruye la Activity, también al rotartrabajo atado a esa instancia de Activity
rememberCoroutineScope() (en Más efectos: scope, derivedStateOf y SideEffect)que el composable sale de la composicióncorrutinas lanzadas desde un onClick
viewModelScopeque se limpia el ViewModel (no al rotar)trabajo de la pantalla que debe sobrevivir a rotar
GlobalScopenuncanada: nadie lo cancela

Error típico: GlobalScope dentro de un ViewModel

con el bug: nadie cancela esta corrutina
class PartidoViewModel : ViewModel() {
    private val minuto = MutableStateFlow(0)
    fun empezar() {
        GlobalScope.launch {
            while (true) {
                delay(250)
                minuto.value += 1
            }
        }
    }
}
corregido: la cancela el propio ViewModel
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().

Gradle Scripts
libs.versions.toml (Version Catalog "libs")se modifica: [libraries]
build.gradle.kts (Module :app)se modifica: dependencies
app → kotlin+java → com.sebastiannarvaez.marcador → ejercicios → temporizador
TemporizadorViewModel.ktse crea (ejercicio)
PantallaTemporizador.ktse crea (ejercicio)
Dos ficheros de Gradle que ya tienes se modifican; el temporizador del ejercicio vive en la subcarpeta nueva de ejercicios.

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

[libraries]: la línea nueva (con comentarios)
# 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:

build.gradle.kts (Module :app): línea nueva (con comentarios)
    // 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 etiqueta Temporizador la 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.

  1. En Split: «Temporizador: 0 s», «Toques: 0» y los dos botones.
  2. 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».
  3. Gira el emulador: el temporizador sigue por donde iba y Logcat no se corta, pero «Toques» vuelve a 0.
  4. 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
app/src/main/java/com/sebastiannarvaez/marcador/ejercicios/temporizador/TemporizadorViewModel.kt
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")
    }
}
app/src/main/java/com/sebastiannarvaez/marcador/ejercicios/temporizador/PantallaTemporizador.kt
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ínea androidx-lifecycle-viewmodel-compose y build.gradle.kts (Module :app) tiene implementation(libs.androidx.lifecycle.viewmodel.compose); Sync Now termina sin errores rojos.
  • En app → kotlin+java → com.sebastiannarvaez.marcador → ejercicios tienes la subcarpeta temporizador con 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 ejercicios y 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. 1.Giras el móvil con un partido en marcha guardado en un ViewModel. ¿Qué ocurre con el ViewModel?

  2. 2.¿Cuál de estas situaciones NO sobrevive un ViewModel?

  3. 3.¿Cuándo se cancelan las corrutinas lanzadas en viewModelScope?

  4. 4.Un compañero lanza el reloj del partido con GlobalScope.launch dentro del ViewModel. ¿Qué problema tiene?

  5. 5.¿Para qué sirve sobrescribir onCleared() en un ViewModel?

Faltan 5 de 5