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

ViewModel en Marcador: el partido

Al terminar: Llevar el partido de Marcador a un ViewModel y comprobar que sobrevive a girar la pantalla.

Al terminar esta lección, el partido en vivo de Marcador ya no se reinicia al girar el móvil. El fallo que provocaste a propósito dos lecciones atrás desaparece: el partido pasa de remember a un ViewModel, y la pantalla se limita a mostrar lo que el ViewModel le da.

Importa porque es la primera vez que ves un ViewModel de verdad, con varios estados, un reloj y una pantalla que solo lee. Temas: estado privado con lectura pública, juntar dos flujos en un solo estado con stateIn, y las referencias a funciones del ViewModel. No instalas nada: la librería ya la tienes de ViewModel: qué sobrevive.

Estado privado, lectura pública

Un ViewModel guarda su estado en un MutableStateFlow, que se puede leer y escribir. Si lo dejaras a la vista, cualquier pantalla podría cambiarlo, y acabarías sin saber quién tocó qué. La costumbre es guardar la caja con private y enseñar hacia fuera la misma caja vista como StateFlow, que solo se puede leer. asStateFlow() hace esa conversión sin copiar nada.

Es como una vitrina de tienda: dentro puedes mover los productos (solo el dueño), y el cliente mira a través del cristal pero no toca.

ejemplo mínimo: estado privado, lectura pública (con comentarios)
class VotosViewModel : ViewModel() {
    private val votosActuales = MutableStateFlow(0)             // la caja: privada, solo la toca esta clase
    val votos: StateFlow<Int> = votosActuales.asStateFlow()     // la vitrina: la misma caja, de solo lectura
 
    fun votar() {                                               // lo único que la pantalla puede pedir
        votosActuales.value += 1
    }
}
 
// Desde fuera:
//   viewModel.votos.value          -> se puede leer
//   viewModel.votos.value = 5      -> no compila: el StateFlow no se puede escribir
//   viewModel.votar()              -> así es como se cambia

Un estado que junta dos flujos

A veces lo que la pantalla muestra sale de dos estados a la vez: el marcador de Marcador es el de los goles del partido más los que pulsas a mano. combine junta los dos flujos y calcula un valor cada vez que cambia cualquiera de los dos. Pero combine devuelve un Flow normal, sin valor inicial, y la pantalla necesita un StateFlow. stateIn lo convierte, y pide tres cosas: el alcance donde vivirá (viewModelScope), cuándo debe trabajar (SharingStarted) y el valor inicial.

SharingStarted.WhileSubscribed(5_000) quiere decir: trabaja mientras haya alguien mirando y, cuando el último deja de mirar, espera 5 segundos antes de parar. Girar el móvil dura menos de eso: la pantalla vieja se va, la nueva llega a tiempo y el flujo ni se entera.

ejemplo mínimo: un total que sale de dos estados (con comentarios)
class PedidoViewModel : ViewModel() {
    private val unidades = MutableStateFlow(1)                  // cuántas entradas de cine
    private val precioUnitario = MutableStateFlow(8)            // euros por entrada
 
    val total: StateFlow<Int> =
        combine(unidades, precioUnitario) { u, p -> u * p }     // se recalcula si cambia cualquiera de los dos
            .stateIn(
                viewModelScope,                                  // el alcance: vive lo que el ViewModel
                SharingStarted.WhileSubscribed(5_000),           // activo mientras alguien mira, y 5 s de margen
                8,                                               // valor inicial: 1 unidad × 8 euros
            )
 
    fun sumarUnidad() {
        unidades.update { it + 1 }                              // update: lee y escribe de una vez
    }
}

Pasar una función del ViewModel a un botón

Un botón recibe una lambda () -> Unit. Si el ViewModel ya tiene una función con esa forma, viewModel::sumarUnidad es una referencia a la función: se usa como si fuera la lambda { viewModel.sumarUnidad() }, sin escribirla. Es lo que ya viste en Lambdas y funciones de orden superior, aplicado a una función de un objeto.

Reto · Ejercicio: la caja del súper

