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.
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 cambiaUn 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.
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
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.
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()yviewModel(): 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ónviewModel()de la librería que instalaste en la lección anterior. Llamarla así es una costumbre; podrías llamar a la variablevmy funcionaría igual.MINUTOS_DEL_PARTIDO: la constante90que ya tienes enEfectos.kt. Está en el mismo paquetefeature.marcador.uiquePartidoViewModel, así que no necesitaimport; el bucle deempezarcuenta hasta ella.private val a = MutableStateFlow(…)yval 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 unStateFlow(combinede Flow: scan y combine ystateInde stateIn, shareIn y los flujos calientes); conWhileSubscribed(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 }: uncompanion objectes el rincón de una clase donde van las cosas que no pertenecen a un objeto concreto, como una constante;const valla marca como número fijo, y dentro de la clase se usa por su nombre,MS_POR_MINUTO.update,foldydespuesDe: 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íneaimport 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,Marcadorse queda: la tarjeta del partido lo usa. - El estado: dentro de
PantallaMarcador, borra todo desdeval partido = Ejemplo.rayoContraToroshasta la líneaTituloDeLaActividad(estado.marcador)incluida, y escribe en su lugar el bloque que empieza porval viewModel: PartidoViewModel = viewModel(). Es la parte que escribiste en Recoger un Flow con el ciclo de vida conremember, el interruptor, elonEachy el reloj. MarcadorContent(…): el último bloque, el que empieza porMarcadorContent(, sustituye a toda la llamada, desdeMarcadorContent(hasta su)de cierre. Pierde elSwitchcon suTexty manda los eventos al ViewModel conviewModel::golAManoyviewModel::empezar.MS_POR_MINUTO: borra la líneaprivate const val MS_POR_MINUTO = 250L, que ahora vive en el ViewModel.RelojDelPartido, deEfectos.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):
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:
import androidx.lifecycle.viewmodel.compose.viewModelEl estado de la pantalla, que sustituye a todo lo que borraste:
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:
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
empezares 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.
golesAManose escribe conupdatedentro del ViewModel; la pantalla solo recibeStateFlowy avisa congolAMano. La pantalla no sabe que existe un partido en vivo. pausasse queda en la pantalla. Cuenta cuántas veces se oculta la pantalla: es estado de la pantalla, cabe en unIntyrememberSaveablelo guarda.- Las constantes.
MS_POR_MINUTO = 250son los milisegundos reales que dura un minuto de partido;MINUTOS_DEL_PARTIDO = 90ya existía enEfectos.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 1La 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:assembleGratisDebugtermina enBUILD 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
collectAsStateycollectAsStateWithLifecycleya no está. PantallaMarcador.ktya no contieneremember { MutableStateFlow(…) }ni ningúnLaunchedEffectdel partido: todo eso está enPartidoViewModel.kt.
Comprueba lo que entendiste
5 preguntas · necesitas 4 para completar la lección
1.En
PartidoViewModel, el minuto está en unMutableStateFlowprivado y se expone como unStateFlow. ¿Por qué?2.El marcador se crea con
combine(golesEnVivo, golesAMano) { … }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), Marcador(0, 0)). ¿Qué consigueWhileSubscribed(5_000)al girar el móvil?3.¿Qué es
viewModel::empezarcuando se le pasa a un botón?4.El usuario pulsa «Empezar partido» dos veces. ¿Qué evita que arranquen dos relojes?
5.En esta lección,
pausas(las veces que la pantalla pasa a segundo plano) se queda enrememberSaveabley no pasa al ViewModel. ¿Por qué?