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

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?
VariantesEnCurso, Lista(id, partido), Fallo(motivo)Cargando, Exito(…), Error(mensaje)
Cambiados 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:

  1. Mira cómo va la carga. uiState parte de carga, el flujo privado que dice EnCurso, Lista o Fallo.
  2. Elige un flujo según el caso, con flatMapLatest. Cada vez que carga cambia, flatMapLatest abandona el flujo anterior y pasa al que toca: con EnCurso emite Cargando; con Fallo, un Error.
  3. Si la carga está Lista, junta todo con combine. Une el partido con los flujos del reloj (minuto, marcador, aviso) en un Exito que 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 interface privada dentro de la clase: es Carga.
  • MutableStateFlow<Carga>(Carga.EnCurso): aquí el tipo va escrito entre <> a propósito. Sin él, Kotlin deduciría MutableStateFlow<Carga.EnCurso> (el tipo exacto del valor inicial), y entonces no podrías guardar después un Carga.Lista ni un Carga.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 el SavedStateHandle.
  • 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 @OptIn que pusiste a flatMapLatest en flatMapLatest y debounce).
  • x as? T: la conversión segura, que da null en lugar de fallar si x no es de ese tipo; con ?: return se sale de la función cuando es null (el ?: de Nulabilidad). Un ejemplo: val paso = carga.value as? Carga.Lista ?: return guarda en paso la carga si está Lista; si está en cualquier otro estado, as? da null y ?: return termina la función sin hacer nada.
  • lista.firstOrNull { … }: el primer elemento que cumple la condición, o null si 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.

ejemplo mínimo: un ViewModel con UiState y eventos (con comentarios)
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
solución del reto
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.

app → kotlin+java → com.sebastiannarvaez.marcador → feature → marcador → ui
PartidoViewModel.ktse modifica
PantallaMarcador.ktse modifica
app → kotlin+java → com.sebastiannarvaez.marcador → ui
AppMarcador.ktse modifica
Tres ficheros que ya tienes, en la vista Android.

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ínea class PartidoViewModel(private val estadoGuardado: SavedStateHandle) : ViewModel() {, val partidoId y el companion object del 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, seleccionar y empezar). Si tu fichero tiene alguna con otro nombre, también se va: lo único que sobrevive son esas tres cosas. Borra también guionDe, que está justo antes del companion 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, init y alEvento) y el que pegas (golAMano, cargar y empezar, que ahora son privados). Los imports se sustituyen por la lista del primer bloque.
  • AppMarcador.kt: en la rama 1 ->, dentro de onElegir = { id -> … }, la línea viewModel.seleccionar(id) pasa a mandar el evento.
  • PantallaMarcador.kt: la función PantallaMarcador de antes (desde su @OptIn hasta 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ú.

  1. La pantalla con estado: PantallaMarcador recoge el estado del ViewModel y le manda los eventos, y entrega todo a PantallaMarcadorContenido.
  2. 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; y alEvento, 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 en cargar para 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.

app/src/main/java/com/sebastiannarvaez/marcador/feature/marcador/ui/PartidoViewModel.kt
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.launch

El bloque que escribes, a continuación de val partidoId: la carga privada, los estados sueltos, el uiState, el init y alEvento.

app/src/main/java/com/sebastiannarvaez/marcador/feature/marcador/ui/PartidoViewModel.kt
    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.

app/src/main/java/com/sebastiannarvaez/marcador/feature/marcador/ui/PartidoViewModel.kt
    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):

app/src/main/java/com/sebastiannarvaez/marcador/ui/AppMarcador.kt
                    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):

app/src/main/java/com/sebastiannarvaez/marcador/feature/marcador/ui/PantallaMarcador.kt
@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:assembleGratisDebug termina en BUILD 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.kt ya no tiene propiedades públicas sueltas: solo partidoId, uiState y alEvento.

Comprueba lo que entendiste

4 preguntas · necesitas 3 para completar la lección

  1. 1.En el flujo unidireccional de datos, ¿qué viaja hacia arriba, de la pantalla al ViewModel?

  2. 2.En PartidoViewModel, ¿por qué Carga es una sealed interface privada?

  3. 3.La carga pasa de Lista a Fallo mientras corre el reloj. ¿Qué hace flatMapLatest en uiState?

  4. 4.cargar solo guarda el id del partido en el SavedStateHandle si el partido existe. ¿Por qué?

Faltan 4 de 4