Fuera de tu proyecto, en el Kotlin Playground.

Escribe una clase, Caja, que reciba al crearse un alcance de corrutinas. Por dentro guarda, en dos flujos de estado privados, lo que suman los productos y el descuento, los dos a 0 al empezar. Hacia fuera expone solo un flujo de estado de lectura, el total (productos menos descuento), que junta los dos y se mantiene activo desde el principio en el alcance que recibió, con valor inicial 0. Tiene dos funciones: una que añade un precio y otra que fija el descuento.

En el programa principal, con corrutinas, crea un alcance aparte, con su propio trabajo (Job()), recoge el total imprimiendo «total: N €» y, con 50 ms entre cada paso: añade 5, añade 3, fija un descuento de 2, cancela la recogida y el alcance. La consola debe mostrar:

total: 0 €
total: 5 €
total: 8 €
total: 6 €

Para pensar: ¿podrías escribir el total desde el programa principal? (No: es un StateFlow, de solo lectura. Solo la caja lo cambia, a través de sus funciones.)

Ver solución
solución: la caja del súper
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
 
class Caja(alcance: CoroutineScope) {
    private val productos = MutableStateFlow(0)                       // privado: solo la caja escribe
    private val descuento = MutableStateFlow(0)
    val total: StateFlow<Int> = combine(productos, descuento) { p, d -> p - d }
        .stateIn(alcance, SharingStarted.Eagerly, 0)                  // hacia fuera, un StateFlow de solo lectura
 
    fun anadir(precio: Int) = productos.update { it + precio }
    fun aplicarDescuento(euros: Int) { descuento.value = euros }
}
 
fun main() = runBlocking {
    val alcance = CoroutineScope(coroutineContext + Job())            // el alcance que haría de viewModelScope
    val caja = Caja(alcance)
    val pantalla = launch { caja.total.collect { println("total: $it €") } }
    delay(50); caja.anadir(5)
    delay(50); caja.anadir(3)
    delay(50); caja.aplicarDescuento(2)
    delay(50); pantalla.cancel(); alcance.cancel()
}

SharingStarted.Eagerly se vio en stateIn, shareIn y los flujos calientes: empieza a trabajar ya, sin esperar a que alguien mire.

Paso · el partido pasa al ViewModel

Objetivo. Mover el estado del partido (minuto, marcador, aviso y goles de los botones) y su reloj desde PantallaMarcador a un PartidoViewModel. Al terminar, pulsas «Empezar partido», giras el móvil y el partido sigue donde iba.

app → kotlin+java → com.sebastiannarvaez.marcador → feature → marcador → ui
PartidoViewModel.ktse crea
PantallaMarcador.ktse modifica
Un fichero de código que ya tienes se modifica; el ViewModel es el único fichero nuevo. No hay nada de Gradle.

Crea PartidoViewModel.kt

En la vista Android, clic derecho sobre ui, dentro de feature → marcador (en app → kotlin+java → com.sebastiannarvaez.marcador; es la carpeta que creaste con la pantalla del marcador) → New → Kotlin Class/File → Class → PartidoViewModel. Va ahí porque el ViewModel prepara lo que esa pantalla muestra: es parte de la feature marcador. Android Studio crea el fichero con una clase vacía; lo sustituirás entero.

