Estado de toda la app: el partido en juego
Al terminar: Entender qué es el estado de toda la app y guardar el partido en juego en una sola pieza que viva en toda la app.
Al terminar esta lección, Marcador tendrá una pieza nueva, PartidoEnJuego, que guarda el partido que se está jugando en un solo sitio, con una sola copia que vive lo que vive la app. Todavía no la usa ninguna pantalla, así que la app se ve igual que antes; lo que ganas es la pieza, sus cinco pruebas y la idea que hay detrás: el estado de toda la app.
Importa porque hoy el partido tiene un fallo que se nota en cuanto sales de su pantalla: se para y se pierde. Y porque, antes de arreglarlo, hay que saber cuándo un dato merece vivir en toda la app y cuándo no (casi nunca lo merece). Temas: qué es el estado de toda la app y cuándo no usarlo; una sola instancia con @Singleton; estado privado que se cambia por dentro y se lee por fuera; por qué no un CompositionLocal propio; y probar una pieza con backgroundScope, el alcance de la app de una prueba.
El fallo de hoy: el partido muere con su pantalla
Compruébalo en tu app. Ejecútala (variante gratisDebug), abre el partido 1 y pulsa «Empezar partido». Espera a que el minuto pase de 10 y pulsa «atrás». Vuelve a abrir el partido 1: el minuto está en 0 y el botón «Empezar partido» vuelve a estar disponible. El partido se ha perdido.
Pasa porque el reloj corre en viewModelScope, el alcance que cancela el ViewModel al irse su pantalla (ViewModel: qué sobrevive). El partido tiene una pantalla por dueña, y cuando la dueña se va, se lleva el partido.
Queremos otra cosa: que el partido siga aunque salgas, que se vea en otras pantallas (una barra de abajo, en todas las pestañas, con el marcador en vivo) y que solo pueda haber uno a la vez. Para eso el partido no puede ser de una pantalla: tiene que ser de la app.
Qué es el estado de toda la app
El estado es lo que una app recuerda en un momento dado: el minuto, el marcador, el texto de un campo. El estado de toda la app (a veces se le llama «estado global») es un dato que varias pantallas leen a la vez y que vive en una sola instancia, fuera de cualquier pantalla. En Marcador ya tenías uno: el repositorio de partidos.
Tiene tres ingredientes:
| Ingrediente | Cómo se hace |
|---|---|
| Una sola copia | @Singleton en su receta de Hilt: una instancia mientras viva el proceso |
| Un solo dueño que lo cambia | El dato se guarda en un MutableStateFlow privado; hacia fuera solo sale un StateFlow de lectura |
| Las pantallas lo observan | Cada pantalla recoge el StateFlow y se repinta sola; ninguna guarda su propia copia |
Una advertencia con la palabra «alcance», que en esta lección significa dos cosas distintas. En la cita de Google es el scope de Hilt: cuánto tiempo guarda el contenedor una copia (@Singleton: mientras viva la app). En el resto de la lección, «el alcance de la app» es un CoroutineScope: el que decide hasta cuándo puede correr una corrutina (Alcance, Job y cancelación). Las dos cosas se apoyan en la misma idea (algo que dura lo que la app), pero no son lo mismo.
Cuándo NO usarlo
Guardar un dato en toda la app tiene un precio: una pieza @Singleton no se libera mientras el proceso viva, la puede tocar cualquier pantalla y, si se usa de más, ya no sabes quién la cambió. Por eso la regla es guardar cada dato lo más abajo posible.
Una escalera de tres peldaños: empieza por el de abajo y sube solo si hace falta.
| Peldaño | Cuándo | Ejemplo en Marcador |
|---|---|---|
remember (en el composable) | Solo lo usa esa pieza de pantalla | Si un desplegable está abierto |
El ViewModel de la pantalla | Lo usa una pantalla y debe sobrevivir a girar el móvil | Los textos del formulario de añadir jugador |
Toda la app (@Singleton) | Otras pantallas lo leen a la vez y debe seguir vivo cuando la suya se cierra | El partido que se está jugando |
El tercer peldaño es el más caro de los tres: por eso se sube a él solo cuando hace falta.
La pregunta para decidir es una sola, con dos mitades: «¿lo necesita otra pantalla a la vez, y tiene que seguir vivo cuando esta se cierre?». Si las dos respuestas son sí, es de la app. Con una sola, hay una solución más barata:
- Lo leen varias piezas a la vez, pero mueren con su pantalla. El texto de un buscador que leen la lista de resultados y el contador «12 resultados», los dos dentro de la misma pantalla: basta el
ViewModelde esa pantalla. - Tiene que seguir vivo al cerrar la pantalla, pero solo lo lee una. Las notas de una app de notas: tienen que seguir ahí aunque cierres la app, pero solo las lee la pantalla de notas. No hace falta guardarlas en memoria en una pieza nueva: basta la base de datos, a través del repositorio que ya tienes (como los partidos con Room).
Por qué no un CompositionLocal propio
Hay otra forma de que muchas pantallas lean lo mismo sin pasarlo de una en una: un CompositionLocal, el mecanismo con el que MaterialTheme reparte sus colores (Compose: Scaffold, slots y tema). Tienta, porque cualquier composable lo lee sin recibirlo por parámetro. Pero Google lo desaconseja para los datos de una app:
Una dependencia implícita es una que no se ve en el nombre ni en los parámetros de la función (lo que se llama su «firma»): para saber qué necesita un composable tendrías que leer su cuerpo entero. Y tampoco queda claro quién es el dueño del dato. Por eso en Marcador el camino será otro: la pieza vive en la capa de dominio, Hilt se la da a los ViewModel por el constructor, y las pantallas reciben el estado por parámetros, como siempre.
Ejemplo: un reproductor de música
Con datos ajenos a Marcador: la canción que suena. Una lista de canciones y un mini reproductor de abajo la leen a la vez, y la canción vive en un Reproductor. Pruébalo en el Kotlin Playground (play.kotlinlang.org):
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
data class Cancion(val titulo: String, val artista: String) // una canción: dos textos
class Reproductor { // el DUEÑO del estado
private val sonando = MutableStateFlow<Cancion?>(null) // privado: solo el reproductor lo cambia (null = nada suena)
val actual: StateFlow<Cancion?> = sonando.asStateFlow() // hacia fuera, solo lectura
fun reproducir(cancion: Cancion) { sonando.value = cancion } // la única forma de poner una canción
fun parar() { sonando.value = null } // y de quitarla
}
fun main() = runBlocking {
val reproductor = Reproductor() // UNA instancia...
// ...y dos «pantallas» que la observan a la vez (cada launch es una pantalla)
val lista = launch {
reproductor.actual.collect { println("Lista: sonando ${it?.titulo ?: "nada"}") }
}
val barra = launch {
reproductor.actual.collect { cancion ->
val texto = if (cancion == null) "oculto" else "${cancion.titulo} · ${cancion.artista}"
println("Mini reproductor: $texto")
}
}
delay(10) // deja que las dos empiecen a mirar
reproductor.reproducir(Cancion("Imagine", "John Lennon")) // alguien toca una canción
delay(10)
reproductor.parar() // y la para
delay(10)
lista.cancel() // las pantallas se van
barra.cancel()
}Lista: sonando nada
Mini reproductor: oculto
Lista: sonando Imagine
Mini reproductor: Imagine · John Lennon
Lista: sonando nada
Mini reproductor: ocultoLas dos «pantallas» ven lo mismo y a la vez porque observan el mismo objeto. Si cada una hubiera creado su propio Reproductor(), la lista diría «Imagine», pero el mini reproductor seguiría en «oculto»: la canción se habría puesto en el reproductor de la lista, y no en el suyo. it?.titulo ?: "nada" es la forma corta de «si hay canción, su título; si no, “nada”» (Nulabilidad).
Reto · Ejercicio: lista de canciones y mini reproductor
En la carpeta ejercicios de tu proyecto. Crea la subcarpeta estado_app: clic derecho sobre com.sebastiannarvaez.marcador (el que no lleva (test) ni (androidTest) detrás) → New → Package → añade .ejercicios.estado_app al final del nombre que aparece ya escrito. Después, clic derecho sobre esa carpeta → New → Kotlin Class/File → File → MiniReproductor. La primera línea debe ser package com.sebastiannarvaez.marcador.ejercicios.estado_app.
Ya vistas: remember y la vista previa interactiva (Estado en Compose), collectAsStateWithLifecycle (Recoger un Flow con el ciclo de vida) y clickable (Compose: composables y modifiers).
Pasa el ejemplo de arriba a pantallas de verdad, con tres piezas en un solo fichero. Para no meter aquí un ViewModel, cada composable recibe el reproductor entero; en Marcador no será así: el ViewModel tendrá la pieza y las pantallas recibirán solo lo que deben pintar.
- Una
Canciony unReproductorcomo los del ejemplo, sinparar: solo guarda qué canción suena y sirve para poner una. ListaDeCanciones: un composable que recibe el reproductor y dibuja tres canciones, «Imagine» (John Lennon), «Yesterday» (The Beatles) y «Hallelujah» (Leonard Cohen), una debajo de otra. Al tocar una, suena.MiniReproductor: un composable que recibe el reproductor y dibuja una línea de texto con la canción que suena.
Añade una vista previa con las dos pantallas, una encima de la otra, que crea un solo Reproductor y se lo da a las dos. Pista: dentro de una vista previa, remember { Reproductor() } crea el objeto una vez y lo conserva mientras la vista previa se repinta; es la copia única que las dos pantallas comparten. En el modo interactivo, la línea de abajo debe decir al principio:
Elige una canciónY al tocar «Imagine»:
Imagine · John LennonVer solución
package com.sebastiannarvaez.marcador.ejercicios.estado_app
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
data class Cancion(val titulo: String, val artista: String)
class Reproductor {
private val sonando = MutableStateFlow<Cancion?>(null)
val actual: StateFlow<Cancion?> = sonando.asStateFlow()
fun reproducir(cancion: Cancion) { sonando.value = cancion }
}
// Una lista fija que no cambia: por convención, las constantes se escriben en mayúsculas.
val CANCIONES = listOf(Cancion("Imagine", "John Lennon"), Cancion("Yesterday", "The Beatles"), Cancion("Hallelujah", "Leonard Cohen"))
@Composable
fun ListaDeCanciones(reproductor: Reproductor, modifier: Modifier = Modifier) {
Column(modifier) {
CANCIONES.forEach { cancion ->
Text(cancion.titulo, Modifier.fillMaxWidth().clickable { reproductor.reproducir(cancion) }.padding(16.dp))
}
}
}
@Composable
fun MiniReproductor(reproductor: Reproductor, modifier: Modifier = Modifier) {
val cancion by reproductor.actual.collectAsStateWithLifecycle()
Text(cancion?.let { "${it.titulo} · ${it.artista}" } ?: "Elige una canción", modifier.fillMaxWidth().padding(16.dp))
}
@Preview(showBackground = true)
@Composable
private fun DosPantallasUnReproductorPreview() {
val reproductor = remember { Reproductor() } // UNA instancia para las dos
MaterialTheme {
Column {
ListaDeCanciones(reproductor)
MiniReproductor(reproductor)
}
}
}El remember { Reproductor() } de la vista previa crea la copia una vez y se la da a las dos pantallas: esa es la «instancia única». Si cada composable hiciera su propio Reproductor(), tocar una canción no cambiaría la línea de abajo. En la app real quien reparte la copia única es Hilt, con @Singleton; aquí lo hace el remember.
Paso · PartidoEnJuego: el partido en una sola pieza
Objetivo. Crear PartidoEnJuego, que guarda el partido que se juega (el marcador, el minuto y si terminó) y no deja empezar otro mientras tanto; entregárselo a Hilt como @Singleton; y escribir sus pruebas. Al terminar, ./gradlew :app:testGratisDebugUnitTest cuenta 32 tests (35 si hiciste el ejercicio del temporizador de Tests de un ViewModel: fakes, setMain y Turbine) y 0 fallos. La app se ve igual: ninguna pantalla pide todavía la pieza.
Crea los ficheros. Clic derecho sobre domain de feature → marcador (donde ya están Directo y Lado) → New → Kotlin Class/File → File → PartidoEnJuego. Va ahí porque lo mantiene la feature marcador y lo leen también la barra y el ViewModel de la app. Los tests copian esa carpeta: clic derecho sobre com.sebastiannarvaez.marcador (test) (el que lleva (test) detrás, donde ya tienes core y feature) → New → Package → añade .feature.marcador.domain al final del nombre que aparece ya escrito, y sobre ese domain → New → Kotlin Class/File → File → PartidoEnJuegoTest.
Qué pasa por dentro. Antes de escribir, el mapa de la pieza:
| Parte | Qué hace |
|---|---|
EnJuego | Una data class con lo que las pantallas necesitan pintar: id, equipos, marcador, minuto y si terminó. Las pantallas no pintan el Directo, sino un EnJuego |
estado / actual | El MutableStateFlow privado y su versión de solo lectura. Vale null cuando no hay partido |
vivo | El Directo que está en juego (o que acaba de terminar) |
observacion | La corrutina que copia el directo a actual. Se guarda (es un Job) para poder cancelarla al empezar otro partido o al cerrar |
empezar(directo) | Arranca el directo en el alcance de la app y copia cada cambio a actual. Devuelve false sin hacer nada si ya hay otro partido sin terminar |
directo(id) | Da el directo de ese partido, si es el que está en juego. Lo usará el ViewModel de la pantalla para seguirlo en vez de crear otro |
cerrar() | Quita el partido, pero solo si ya terminó |
Piezas nuevas de este paso:
data class EnJuego(…)escrita dentro declass PartidoEnJuego { … }: una clase dentro de otra. Se declara entre las llaves de la de fuera y, desde fuera, se nombra con el prefijo,PartidoEnJuego.EnJuego. Así queda claro que solo tiene sentido junto aPartidoEnJuego.class PartidoEnJuego(private val alcance: CoroutineScope): la clase no crea su alcance, se lo prestan por el constructor.Directotambién recibe el suyo, aunque en su funcióniniciar(alcance)(Capas en Marcador: el directo). En la app será el@AlcanceDeApp; en las pruebas, elbackgroundScope.alcance.launch { combine(…) { … }.collect { estado.value = it } }: una corrutina del alcance de la app que mira el marcador y el reloj del directo y, cada vez que cambia uno, escribe unEnJuegonuevo enestado. Devuelve unJob, que se guarda para poder cancelarlo.private val Directo.terminado: Boolean get() = …: una propiedad de extensión, que añade aDirectola pregunta «¿ha terminado?» (Extensiones). Elget() =hace que la respuesta se calcule cada vez que preguntas, con el reloj de ese momento, en vez de guardarse una vez. Yprivatefuera de una clase quiere decir «solo se ve dentro de este fichero». El partido ha terminado cuando su reloj llega a la duración.val anterior = vivo(enempezar):vivoes unvar, así que su valor puede cambiar en cualquier momento, y Kotlin no deja tratarlo como «ya sinnull» después de comprobarlo. Copiarlo a unvalcongela el valor de ese instante, yanterior != null && !anterior.terminadoya es seguro.val anterior = vivo ?: return: sivivoesnull(no hay partido), la función termina ahí sin hacer nada; si no,anteriores ese partido ya sin elnull(Nulabilidad).vivo?.takeIf { it.partidoId == partidoId }: da el directo solo si es el de ese id, ynullsi no (Nulabilidad).- En las pruebas,
backgroundScope: el alcance de la prueba querunTestcancela solo al terminar. Hace el papel del alcance de la app: un partido en juego es una corrutina que no termina nunca por sí sola, y así la prueba no se queda esperándola (Tests con backgroundScope y Turbine). runCurrent()suelto ennoDejaEmpezarOtroMientrasSeJuega: deja que la corrutina del alcance publique el partido 1 enactual. Sin él,actualseguiría ennull(se rellena un instante después, como dice el aviso de más abajo) y la comprobación delpartidoIdfallaría.private fun TestScope.jugarMinutos(n): una función de ayuda que se llama dentro derunTest { }y adelanta el reloj virtual. Mismo+ 1yrunCurrent()que en Tests de un ViewModel: fakes, setMain y Turbine.
Intenta escribirlo tú, en este orden:
PartidoEnJuego.kt, enfeature/marcador/domain(Kotlin puro: ni unimportde Android). Recibe un alcance por el constructor. Guarda unEnJuego?que las pantallas solo pueden leer, y unDirectovivo. Y tres funciones:empezar(directo): si el directo vivo no ha terminado, no hace nada y devuelvefalse. Si no, cancela la observación anterior, guarda el nuevo y lo arranca condirecto.iniciar(alcance). Después lanza en el alcance una corrutina que combina el marcador y el reloj del directo y los copia al estado, y devuelvetrue.directo(id): como en la tabla.cerrar(): como en la tabla.
- La receta en
DatosModule: una función que recibe el alcance con la etiqueta@AlcanceDeAppy entrega unPartidoEnJuego, con una sola copia para toda la app. Es la misma pauta quepartidos(…), justo encima. PartidoEnJuegoTest.kt, con cinco pruebas que usan elbackgroundScopederunTestcomo alcance de la app (lo cancela solo al terminar la prueba) y unDirectopor partido, de 250 ms por minuto:- al empezar y pasar 34 minutos,
actualdice partido 1, minuto 34, marcador 0-0 y sin terminar; - con uno en juego,
empezarde otro devuelvefalse,actualsigue en el partido 1 ydirecto(2)esnull; - al llegar al minuto 90 queda terminado, y entonces
empezarde otro ya devuelvetrue; cerrarcon el partido terminado dejaactualennullydirecto(1)ennull;cerrarmientras se juega (minuto 10) no hace nada.
- al empezar y pasar 34 minutos,
Lo que debes ver: la app igual que antes, y los cinco tests en verde.
Intenta primero. Cuando termines, compara con la solución.Mostrar el código
package com.sebastiannarvaez.marcador.feature.marcador.domain
import com.sebastiannarvaez.marcador.torneo.Marcador
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch
// ESTADO DE TODA LA APP: el partido que se esta jugando.
//
// Lo leen varias pantallas a la vez: la del propio partido, la barra de abajo (en todas las
// pestanas) y la de otro partido (para decir «Ya hay un partido en juego»). Por eso no vive en
// ningun ViewModel: vive aqui, en UNA sola instancia que Hilt guarda en el contenedor de la app
// (@Singleton en DatosModule), y cada pantalla lo OBSERVA.
//
// Es Kotlin puro (capa de dominio): recibe el alcance por el constructor. En la app es el
// alcance de la app (@AlcanceDeApp), que dura lo que el proceso; por eso el partido sigue
// aunque se cierre su pantalla. En las pruebas, el `backgroundScope` de `runTest`.
//
// Regla: UNO A LA VEZ. Mientras un partido no ha terminado, no se puede empezar otro. Al
// terminar, se queda aqui («Final») hasta que alguien lo cierra.
class PartidoEnJuego(private val alcance: CoroutineScope) {
// Lo que necesitan las pantallas para pintarlo, sin conocer el `Directo`.
data class EnJuego(
val partidoId: Int,
val local: String,
val visitante: String,
val marcador: Marcador,
val minuto: Int,
val terminado: Boolean,
)
// PRIVADO y mutable dentro; hacia fuera, solo lectura (la misma pauta que en `Directo`).
private val estado = MutableStateFlow<EnJuego?>(null)
val actual: StateFlow<EnJuego?> = estado.asStateFlow()
private var vivo: Directo? = null
private var observacion: Job? = null
// El directo de ESE partido, si es el que esta en juego (o el que acaba de terminar).
// La pantalla del partido lo reutiliza en vez de crear otro.
fun directo(partidoId: Int): Directo? = vivo?.takeIf { it.partidoId == partidoId }
// Empieza `directo` en el alcance de la app. Devuelve `false`, sin hacer nada, si hay otro
// partido sin terminar.
// Se mira el directo, no `actual`: `actual` se rellena un instante despues (en el alcance)
// y dos toques seguidos colarian dos partidos.
fun empezar(directo: Directo): Boolean {
val anterior = vivo
if (anterior != null && !anterior.terminado) return false
observacion?.cancel()
vivo = directo
directo.iniciar(alcance)
// Cada minuto y cada gol del directo se copian a `actual`.
observacion = alcance.launch {
combine(directo.marcador, directo.instante) { marcador, instante ->
EnJuego(
partidoId = directo.partidoId,
local = directo.partido.local.nombre,
visitante = directo.partido.visitante.nombre,
marcador = marcador,
minuto = instante.minuto,
terminado = instante.minuto >= directo.minutosDelPartido,
)
}.collect { estado.value = it }
}
return true
}
// Quitar el partido (la X de la barra). Solo cuando ya termino: en juego no se cierra.
fun cerrar() {
val anterior = vivo ?: return
if (!anterior.terminado) return
observacion?.cancel()
observacion = null
vivo = null
estado.value = null
}
}
// Un directo ha terminado cuando su reloj llega a la duracion del partido.
private val Directo.terminado: Boolean get() = instante.value.minuto >= minutosDelPartidoEn DatosModule.kt, el import nuevo, con los demás de feature.marcador.domain:
import com.sebastiannarvaez.marcador.feature.marcador.domain.PartidoEnJuegoY, justo debajo de la receta partidos(…) y dentro del object, la receta nueva (el resto del fichero no cambia):
// EL PARTIDO EN JUEGO: estado de toda la app. @Singleton, porque lo comparten la pantalla
// del partido, la de otro partido y la barra de abajo: con dos instancias, cada una veria
// un partido distinto. Recibe el alcance de la app, asi que el partido sigue aunque se
// cierre su pantalla. La misma pauta que el repositorio de partidos.
@Provides
@Singleton
fun partidoEnJuego(@AlcanceDeApp alcance: CoroutineScope): PartidoEnJuego = PartidoEnJuego(alcance)package com.sebastiannarvaez.marcador.feature.marcador.domain
import com.sebastiannarvaez.marcador.torneo.Ejemplo
import com.sebastiannarvaez.marcador.torneo.Marcador
import com.sebastiannarvaez.marcador.torneo.Partido
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNull
import kotlin.test.assertTrue
// PRUEBAS DEL PARTIDO EN JUEGO (Kotlin puro: sin ViewModel ni Android).
//
// `backgroundScope` es un alcance que `runTest` cancela solo al terminar la prueba: hace de
// alcance de la app. Va con el mismo reloj VIRTUAL, asi que 90 minutos tardan milisegundos.
@OptIn(ExperimentalCoroutinesApi::class)
class PartidoEnJuegoTest {
private fun directo(id: Int) = Directo(id, Partido(Ejemplo.rayo, Ejemplo.toros), MS_POR_MINUTO)
private fun TestScope.jugarMinutos(minutos: Int) {
advanceTimeBy(minutos * MS_POR_MINUTO + 1)
runCurrent()
}
@Test
fun empezarPublicaElPartidoEnJuego() = runTest {
val enJuego = PartidoEnJuego(backgroundScope)
assertTrue(enJuego.empezar(directo(1)))
jugarMinutos(34)
assertEquals(
PartidoEnJuego.EnJuego(1, "Rayo FC", "Toros", Marcador(0, 0), minuto = 34, terminado = false),
enJuego.actual.value,
)
}
@Test
fun noDejaEmpezarOtroMientrasSeJuega() = runTest {
val enJuego = PartidoEnJuego(backgroundScope)
enJuego.empezar(directo(1))
assertFalse(enJuego.empezar(directo(2)))
runCurrent()
assertEquals(1, enJuego.actual.value?.partidoId)
assertNull(enJuego.directo(2))
}
@Test
fun alLlegarAlMinuto90QuedaTerminado() = runTest {
val enJuego = PartidoEnJuego(backgroundScope)
enJuego.empezar(directo(1))
jugarMinutos(90)
assertEquals(90, enJuego.actual.value?.minuto)
assertEquals(true, enJuego.actual.value?.terminado)
// Terminado, ya deja empezar otro.
assertTrue(enJuego.empezar(directo(2)))
}
@Test
fun cerrarQuitaElPartidoTerminado() = runTest {
val enJuego = PartidoEnJuego(backgroundScope)
enJuego.empezar(directo(1))
jugarMinutos(90)
enJuego.cerrar()
assertNull(enJuego.actual.value)
assertNull(enJuego.directo(1))
}
@Test
fun cerrarNoHaceNadaMientrasSeJuega() = runTest {
val enJuego = PartidoEnJuego(backgroundScope)
enJuego.empezar(directo(1))
jugarMinutos(10)
enJuego.cerrar()
assertEquals(10, enJuego.actual.value?.minuto)
}
private companion object {
const val MS_POR_MINUTO = 250L
}
}Qué hace cada pieza:
@Provides @Singleton: una sola copia dePartidoEnJuegopara toda la app. Sin@Singleton, la pantalla del partido, la de otro partido y la barra recibirían cada una la suya, y cada una vería un partido distinto.@AlcanceDeAppen el parámetro: Hilt entrega el alcance etiquetado, el que dura lo que el proceso. Por eso el partido seguirá corriendo aunque se cierre su pantalla: ya no depende deviewModelScope.PartidoEnJuegosin anotaciones de Hilt: es Kotlin puro. La receta es la que habla con Hilt, y la clase se prueba escribiendoPartidoEnJuego(backgroundScope). Las dos formas de dar una pieza a Hilt valen:@Inject constructores lo más corto (así quedóRegistrarGol, y también se prueba escribiendo su constructor), y la receta deja la clase sin etiquetas y pone@Singletonen un solo sitio,DatosModule. ParaPartidoEnJuegose elige la receta: la clase queda sin etiquetas y la copia única se declara en un solo sitio.
Compila y prueba desde la Terminal de Android Studio (View → Tool Windows → Terminal):
./gradlew :app:testGratisDebugUnitTestDebe terminar en BUILD SUCCESSFUL. Abre el informe (en Mac, open app/build/reports/tests/testGratisDebugUnitTest/index.html; en Windows o Linux, abre ese fichero desde el explorador de archivos): aparece la clase PartidoEnJuegoTest con 5 tests. La app, en el emulador, se comporta como antes: el partido sigue muriendo al salir. Lo arreglas en El partido sigue aunque salgas.
Punto de control
./gradlew :app:testGratisDebugUnitTesttermina enBUILD SUCCESSFULy cuenta 32 tests (35 si hiciste el ejercicio del temporizador), 0 fallos;PartidoEnJuegoTesttiene 5.feature/marcador/domain/PartidoEnJuego.ktno importa nada deandroidni deandroidx, yDatosModuletiene la receta nueva con@Singleton.- La app se ve igual que antes, y en el emulador el partido sigue perdiéndose al salir de su pantalla (todavía nadie usa la pieza).
- Tu carpeta
ejerciciostieneestado_appconMiniReproductor.kt, y su vista previa interactiva enseña «Imagine · John Lennon» al tocar la canción.
Comprueba lo que entendiste
5 preguntas · necesitas 4 para completar la lección
1.¿Qué hace que un dato sea «estado de toda la app»?
2.Un menú desplegable de una pantalla, abierto o cerrado, que ninguna otra pantalla necesita. ¿Dónde guardas ese dato?
3.
PartidoEnJuegoguarda el estado en unMutableStateFlowprivado y publicaval actual: StateFlow<EnJuego?>. ¿Para qué sirve esa separación?4.Quitas
@Singletonde la receta dePartidoEnJuegoenDatosModule. La app compila igual. ¿Qué pasa al usarla?5.Para que muchas pantallas lean el partido sin pasarlo de una en una, ¿por qué no sirve un
CompositionLocalpropio?