UiState en Marcador: el ViewModel
Al terminar: Mover el estado de la pantalla a un UiState dentro del ViewModel de Marcador.
Al terminar esta lección, el ViewModel produce el MarcadorUiState y la pantalla lo recoge. Verás el indicador de carga al elegir un partido, un mensaje de error con un botón para volver cuando el partido no existe, y la pantalla normal del partido cuando todo va bien.
Importa porque alguien tiene que juntar lo que se lee una vez (el partido elegido) con lo que cambia sin parar (el reloj), y hacerlo en un solo sitio para que el minuto y el marcador lleguen siempre juntos. Temas: un estado de carga privado, flatMapLatest y combine para producir el UiState, y alEvento como única puerta de los eventos.
Por qué hacen falta dos tipos sellados
El ViewModel de esta lección tiene dos sealed interface de tres variantes, y no son lo mismo:
Carga (privada, dentro del ViewModel) | MarcadorUiState (pública) | |
|---|---|---|
| Responde a | ¿cómo va la lectura del partido elegido? | ¿qué dibuja la pantalla? |
| Variantes | EnCurso, Lista(id, partido), Fallo(motivo) | Cargando, Exito(…), Error(mensaje) |
| Cambia | dos veces por partido elegido (EnCurso y luego Lista o Fallo) | cada 250 ms mientras corre el reloj |
Exito necesita el partido (de Carga.Lista) y el minuto, el marcador y el aviso (que cambian con el reloj). Alguien tiene que juntar lo que se lee una vez con lo que se actualiza sin parar: es el trabajo de uiState, que lo hace en tres pasos:
- Mira cómo va la carga.
uiStateparte decarga, el flujo privado que diceEnCurso,ListaoFallo. - Elige un flujo según el caso, con
flatMapLatest. Cada vez quecargacambia,flatMapLatestabandona el flujo anterior y pasa al que toca: conEnCursoemiteCargando; conFallo, unError. - Si la carga está
Lista, junta todo concombine. Une el partido con los flujos del reloj (minuto, marcador, aviso) en unExitoque se renueva a cada cambio.
Sin Carga, el ViewModel guardaría un partido: Partido? y un error: String? sueltos: las banderas del error típico, dentro del ViewModel. La pantalla nunca ve Carga.
Piezas nuevas del ViewModel:
sealed interfaceprivada dentro de la clase: esCarga.MutableStateFlow<Carga>(Carga.EnCurso): aquí el tipo va escrito entre<>a propósito. Sin él, Kotlin deduciríaMutableStateFlow<Carga.EnCurso>(el tipo exacto del valor inicial), y entonces no podrías guardar después unCarga.Listani unCarga.Fallo. Con<Carga>le dices que el flujo guarda cualquiera de las tres variantes.init { … }(el de Clases y objetos): aquí arranca la primera carga con el partido que había guardado en elSavedStateHandle.flowOf(valor)(de Operadores de Flow): un flujo que emite ese valor y termina.flatMapLatest { … }pide@OptIn(ExperimentalCoroutinesApi::class): el aviso de que la API puede cambiar (el mismo@OptInque pusiste aflatMapLatesten flatMapLatest y debounce).x as? T: la conversión segura, que danullen lugar de fallar sixno es de ese tipo; con?: returnse sale de la función cuando esnull(el?:de Nulabilidad). Un ejemplo:val paso = carga.value as? Carga.Lista ?: returnguarda enpasola carga si estáLista; si está en cualquier otro estado,as?danully?: returntermina la función sin hacer nada.lista.firstOrNull { … }: el primer elemento que cumple la condición, onullsi ninguno.
Nada que instalar: flatMapLatest y flowOf vienen de las corrutinas que declaraste en :app en El hilo principal y el ANR.
Todo eso junto, en una pantalla de perfil inventada (no es PartidoViewModel): una carga privada, un uiState que elige flujo, un init y un único punto de entrada de eventos.
private data class Usuario(val id: Int, val nombre: String) // datos de mentira para el ejemplo
private val usuarios = listOf(Usuario(1, "Ana"), Usuario(2, "Luis"))
sealed interface PerfilUiState { // lo que la pantalla puede mostrar
data object Cargando : PerfilUiState
data class Exito(val nombre: String, val seguidores: Int) : PerfilUiState
data class Error(val mensaje: String) : PerfilUiState
}
sealed interface PerfilEvento { // lo que la pantalla puede pedir
data object Recargar : PerfilEvento
data class Abrir(val usuarioId: Int) : PerfilEvento
}
class PerfilViewModel : ViewModel() {
private sealed interface Carga { // privada: la pantalla nunca la ve
data object EnCurso : Carga
data class Lista(val nombre: String) : Carga
data class Fallo(val motivo: String) : Carga
}
private val carga = MutableStateFlow<Carga>(Carga.EnCurso) // <Carga>: admite las tres variantes, no solo EnCurso
private val seguidores = MutableStateFlow(0) // un dato que cambia aparte
@OptIn(ExperimentalCoroutinesApi::class) // flatMapLatest es experimental
val uiState: StateFlow<PerfilUiState> = carga.flatMapLatest { paso -> // cada cambio de carga abandona el flujo anterior
when (paso) {
Carga.EnCurso -> flowOf(PerfilUiState.Cargando) // flowOf: emite ese valor y termina
is Carga.Fallo -> flowOf(PerfilUiState.Error(paso.motivo))
is Carga.Lista -> seguidores.map { n -> PerfilUiState.Exito(paso.nombre, n) } // se renueva con cada cambio
}
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), PerfilUiState.Cargando) // a StateFlow, con valor inicial
init { // se ejecuta una vez, al crear el ViewModel
abrir(1)
}
fun alEvento(evento: PerfilEvento) = when (evento) { // una sola puerta para todos los eventos
PerfilEvento.Recargar -> abrir(1)
is PerfilEvento.Abrir -> abrir(evento.usuarioId)
}
private fun abrir(id: Int) {
carga.value = Carga.EnCurso
viewModelScope.launch {
val usuario = usuarios.firstOrNull { it.id == id } // el primero que cumple, o null
carga.value = if (usuario == null) Carga.Fallo("No existe el usuario $id") else Carga.Lista(usuario.nombre)
}
}
}
@Composable
fun PerfilContenido(estado: PerfilUiState, onEvento: (PerfilEvento) -> Unit) { // sin estado: recibe y avisa
when (estado) {
PerfilUiState.Cargando -> CircularProgressIndicator() // el indicador circular de Material 3
is PerfilUiState.Exito -> Text("${estado.nombre}: ${estado.seguidores} seguidores")
is PerfilUiState.Error -> Button(onClick = { onEvento(PerfilEvento.Recargar) }) { Text(estado.mensaje) }
}
}Reto · Ejercicio: un UiState que sale de la carga
Fuera de tu proyecto, en el Kotlin Playground.
Modela dos tipos cerrados. El primero es la carga de un jugador: en curso, lista con un nombre, o fallo con un motivo. El segundo, el estado de su pantalla: cargando, éxito con nombre y goles, o error con un mensaje. Con un flujo de estado para la carga (empieza en curso) y otro para los goles (empieza en 0), construye un único flujo con el estado de la pantalla (no hace falta convertirlo en un flujo de estado): cuando la carga está lista, se renueva con cada gol; si la carga cambia, deja de escuchar los goles. Recógelo e imprime cada estado.
Desde main, con 50 ms entre cada paso: la carga pasa a lista con «Ana», los goles a 1, luego a 2, la carga pasa a fallo «sin red», los goles a 3, y cancela la recogida. La consola debe mostrar:
Cargando
Exito(nombre=Ana, goles=0)
Exito(nombre=Ana, goles=1)
Exito(nombre=Ana, goles=2)
Error(mensaje=sin red)¿Por qué el gol 3 no imprime nada?
Ver solución
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
sealed interface Carga {
data object EnCurso : Carga
data class Lista(val nombre: String) : Carga
data class Fallo(val motivo: String) : Carga
}
sealed interface JugadorUiState {
data object Cargando : JugadorUiState
data class Exito(val nombre: String, val goles: Int) : JugadorUiState
data class Error(val mensaje: String) : JugadorUiState
}
@OptIn(ExperimentalCoroutinesApi::class)
fun main() = runBlocking {
val carga = MutableStateFlow<Carga>(Carga.EnCurso)
val goles = MutableStateFlow(0)
val uiState: Flow<JugadorUiState> = carga.flatMapLatest { paso ->
when (paso) {
Carga.EnCurso -> flowOf(JugadorUiState.Cargando)
is Carga.Fallo -> flowOf(JugadorUiState.Error(paso.motivo))
is Carga.Lista -> goles.map { JugadorUiState.Exito(paso.nombre, it) }
}
}
val pantalla = launch { uiState.collect { println(it) } }
delay(50); carga.value = Carga.Lista("Ana")
delay(50); goles.value = 1
delay(50); goles.value = 2
delay(50); carga.value = Carga.Fallo("sin red")
delay(50); goles.value = 3
delay(50); pantalla.cancel()
}flatMapLatest abandonó el flujo de los goles en cuanto la carga pasó a fallo: el gol 3 se escribe en un flujo al que ya nadie escucha. Y si añades una variante nueva al estado de pantalla, el compilador te obliga a tratarla en cada when.
Paso · el ViewModel produce el MarcadorUiState
Objetivo. El ViewModel expone un único uiState: StateFlow<MarcadorUiState> y un único punto de entrada de eventos, alEvento. PantallaMarcador recoge ese estado y se lo pasa a PantallaMarcadorContenido. Al terminar ves el indicador de carga, el error con su botón y, si todo va bien, la pantalla normal del partido.
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/… 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):
PartidoViewModel.kt: es el cambio grande. De la lección anterior se quedan tres cosas: la líneaclass PartidoViewModel(private val estadoGuardado: SavedStateHandle) : ViewModel() {,val partidoIdy elcompanion objectdel final. Todo lo demás de la clase se borra: todas las propiedades y funciones, privadas o no (guion,partido,golesEnVivo,minuto,minutoActual,corriendo,enMarcha,ultimoAviso,avisoActual,marcador,golesAMano,trabajos,golAMano,seleccionaryempezar). Si tu fichero tiene alguna con otro nombre, también se va: lo único que sobrevive son esas tres cosas. Borra tambiénguionDe, que está justo antes delcompanion object: ya no la llama nadie (si la dejas, compila, pero Android Studio la pinta en gris). Lo borrado lo reemplazan dos bloques, en este orden: el que escribes tú (Carga,uiState,inityalEvento) y el que pegas (golAMano,cargaryempezar, que ahora son privados). Los imports se sustituyen por la lista del primer bloque.AppMarcador.kt: en la rama1 ->, dentro deonElegir = { id -> … }, la líneaviewModel.seleccionar(id)pasa a mandar el evento.PantallaMarcador.kt: la funciónPantallaMarcadorde antes (desde su@OptInhasta su llave de cierre) se sustituye por la del bloque, la que recoge el estado.PantallaMarcadorContenido, justo debajo, ya la tienes de la lección anterior y no cambia.
Intenta escribirlo tú.
- La pantalla con estado:
PantallaMarcadorrecoge el estado delViewModely le manda los eventos, y entrega todo aPantallaMarcadorContenido. - En el
ViewModel, el paso de la carga, privado (en curso, lista con el id y el partido, o fallo con un motivo); el estado de la pantalla, que sale de ese paso (y, cuando está lista, de los goles, el minuto, si corre y el aviso); la carga del partido guardado al crearse; yalEvento, la única puerta para los tres eventos. Lo que pegas después (cargar,empezar,golAMano) es lo mismo que ya tenías, reorganizado, con una espera artificial de 400 ms encargarpara que se vea «cargando». Debes ver el spinner unos 0,4 segundos al elegir un partido, y «No existe el partido n.º 99» con «Provocar error».
Intenta primero. Cuando termines, compara con la solución.Mostrar el código
PartidoViewModel.kt. Los imports: sustituyen a todos los de arriba del fichero.
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.sebastiannarvaez.marcador.feature.partidos.data.DatosDeEjemplo
import com.sebastiannarvaez.marcador.torneo.Marcador
import com.sebastiannarvaez.marcador.torneo.Partido
import com.sebastiannarvaez.marcador.torneo.despuesDe
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launchEl bloque que escribes, a continuación de val partidoId: la carga privada, los estados sueltos, el uiState, el init y alEvento.
private sealed interface Carga {
data object EnCurso : Carga
data class Lista(val id: Int, val partido: Partido) : Carga
data class Fallo(val motivo: String) : Carga
}
private val carga = MutableStateFlow<Carga>(Carga.EnCurso)
private val minutoActual = MutableStateFlow(0)
private val enMarcha = MutableStateFlow(false)
private val avisoActual = MutableStateFlow("ninguno")
private val golesAMano = MutableStateFlow(Marcador(0, 0))
private val golesEnVivo = MutableStateFlow(Marcador(0, 0))
private var trabajos: List<Job> = emptyList()
@OptIn(ExperimentalCoroutinesApi::class)
val uiState: StateFlow<MarcadorUiState> = carga.flatMapLatest { paso ->
when (paso) {
Carga.EnCurso -> flowOf(MarcadorUiState.Cargando)
is Carga.Fallo -> flowOf(MarcadorUiState.Error(paso.motivo))
is Carga.Lista -> combine(
golesEnVivo, golesAMano, minutoActual, enMarcha, avisoActual,
) { vivo, mano, minuto, corriendo, aviso ->
MarcadorUiState.Exito(
partidoId = paso.id,
partido = paso.partido,
marcador = Marcador(vivo.local + mano.local, vivo.visitante + mano.visitante),
minuto = minuto,
corriendo = corriendo,
ultimoAviso = aviso,
)
}
}
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), MarcadorUiState.Cargando)
init {
cargar(partidoId.value)
}
fun alEvento(evento: MarcadorEvento) = when (evento) {
MarcadorEvento.Empezar -> empezar()
is MarcadorEvento.Gol -> golAMano(evento.lado)
is MarcadorEvento.Elegir -> cargar(evento.partidoId)
}El bloque que pegas, justo después de alEvento. cargar hace dos cosas que merecen una frase: lo pone todo a cero y pasa siempre por EnCurso, con un delay(400) artificial (en la lección de Room la lectura será de verdad) para que se vea el indicador de carga; y solo guarda el id en el handle si el partido existe. empezar solo arranca si la carga está Lista.
private fun golAMano(lado: Lado) = golesAMano.update {
when (lado) {
Lado.LOCAL -> it.copy(local = it.local + 1)
Lado.VISITANTE -> it.copy(visitante = it.visitante + 1)
}
}
private fun cargar(id: Int) {
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)
carga.value = Carga.EnCurso
viewModelScope.launch {
delay(400)
val elegido = DatosDeEjemplo.partidos.firstOrNull { it.id == id }
if (elegido == null) {
carga.value = Carga.Fallo("No existe el partido n.º $id")
} else {
estadoGuardado[CLAVE_PARTIDO] = id
carga.value = Carga.Lista(id, elegido.partido)
}
}
}
private fun empezar() {
val lista = carga.value as? Carga.Lista ?: return
if (enMarcha.value || minutoActual.value > 0) return
enMarcha.value = true
val guion = lista.partido
trabajos = listOf(
viewModelScope.launch {
while (minutoActual.value < MINUTOS_DEL_PARTIDO) {
delay(MS_POR_MINUTO)
val nuevo = minutoActual.value + 1
golesEnVivo.update { antes ->
guion.eventos.filter { it.minuto == nuevo }.fold(antes) { m, evento -> m.despuesDe(evento, guion) }
}
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
},
)
}AppMarcador.kt, dentro de onElegir = { id -> … }. MarcadorEvento es de otra carpeta, así que arriba del fichero añade import com.sebastiannarvaez.marcador.feature.marcador.ui.MarcadorEvento (o pulsa Option+Enter sobre el nombre en rojo y elige ese import):
viewModel.alEvento(MarcadorEvento.Elegir(id))PantallaMarcador.kt. La función con estado sustituye a la PantallaMarcador de antes (desde su @OptIn hasta su llave de cierre):
@Composable
fun PantallaMarcador(
onVerDemos: () -> Unit,
modifier: Modifier = Modifier,
viewModel: PartidoViewModel = viewModel(),
) {
val estado by viewModel.uiState.collectAsStateWithLifecycle()
var pausas by rememberSaveable { mutableIntStateOf(0) }
ObservadorDelCiclo(alPararse = { pausas++ })
PantallaMarcadorContenido(estado, pausas, viewModel::alEvento, onVerDemos, modifier)
}alEvento es la única puerta: la pantalla no llama a golAMano, empezar ni cargar, manda Gol, Empezar o Elegir, y el ViewModel decide. Como combine emite un Exito completo cada vez que cambia cualquiera de los cinco flujos, el minuto y el marcador llegan siempre juntos. Y el id solo se guarda si es válido: si se guardara el 99, la app volvería a abrirse en la pantalla de error tras un am kill.
Verás las tres variantes
Ejecuta la app con Run, con la variante gratisDebug elegida en View → Tool Windows → Build Variants. En Partidos, toca una fila: durante unos 0,4 segundos verás el CircularProgressIndicator y luego la pantalla del partido (Exito). Pulsa Provocar error (partido 99): aparece «No existe el partido n.º 99» en color de error y el botón «Volver al partido 1», que lleva de nuevo a Exito.
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). - Al elegir un partido en la lista se ve brevemente el indicador de carga y después el marcador con «Partido n.º N».
- «Provocar error (partido 99)» muestra «No existe el partido n.º 99»; «Volver al partido 1» lo recupera.
- El partido y el reloj funcionan como antes: «Empezar partido», goles a mano, giro sin reinicio.
PartidoViewModel.ktya no tiene propiedades públicas sueltas: solopartidoId,uiStateyalEvento.
Comprueba lo que entendiste
4 preguntas · necesitas 3 para completar la lección
1.En el flujo unidireccional de datos, ¿qué viaja hacia arriba, de la pantalla al ViewModel?
2.En
PartidoViewModel, ¿por quéCargaes unasealed interfaceprivada?3.La carga pasa de
ListaaFallomientras corre el reloj. ¿Qué haceflatMapLatestenuiState?4.
cargarsolo guarda el id del partido en elSavedStateHandlesi el partido existe. ¿Por qué?