Piezas nuevas de este paso:

  • class X : ViewModel() y viewModel(): el ViewModel y la función de Compose que lo pide al almacén (viewModel() viene de la librería que instalaste en la lección anterior).
  • viewModelScope.launch { … }: lanza una corrutina con la vida del ViewModel.
  • val viewModel: PartidoViewModel = viewModel(): la variable y la función se llaman igual, y Kotlin las distingue por el sitio: a la izquierda del = está el nombre que le pones a tu variable, y a la derecha, con paréntesis, la función viewModel() de la librería que instalaste en la lección anterior. Llamarla así es una costumbre; podrías llamar a la variable vm y funcionaría igual.
  • MINUTOS_DEL_PARTIDO: la constante 90 que ya tienes en Efectos.kt. Está en el mismo paquete feature.marcador.ui que PartidoViewModel, así que no necesita import; el bucle de empezar cuenta hasta ella.
  • private val a = MutableStateFlow(…) y val b: StateFlow<T> = a.asStateFlow(): escribir por dentro, solo leer por fuera.
  • combine(a, b) { … }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), inicial): junta dos flujos y los convierte en un StateFlow (combine de Flow: scan y combine y stateIn de stateIn, shareIn y los flujos calientes); con WhileSubscribed(5_000) sigue activo 5 segundos después de que el último recolector se vaya, así que una rotación (que dura menos) no lo interrumpe.
  • viewModel::golAMano: una referencia a una función del ViewModel, que sirve como lambda (Lado) -> Unit (las referencias con :: son las de Lambdas y funciones de orden superior).
  • companion object { const val MS_POR_MINUTO = 250L }: un companion object es el rincón de una clase donde van las cosas que no pertenecen a un objeto concreto, como una constante; const val la marca como número fijo, y dentro de la clase se usa por su nombre, MS_POR_MINUTO.
  • update, fold y despuesDe: ya los usaste en Recoger un Flow con el ciclo de vida.

Intenta escribirlo tú, pero solo lo nuevo. El fichero que verás abajo tiene unas 60 líneas porque junta piezas de cinco lecciones (StateFlow, combine, update, fold, el guion del partido); reconstruirlo de memoria no es el objetivo. Lo nuevo de esta lección es el ViewModel mínimo: un PartidoViewModel que guarda el minuto (solo él lo cambia; desde fuera solo se lee) y una función, empezar, que, si el partido no ha empezado, sube el minuto cada 250 ms hasta el 90 en una corrutina que vive lo que vive el ViewModel; y, en la pantalla, pedir ese ViewModel y recoger el minuto teniendo en cuenta el ciclo de vida. Intenta escribir solo eso (basta con el minuto) y comprueba que, con el partido en marcha, el minuto sigue donde iba después de girar el móvil. Después abre la solución, que es el fichero completo, para leerla y copiarla: es normal no escribirla entera tú.

Objetivo final: que PantallaMarcador no tenga ninguna corrutina ni ningún remember del partido. El ViewModel guarda los estados y arranca el reloj; la pantalla los recoge. El pausas (veces que la pantalla pasa a segundo plano) sigue en rememberSaveable. En pantalla debes ver lo mismo que antes, y al girar, ningún reinicio.

