Saltar al contenido
K
Jetpack y arquitecturaPaso a paso40 min Intermedio

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:

IngredienteCómo se hace
Una sola copia@Singleton en su receta de Hilt: una instancia mientras viva el proceso
Un solo dueño que lo cambiaEl dato se guarda en un MutableStateFlow privado; hacia fuera solo sale un StateFlow de lectura
Las pantallas lo observanCada 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ñoCuándoEjemplo en Marcador
remember (en el composable)Solo lo usa esa pieza de pantallaSi un desplegable está abierto
El ViewModel de la pantallaLo usa una pantalla y debe sobrevivir a girar el móvilLos 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 cierraEl 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 ViewModel de 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):

ejemplo mínimo: la canción que suena, estado de toda la app (con comentarios)
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: oculto

Las 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.

  1. Una Cancion y un Reproductor como los del ejemplo, sin parar: solo guarda qué canción suena y sirve para poner una.
  2. 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.
  3. 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ón

Y al tocar «Imagine»:

Imagine · John Lennon
Ver solución
app/src/main/java/com/sebastiannarvaez/marcador/ejercicios/estado_app/MiniReproductor.kt
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.

app → kotlin+java
com.sebastiannarvaez.marcador
feature
marcador
domain
PartidoEnJuego.ktse crea
di
DatosModule.ktse modifica
com.sebastiannarvaez.marcador (test)el paquete de los tests unitarios
feature
marcador
domaincarpeta nueva
PartidoEnJuegoTest.ktse crea
Dos ficheros nuevos y uno que se modifica, en la vista Android.

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:

ParteQué hace
EnJuegoUna 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 / actualEl MutableStateFlow privado y su versión de solo lectura. Vale null cuando no hay partido
vivoEl Directo que está en juego (o que acaba de terminar)
observacionLa 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 de class 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 a PartidoEnJuego.
  • class PartidoEnJuego(private val alcance: CoroutineScope): la clase no crea su alcance, se lo prestan por el constructor. Directo también recibe el suyo, aunque en su función iniciar(alcance) (Capas en Marcador: el directo). En la app será el @AlcanceDeApp; en las pruebas, el backgroundScope.
  • 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 un EnJuego nuevo en estado. Devuelve un Job, que se guarda para poder cancelarlo.
  • private val Directo.terminado: Boolean get() = …: una propiedad de extensión, que añade a Directo la pregunta «¿ha terminado?» (Extensiones). El get() = hace que la respuesta se calcule cada vez que preguntas, con el reloj de ese momento, en vez de guardarse una vez. Y private fuera 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 (en empezar): vivo es un var, así que su valor puede cambiar en cualquier momento, y Kotlin no deja tratarlo como «ya sin null» después de comprobarlo. Copiarlo a un val congela el valor de ese instante, y anterior != null && !anterior.terminado ya es seguro.
  • val anterior = vivo ?: return: si vivo es null (no hay partido), la función termina ahí sin hacer nada; si no, anterior es ese partido ya sin el null (Nulabilidad).
  • vivo?.takeIf { it.partidoId == partidoId }: da el directo solo si es el de ese id, y null si no (Nulabilidad).
  • En las pruebas, backgroundScope: el alcance de la prueba que runTest cancela 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 en noDejaEmpezarOtroMientrasSeJuega: deja que la corrutina del alcance publique el partido 1 en actual. Sin él, actual seguiría en null (se rellena un instante después, como dice el aviso de más abajo) y la comprobación del partidoId fallaría.
  • private fun TestScope.jugarMinutos(n): una función de ayuda que se llama dentro de runTest { } y adelanta el reloj virtual. Mismo + 1 y runCurrent() que en Tests de un ViewModel: fakes, setMain y Turbine.

Intenta escribirlo tú, en este orden:

  1. PartidoEnJuego.kt, en feature/marcador/domain (Kotlin puro: ni un import de Android). Recibe un alcance por el constructor. Guarda un EnJuego? que las pantallas solo pueden leer, y un Directo vivo. Y tres funciones:
    • empezar(directo): si el directo vivo no ha terminado, no hace nada y devuelve false. Si no, cancela la observación anterior, guarda el nuevo y lo arranca con directo.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 devuelve true.
    • directo(id): como en la tabla.
    • cerrar(): como en la tabla.
  2. La receta en DatosModule: una función que recibe el alcance con la etiqueta @AlcanceDeApp y entrega un PartidoEnJuego, con una sola copia para toda la app. Es la misma pauta que partidos(…), justo encima.
  3. PartidoEnJuegoTest.kt, con cinco pruebas que usan el backgroundScope de runTest como alcance de la app (lo cancela solo al terminar la prueba) y un Directo por partido, de 250 ms por minuto:
    • al empezar y pasar 34 minutos, actual dice partido 1, minuto 34, marcador 0-0 y sin terminar;
    • con uno en juego, empezar de otro devuelve false, actual sigue en el partido 1 y directo(2) es null;
    • al llegar al minuto 90 queda terminado, y entonces empezar de otro ya devuelve true;
    • cerrar con el partido terminado deja actual en null y directo(1) en null;
    • cerrar mientras se juega (minuto 10) no hace nada.

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
app/src/main/java/com/sebastiannarvaez/marcador/feature/marcador/domain/PartidoEnJuego.kt
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 >= minutosDelPartido

En DatosModule.kt, el import nuevo, con los demás de feature.marcador.domain:

app/src/main/java/com/sebastiannarvaez/marcador/di/DatosModule.kt
import com.sebastiannarvaez.marcador.feature.marcador.domain.PartidoEnJuego

Y, justo debajo de la receta partidos(…) y dentro del object, la receta nueva (el resto del fichero no cambia):

app/src/main/java/com/sebastiannarvaez/marcador/di/DatosModule.kt
    // 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)
app/src/test/java/com/sebastiannarvaez/marcador/feature/marcador/domain/PartidoEnJuegoTest.kt
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 de PartidoEnJuego para 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.
  • @AlcanceDeApp en 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 de viewModelScope.
  • PartidoEnJuego sin anotaciones de Hilt: es Kotlin puro. La receta es la que habla con Hilt, y la clase se prueba escribiendo PartidoEnJuego(backgroundScope). Las dos formas de dar una pieza a Hilt valen: @Inject constructor es 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 @Singleton en un solo sitio, DatosModule. Para PartidoEnJuego se 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:testGratisDebugUnitTest

Debe 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:testGratisDebugUnitTest termina en BUILD SUCCESSFUL y cuenta 32 tests (35 si hiciste el ejercicio del temporizador), 0 fallos; PartidoEnJuegoTest tiene 5.
  • feature/marcador/domain/PartidoEnJuego.kt no importa nada de android ni de androidx, y DatosModule tiene 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 ejercicios tiene estado_app con MiniReproductor.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. 1.¿Qué hace que un dato sea «estado de toda la app»?

  2. 2.Un menú desplegable de una pantalla, abierto o cerrado, que ninguna otra pantalla necesita. ¿Dónde guardas ese dato?

  3. 3.PartidoEnJuego guarda el estado en un MutableStateFlow privado y publica val actual: StateFlow<EnJuego?>. ¿Para qué sirve esa separación?

  4. 4.Quitas @Singleton de la receta de PartidoEnJuego en DatosModule. La app compila igual. ¿Qué pasa al usarla?

  5. 5.Para que muchas pantallas lean el partido sin pasarlo de una en una, ¿por qué no sirve un CompositionLocal propio?

Faltan 5 de 5