Dónde va cada trozo de PantallaMarcador.kt (obligatorio; el código está debajo):

  • Imports: añade el de viewModel (el bloque de una sola línea import androidx.lifecycle.viewmodel.compose.viewModel). Los que dejan de usarse, Android Studio los pone en gris: bórralos (si los dejas no rompen nada, solo son avisos). Si dudas con alguno, Marcador se queda: la tarjeta del partido lo usa.
  • El estado: dentro de PantallaMarcador, borra todo desde val partido = Ejemplo.rayoContraToros hasta la línea TituloDeLaActividad(estado.marcador) incluida, y escribe en su lugar el bloque que empieza por val viewModel: PartidoViewModel = viewModel(). Es la parte que escribiste en Recoger un Flow con el ciclo de vida con remember, el interruptor, el onEach y el reloj.
  • MarcadorContent(…): el último bloque, el que empieza por MarcadorContent(, sustituye a toda la llamada, desde MarcadorContent( hasta su ) de cierre. Pierde el Switch con su Text y manda los eventos al ViewModel con viewModel::golAMano y viewModel::empezar.
  • MS_POR_MINUTO: borra la línea private const val MS_POR_MINUTO = 250L, que ahora vive en el ViewModel. RelojDelPartido, de Efectos.kt, se queda en su fichero sin usarse: el reloj ahora es el bucle del ViewModel.
Intenta primero. Cuando termines, compara con la solución.Mostrar el código

El ViewModel entero (el fichero nuevo, sin nada más):

app/src/main/java/com/sebastiannarvaez/marcador/feature/marcador/ui/PartidoViewModel.kt
package com.sebastiannarvaez.marcador.feature.marcador.ui
 
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.sebastiannarvaez.marcador.torneo.Ejemplo
import com.sebastiannarvaez.marcador.torneo.Marcador
import com.sebastiannarvaez.marcador.torneo.despuesDe
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
 
class PartidoViewModel : ViewModel() {
 
    val partido = Ejemplo.rayoContraToros
 
    private val golesEnVivo = MutableStateFlow(Marcador(0, 0))
 
    // Estado PRIVADO y mutable, expuesto como solo lectura: solo el ViewModel escribe.
    private val minutoActual = MutableStateFlow(0)
    val minuto: StateFlow<Int> = minutoActual.asStateFlow()
 
    private val enMarcha = MutableStateFlow(false)
    val corriendo: StateFlow<Boolean> = enMarcha.asStateFlow()
 
    private val avisoActual = MutableStateFlow("ninguno")
    val ultimoAviso: StateFlow<String> = avisoActual.asStateFlow()
 
    private val golesAMano = MutableStateFlow(Marcador(0, 0))
 
    // El marcador que se ve = el del partido en vivo + los goles de los botones.
    // stateIn convierte el combine en un StateFlow con el alcance del ViewModel.
    val marcador: StateFlow<Marcador> =
        combine(golesEnVivo, golesAMano) { vivo, mano ->
            Marcador(vivo.local + mano.local, vivo.visitante + mano.visitante)
        }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), Marcador(0, 0))
 
    // Un evento de la pantalla: «han marcado los de aqui».
    fun golAMano(lado: Lado) = golesAMano.update {
        when (lado) {
            Lado.LOCAL -> it.copy(local = it.local + 1)
            Lado.VISITANTE -> it.copy(visitante = it.visitante + 1)
        }
    }
 
    fun empezar() {
        if (enMarcha.value || minutoActual.value > 0) return
        enMarcha.value = true
        viewModelScope.launch {
            while (minutoActual.value < MINUTOS_DEL_PARTIDO) {
                delay(MS_POR_MINUTO)
                val nuevo = minutoActual.value + 1
                golesEnVivo.update { antes ->
                    partido.eventos.filter { it.minuto == nuevo }.fold(antes) { m, evento -> m.despuesDe(evento, partido) }
                }
                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
        }
    }
 
    companion object {
        const val MS_POR_MINUTO = 250L
    }
}

PantallaMarcador.kt. El import nuevo va arriba, con los demás:

app/src/main/java/com/sebastiannarvaez/marcador/feature/marcador/ui/PantallaMarcador.kt
import androidx.lifecycle.viewmodel.compose.viewModel

El estado de la pantalla, que sustituye a todo lo que borraste:

app/src/main/java/com/sebastiannarvaez/marcador/feature/marcador/ui/PantallaMarcador.kt
    val viewModel: PartidoViewModel = viewModel()
    val partido = viewModel.partido
 
    val marcador by viewModel.marcador.collectAsStateWithLifecycle()
    val minuto by viewModel.minuto.collectAsStateWithLifecycle()
    val corriendo by viewModel.corriendo.collectAsStateWithLifecycle()
    val ultimoAviso by viewModel.ultimoAviso.collectAsStateWithLifecycle()
    val estado = EstadoMarcador(partido, marcador)
 
    var pausas by rememberSaveable { mutableIntStateOf(0) }
    ObservadorDelCiclo(alPararse = { pausas++ })
    TituloDeLaActividad(estado.marcador)

Y la llamada a MarcadorContent pierde el interruptor de Recoger un Flow con el ciclo de vida y manda los eventos al ViewModel:

app/src/main/java/com/sebastiannarvaez/marcador/feature/marcador/ui/PantallaMarcador.kt
        MarcadorContent(
            estado = estado,
            onGol = viewModel::golAMano,
            modifier = Modifier.padding(paddingValues),
            extras = {
                PanelDelCronometro(
                    minuto, corriendo, ultimoAviso, pausas,
                    onEmpezar = viewModel::empezar,
                )
                ComparacionDeEstado()
                OrdenDeLosModifiers()
                InsigniaSobreEscudo()
            },
        )

La vista previa (@Preview) de esta pantalla ahora arranca un ViewModel de verdad. Si el panel de vistas previas de Android Studio muestra «Render Problem» (un mensaje de error en rojo en lugar de la pantalla) o la deja vacía, ignóralo hasta la lección de UiState: no afecta a la app, y esa lección rehace la vista previa para que no lo necesite.

Qué hace cada pieza

  • Un solo reloj. El bucle de empezar es la única fuente del tiempo: en cada minuto aplica los goles del guion y calcula el aviso con ese mismo marcador. Por eso el aviso de cada 15 minutos no va un gol por detrás.
  • Estado privado, lectura pública. golesAMano se escribe con update dentro del ViewModel; la pantalla solo recibe StateFlow y avisa con golAMano. La pantalla no sabe que existe un partido en vivo.
  • pausas se queda en la pantalla. Cuenta cuántas veces se oculta la pantalla: es estado de la pantalla, cabe en un Int y rememberSaveable lo guarda.
  • Las constantes. MS_POR_MINUTO = 250 son los milisegundos reales que dura un minuto de partido; MINUTOS_DEL_PARTIDO = 90 ya existía en Efectos.kt (ver «Piezas nuevas»).

Comprueba que el fallo desapareció

Ejecuta la app con Run, con la variante gratisDebug (Gradle: flavors y variantes) y el emulador arrancado (La Activity). Pulsa Empezar partido y espera a que aparezca algún gol (el primero llega a los pocos segundos). Después gira el móvil como en Rotación y muerte del proceso: con los botones de giro de la barra pegada a la ventana del emulador o, si no los encuentras, con estas dos órdenes en la Terminal de Android Studio (adb shell ejecuta una orden dentro del emulador):

adb shell settings put system accelerometer_rotation 0
adb shell settings put system user_rotation 1

La primera línea desactiva la rotación automática y la segunda fija la orientación horizontal. Ahora el minuto sigue subiendo y el marcador conserva los goles; antes volvía a 0-0 y 0'. Vuelve a vertical con adb shell settings put system user_rotation 0.

Por qué funciona: al girar, la Activity y la composición se destruyen, pero viewModel() devuelve la misma instancia y su corrutina, lanzada en viewModelScope, no se ha cancelado. La pantalla nueva vuelve a recoger los StateFlow y encuentra el estado donde estaba.

Punto de control

  • Los dos ficheros de Gradle no han cambiado en esta lección: la librería ya estaba de la lección anterior.
  • La app compila y se ejecuta sin errores en rojo (si prefieres comprobarlo en la Terminal de Android Studio, ./gradlew :app:assembleGratisDebug termina en BUILD SUCCESSFUL).
  • «Empezar partido» arranca el reloj y el botón pasa a «En juego», como antes.
  • Con el partido en marcha, girar el móvil no lo reinicia: el minuto y el marcador siguen. El interruptor entre collectAsState y collectAsStateWithLifecycle ya no está.
  • PantallaMarcador.kt ya no contiene remember { MutableStateFlow(…) } ni ningún LaunchedEffect del partido: todo eso está en PartidoViewModel.kt.

Comprueba lo que entendiste

5 preguntas · necesitas 4 para completar la lección

  1. 1.En PartidoViewModel, el minuto está en un MutableStateFlow privado y se expone como un StateFlow. ¿Por qué?

  2. 2.El marcador se crea con combine(golesEnVivo, golesAMano) { … }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), Marcador(0, 0)). ¿Qué consigue WhileSubscribed(5_000) al girar el móvil?

  3. 3.¿Qué es viewModel::empezar cuando se le pasa a un botón?

  4. 4.El usuario pulsa «Empezar partido» dos veces. ¿Qué evita que arranquen dos relojes?

  5. 5.En esta lección, pausas (las veces que la pantalla pasa a segundo plano) se queda en rememberSaveable y no pasa al ViewModel. ¿Por qué?

Faltan 5 de 5