Saltar al contenido
K
Jetpack y arquitecturaPaso a paso45 min Intermedio

La plantilla, guardada en Room

Al terminar: Guardar los jugadores de cada equipo en una tabla nueva de Room y migrar la base de la versión 3 a la 4.

Al terminar esta lección, los jugadores de cada equipo de Marcador ya no están escritos en el código: viven en una tabla nueva de Room, jugador, y la base sube a la versión 4. El móvil que ya tenía la app con partidos guardados la actualiza sin perder nada, y la app se ve exactamente igual que antes. Por fuera no cambia nada. Por dentro, la plantilla de cada equipo pasa a ser algo que se puede guardar, leer y, en las lecciones siguientes, ampliar.

Importa porque un dato que el usuario va a poder añadir tiene que estar en la base. Y porque es el caso que más sorprende de las migraciones: la tabla nueva llega vacía también al móvil que ya tenía datos, y alguien tiene que llenarla. Temas: una tabla nueva con su migración automática, sembrar una tabla que llega vacía, reconstruir objetos a partir de filas con groupBy, la diferencia entre igualdad e identidad, y combine y onStart para leer tres tablas a la vez.

La lección Una fila que se toca: la plantilla dibuja la plantilla en pantalla, y Formularios: el estado y las reglas añade jugadores. Hoy solo cambia el almacén.

Lo que cambia: de catálogo fijo a tabla

Hasta ahora, PartidosRepositoryRoom recibía la lista de equipos, con la plantilla de cada uno, desde DatosDeEjemplo: un catálogo escrito en el código, que no cambia nunca. En la base solo estaban los partidos, que guardan los nombres de sus equipos, y los goles. Desde hoy, la plantilla también está en la base.

Antes (versión 3)Después (versión 4)
Dónde viven los jugadoresEn el código (DatosDeEjemplo)En la tabla jugador
De dónde sale un EquipoDe una lista fija que recibe el repositorioSe reconstruye desde las filas de jugador en cada lectura
Qué guarda un partidoLos nombres de sus equiposIgual
PartidosRepositoryTiene la propiedad equiposYa no la tiene, y gana observarPlantilla(equipo)

Una tabla nueva llega vacía

Lo que hace AutoMigration(from = 3, to = 4) se lee en el JSON nuevo. 4.json describe la tabla nueva, y de esa descripción sale la única sentencia que ejecuta la migración (INTEGER es una columna de números enteros y TEXT, una de texto):

lo que ejecuta la migración de la 3 a la 4
CREATE TABLE IF NOT EXISTS `jugador` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `equipo` TEXT NOT NULL, `nombre` TEXT NOT NULL, `dorsal` INTEGER)

Fíjate en la última columna: dorsal INTEGER sin NOT NULL. Es la forma que toma en SQL un Int? de Kotlin: una columna que admite NULL, el valor «no hay nada». En Marcador un jugador puede no tener dorsal, así que la columna lo admite.

Esa sentencia crea la tabla y nada más. El móvil que venía de la versión 3 ya tiene los partidos, así que si la siembra usara la comprobación que ya tiene para los partidos (contarPartidos() == 0, «¿la base está vacía?»), diría «no, ya hay partidos» y nunca llenaría jugador. Y sin jugadores no hay equipos con los que montar los partidos. Por eso la siembra de jugadores tiene su propia comprobación, contarJugadores() == 0, y va primero:

SituaciónPartidosJugadoresQué siembra el repositorio
La app se abre por primera vez (versión 4 nueva)00Los jugadores y después los partidos
El móvil venía de la versión 3180Solo los jugadores
La app ya se abrió en la versión 4188Nada

Los 8 jugadores son los de los cuatro equipos de ejemplo: 3 del Rayo FC, 2 de Toros, 2 de Lobos y 1 de Águilas. Cada fila guarda el nombre del equipo, igual que ya hacen partido y gol, y el id lo pone la base: el orden de llegada es el orden de la plantilla.

Reconstruir objetos a partir de filas

Una fila de jugador dice «Ana, del Rayo FC, dorsal 9». Pero la app trabaja con Equipo, que tiene un nombre y una lista de Jugador. El repositorio tiene que reconstruir cada equipo juntando las filas que comparten su nombre. Eso hace groupBy (Colecciones): agrupa una lista según una clave y devuelve un mapa de clave a grupo.

Y hay un detalle que importa. Equipo es una clase normal, no una data class, así que no define su propio equals (la función que decide cuándo dos objetos son «iguales»). El que trae de serie compara identidad, como ===: dos objetos son iguales solo si son literalmente el mismo objeto en memoria (data class e igualdad). Dos Equipo con el mismo nombre y los mismos jugadores, creados por separado, no son «el mismo» para Kotlin. Un ejemplo ajeno, con listas de reproducción:

ejemplo mínimo: reconstruir listas desde filas, e identidad (con comentarios)
// Una fila de la tabla: a qué lista pertenece cada canción
data class FilaCancion(val lista: String, val titulo: String)
 
// Una lista de reproducción. NO es data class: no define equals, y el de serie compara identidad
class ListaDeReproduccion(val nombre: String, val canciones: List<String>)
 
// Filas -> listas. groupBy junta las filas que tienen la misma lista
fun reconstruir(filas: List<FilaCancion>): List<ListaDeReproduccion> =
    filas.groupBy { it.lista }          // un mapa: "Viaje" -> sus 3 filas, "Estudio" -> sus 2
        // map recorre el mapa par a par; (nombre, delGrupo) abre cada par en dos nombres:
        // la clave y la lista de filas de ese grupo
        .map { (nombre, delGrupo) -> ListaDeReproduccion(nombre, delGrupo.map { it.titulo }) }
 
fun main() {
    val filas = listOf(
        FilaCancion("Viaje", "Carretera"),
        FilaCancion("Estudio", "Lluvia"),
        FilaCancion("Viaje", "Costa"),
        FilaCancion("Estudio", "Piano"),
        FilaCancion("Viaje", "Atardecer"),
    )
 
    val lectura1 = reconstruir(filas)
    for (lista in lectura1) println("${lista.nombre}: ${lista.canciones}")
 
    val viaje = lectura1.first()
    val lectura2 = reconstruir(filas)               // las MISMAS filas, leídas otra vez
 
    println("Misma lectura: ${lectura1.contains(viaje)}")      // contains usa equals (el de serie): identidad
    println("Otra lectura:  ${lectura2.contains(viaje)}")
    // any: ¿hay AL MENOS UNA lista de la otra lectura con ese nombre? Compara nombres, no objetos
    println("Mismo nombre en la otra lectura: ${lectura2.any { it.nombre == viaje.nombre }}")
}
Viaje: [Carretera, Costa, Atardecer]
Estudio: [Lluvia, Piano]
Misma lectura: true
Otra lectura:  false
Mismo nombre en la otra lectura: true

La lista «Viaje» de la primera lectura no está en la segunda, aunque tenga el mismo nombre y las mismas canciones: son objetos distintos. Solo reconoces una lista dentro de la misma lectura que la creó.

En Marcador esto pesa. Torneo compara los equipos por identidad: un partido solo es válido si sus equipos son los mismos objetos que los del torneo. Por eso hay dos reglas:

  • Todos los partidos de una lectura comparten los equipos que se acaban de reconstruir: una sola pasada por las filas de jugador para todos.
  • armarTorneo ya no recibe la lista de equipos: los saca de los propios partidos, que son los objetos con los que se montaron.

Un efecto de esto: un equipo que no juega ningún partido no saldría en Tablas. En Marcador los cuatro juegan 9 partidos cada uno, así que no se nota.

Leer tres tablas a la vez: combine y onStart

observarPartidos() ya usaba combine (Flow: scan y combine) con dos flujos, partidos y goles: cada vez que cualquiera emite, recalcula la lista. Ahora son tres, porque los equipos salen de jugador. Así, un gol nuevo y un jugador nuevo refrescan la lista sin que nadie recargue nada, y las tres tablas se leen a la vez, para que los equipos que se reconstruyen sean los mismos en todos los partidos.

Falta esperar a la siembra. sembrado.join() ya lo conoces de partido(id). Para un flujo se usa onStart { … }, que ejecuta su bloque antes de que el flujo emita su primer valor. Es el sitio justo: si el flujo empezara a leer mientras la siembra todavía está llenando la tabla, vería partidos sin jugadores y no podría montar ningún equipo. Un ejemplo ajeno, con una despensa:

ejemplo mínimo: esperar a una siembra con onStart (con comentarios)
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
 
fun main() = runBlocking {
    // La siembra: una corrutina que tarda un poco en llenar la despensa
    val llenado = launch {
        delay(100)
        println("despensa llena")
    }
 
    // Un flujo de lectura. onStart se ejecuta ANTES de la primera emisión
    val lectura = flowOf("Harina", "Aceite")
        .onStart {
            println("antes de leer: espero a la siembra")
            llenado.join()                       // aquí se queda esperando
        }
 
    lectura.collect { println("llega $it") }
}
antes de leer: espero a la siembra
despensa llena
llega Harina
llega Aceite

Quita la línea del join() y llega Harina saldría antes que despensa llena. Con la espera, la lectura no empieza hasta que la despensa está llena. Es lo mismo que hace .onStart { sembrado.join() } en el repositorio.

Ejercicio: la biblioteca

Fuera de Marcador, en el Kotlin Playground.

Reto · Ejercicio: las estanterías de la biblioteca

Copia este punto de partida: ya trae las filas de la tabla, las dos clases y el main.

punto de partida: las filas y el main
data class FilaLibro(val estanteria: String, val titulo: String)
class Estanteria(val nombre: String, val libros: List<String>)
 
val filas = listOf(
    FilaLibro("Novela", "Dune"),
    FilaLibro("Ciencia", "Cosmos"),
    FilaLibro("Novela", "Emma"),
    FilaLibro("Ciencia", "Origen"),
)
 
// Aquí escribes reconstruirEstanterias, puedeDevolverse y existePorNombre
 
fun main() {
    val lectura1 = reconstruirEstanterias(filas)
    val lectura2 = reconstruirEstanterias(filas)
    for (e in lectura1) println("${e.nombre}: ${e.libros}")
 
    // first { condición } devuelve el primer elemento que la cumple; `"Dune" in it.libros` es «¿está Dune en la lista de libros?»
    val deDune = lectura1.first { "Dune" in it.libros }
    println("Devolver a la estantería de la misma lectura: ${puedeDevolverse(deDune, lectura1)}")
    println("Devolver a la estantería de otra lectura: ${puedeDevolverse(deDune, lectura2)}")
    println("Existe una con ese nombre en la otra lectura: ${existePorNombre(deDune, lectura2)}")
}

Escribe tres funciones:

  • reconstruirEstanterias(filas: List<FilaLibro>): List<Estanteria>: junta las filas que comparten estantería y construye una Estanteria por cada una, con sus libros en el orden en que aparecen.
  • puedeDevolverse(estanteria: Estanteria, estanterias: List<Estanteria>): Boolean: dice si esa estantería (ese mismo objeto) está en la lista que se le da.
  • existePorNombre(estanteria: Estanteria, estanterias: List<Estanteria>): Boolean: dice si en la lista hay alguna con el mismo nombre. Te sirve any { … }, como en el ejemplo: devuelve true si al menos un elemento de la lista cumple la condición que le das entre llaves.

La consola debe mostrar:

Novela: [Dune, Emma]
Ciencia: [Cosmos, Origen]
Devolver a la estantería de la misma lectura: true
Devolver a la estantería de otra lectura: false
Existe una con ese nombre en la otra lectura: true

Cuando lo tengas, piensa por qué la cuarta línea da false y la quinta true si las filas son las mismas.

Ver solución
solución: las estanterías de la biblioteca
fun reconstruirEstanterias(filas: List<FilaLibro>): List<Estanteria> =
    filas.groupBy { it.estanteria }
        .map { (nombre, delGrupo) -> Estanteria(nombre, delGrupo.map { it.titulo }) }
 
fun puedeDevolverse(estanteria: Estanteria, estanterias: List<Estanteria>): Boolean =
    estanterias.contains(estanteria)
 
fun existePorNombre(estanteria: Estanteria, estanterias: List<Estanteria>): Boolean =
    estanterias.any { it.nombre == estanteria.nombre }

groupBy junta las filas por estantería y conserva el orden en que aparecen. contains usa equals, y Estanteria no es una data class: compara identidad. La estantería de lectura1 es un objeto, y la que lectura2 construyó con las mismas filas es otro, aunque tenga el mismo nombre y los mismos libros. Por eso puedeDevolverse da false, y comparar por nombre (existePorNombre) es lo que sí las reconoce. Es lo que le pasaría a Torneo si recibiera los equipos de una lectura y los partidos de otra.

Paso · la plantilla en la tabla jugador

Objetivo. Guardar la plantilla de cada equipo en una tabla nueva, subir la base a la versión 4, y que el repositorio reconstruya los equipos desde ella. Al terminar, la app se ve igual que antes y la tabla jugador tiene 8 filas, también en un móvil que ya tenía la app.

app → kotlin+java → com.sebastiannarvaez.marcador
core
database
Entidades.ktse modifica
MarcadorDatabase.ktse modifica
PartidoDao.ktse modifica
feature
partidos
data
PartidosRepositoryEnMemoria.ktse borra
PartidosRepositoryRoom.ktse modifica
domain
Partidos.ktse modifica
ui
ListaViewModel.ktse modifica
di
DatosModule.ktse modifica (un comentario)
app → kotlin+java → com.sebastiannarvaez.marcador (test)
core
Falsos.ktse modifica
app/schemas/…/MarcadorDatabase/ (en el disco)
4.jsonlo genera Room al compilar
Ocho ficheros se modifican y uno se borra, en la vista Android. Room genera además 4.json en el disco.

Antes de tocar nada, deja datos en la versión 3. La migración solo se ve con una base que ya existe. Ejecuta la app con la variante gratisDebug, abre Partidos y fíjate en el resultado del partido 1. No desinstales la app hasta terminar la lección: desinstalar borra la base y la prueba no serviría.

Piezas nuevas de este paso:

  • Int? en una entidad: una columna que admite NULL (el dorsal es opcional).
  • groupBy { … } y map { (clave, grupo) -> … }: del ejemplo de las listas de reproducción. El paréntesis en la lambda desestructura el par clave-grupo en dos nombres.
  • Función local: una función puede declararse dentro de otra, y solo existe ahí dentro. En aDominio, fun equipo(nombre: String) busca un equipo por nombre y solo la ve aDominio.
  • error("mensaje"): lanza una excepción con ese mensaje y la app falla. Aquí es un «esto no puede pasar»: si un partido nombrara un equipo que no está en la tabla, saldría con el nombre en el mensaje.
  • distinct(): quita los elementos repetidos. Con objetos sin equals propio, «repetido» significa «el mismo objeto»: sirve porque cada equipo es un objeto que todos sus partidos comparten.
  • associate { clave to valor }: convierte una lista en un mapa. listOf(rayo, toros).associate { it.nombre to it.plantilla } da un mapa de nombre de equipo a su plantilla.
  • Flow.onStart { … }: del ejemplo de la despensa.
  • @Insert con una lista: insertarJugadores(jugadores: List<JugadorEntity>) guarda todas las filas de una vez, igual que insertarPartidos.

Ya vistas: @Entity, @Dao, @Query, @Insert y AutoMigration (Room: entidades y DAO y Room: migrar la base de datos); combine (Flow: scan y combine); sembrado.join() (El repositorio con Room).

Intenta escribirlo tú, en este orden:

  1. La tabla. En Entidades.kt, una entidad JugadorEntity (tabla jugador) con id autogenerado, equipo, nombre y dorsal (que puede ser nulo). En PartidoDao.kt, cuatro funciones: observar todos los jugadores ordenados por id, leerlos una vez (suspend), contarlos, e insertar una lista. En MarcadorDatabase.kt: la entidad en la lista, versión 4 y la migración automática de la 3 a la 4. Compila con ./gradlew :app:assembleGratisDebug en la Terminal de Android Studio: debe terminar bien y 4.json aparece junto a 3.json (la vista Android no enseña esa carpeta: míralo en la Terminal con ls app/schemas/com.sebastiannarvaez.marcador.core.database.MarcadorDatabase).
  2. El contrato. En Partidos.kt: fuera val equipos de PartidosRepository; dentro, observarPlantilla(equipo), que devuelve un Flow con la lista de jugadores de ese equipo; y armarTorneo que ya no recibe equipos y los saca de los partidos. Desde aquí el proyecto estará en rojo hasta el paso 4. Es normal.
  3. El repositorio. En PartidosRepositoryRoom.kt: fuera el parámetro equipos (tenía un valor por defecto, así que la receta de DatosModule que construye el repositorio no pasaba nada en ese lugar y su llamada no cambia); la siembra de jugadores va primero y con su propia comprobación; observarPartidos combina tres tablas y espera a la siembra; partido(id) y aDominio usan los equipos que se reconstruyen desde jugador; y nace observarPlantilla.
  4. Los que usaban equipos. En ListaViewModel.kt, la llamada a armarTorneo. En DatosModule.kt, solo el comentario de la receta: la llamada a PartidosRepositoryRoom(…) se queda igual porque el parámetro equipos tenía un valor por defecto y nunca se le pasaba nada. En Falsos.kt (el de tests), el falso pierde equipos y gana plantillas y observarPlantilla. Y se borra PartidosRepositoryEnMemoria.kt: nadie lo usa desde El repositorio con Room, y su propiedad equipos ya no existe en la interfaz, así que ya no compilaría.

Los package y los import ya los tienes; en cada fichero, el único import nuevo es el de lo que se estrena (Option+Enter en Mac, Alt+Enter en Windows y Linux, sobre el nombre en rojo lo propone). La ruta del título de cada bloque es la del fichero en tu proyecto: en la vista Android está en app → kotlin+java → com.sebastiannarvaez.marcador.

Intenta primero. Cuando termines, compara con la solución.Mostrar el código

En Entidades.kt, la entidad nueva va después de GolEntity y antes de GoleadorFila:

app/src/main/java/com/sebastiannarvaez/marcador/core/database/Entidades.kt
// La tabla `jugador` (VERSION 4 del esquema): la plantilla de cada equipo, que deja de ser un
// catalogo fijo. El equipo se guarda por su NOMBRE, igual que en `partido` y en `gol`. El id lo
// pone la base (autoGenerate): el orden de llegada es el orden de la plantilla.
@Entity(tableName = "jugador")
data class JugadorEntity(
    @PrimaryKey(autoGenerate = true) val id: Long = 0,
    val equipo: String,
    val nombre: String,
    // `Int?` -> una columna que admite NULL: el dorsal es opcional.
    val dorsal: Int?,
)

En PartidoDao.kt, las cuatro funciones van después de golesDe:

app/src/main/java/com/sebastiannarvaez/marcador/core/database/PartidoDao.kt
    // La plantilla de todos los equipos, en el orden en que se apuntaron (el id).
    @Query("SELECT * FROM jugador ORDER BY id")
    fun observarJugadores(): Flow<List<JugadorEntity>>
 
    @Query("SELECT * FROM jugador ORDER BY id")
    suspend fun jugadores(): List<JugadorEntity>
 
    @Query("SELECT COUNT(*) FROM jugador")
    suspend fun contarJugadores(): Int
 
    @Insert
    suspend fun insertarJugadores(jugadores: List<JugadorEntity>)

En MarcadorDatabase.kt, la anotación @Database y la clase cambian así (el resto del fichero no cambia; los dos últimos comentarios son nuevos):

app/src/main/java/com/sebastiannarvaez/marcador/core/database/MarcadorDatabase.kt
// Version 3: la tabla nueva `arbitro`. Una tabla nueva es el caso facil de una migracion
// automatica: Room compara 2.json con 3.json y escribe el CREATE TABLE. Los partidos y los
// goles de la version 2 se quedan como estaban.
// Version 4: otra tabla nueva, `jugador`. Llega VACIA tambien al movil que ya tenia partidos:
// la rellena el repositorio al arrancar (ver PartidosRepositoryRoom).
@Database(
    entities = [PartidoEntity::class, GolEntity::class, ArbitroEntity::class, JugadorEntity::class],
    version = 4,
    exportSchema = true,
    autoMigrations = [AutoMigration(from = 1, to = 2), AutoMigration(from = 2, to = 3), AutoMigration(from = 3, to = 4)],
)
abstract class MarcadorDatabase : RoomDatabase() {
    abstract fun partidoDao(): PartidoDao
    abstract fun arbitroDao(): ArbitroDao
}

Partidos.kt entero, tal como queda (cambian los import, la interfaz y armarTorneo):

app/src/main/java/com/sebastiannarvaez/marcador/feature/partidos/domain/Partidos.kt
package com.sebastiannarvaez.marcador.feature.partidos.domain
 
import com.sebastiannarvaez.marcador.torneo.Gol
import com.sebastiannarvaez.marcador.torneo.Jugador
import com.sebastiannarvaez.marcador.torneo.Partido
import com.sebastiannarvaez.marcador.torneo.Torneo
import kotlinx.coroutines.flow.Flow
 
// La lista necesita un identificador ESTABLE para cada fila (la `key` de LazyColumn).
// `Partido` no tiene id (dos partidos pueden repetir equipos), asi que se envuelve.
data class PartidoDeLista(val id: Int, val partido: Partido)
 
// REPOSITORIO: la UNICA puerta a los datos.
//
// Es una INTERFAZ en la capa de dominio: el dominio dice QUE necesita («dame los
// partidos», «guarda este gol») y la capa de datos decide COMO (memoria, Room, red).
// Quien la usa (un caso de uso, un ViewModel) no sabe de donde salen los datos ni si
// cambian: puede ser una lista en memoria o Room, y nadie mas se entera.
//
// Reglas del repositorio:
//   - devuelve y recibe tipos de DOMINIO (Partido, Gol), nunca entidades de Room ni DTO;
//   - lo que cambia con el tiempo se expone como Flow; lo que ocurre una vez, como `suspend`;
//   - es la fuente de la verdad: quien quiera saber los partidos, los OBSERVA aqui.
interface PartidosRepository {
    fun observarPartidos(): Flow<List<PartidoDeLista>>
 
    suspend fun partido(id: Int): Partido?
 
    suspend fun registrarGol(partidoId: Int, gol: Gol)
 
    fun observarGoleadores(): Flow<List<Goleador>>
 
    // La plantilla de un equipo, observada: si alguien anade un jugador, vuelve a emitir.
    fun observarPlantilla(equipo: String): Flow<List<Jugador>>
}
 
// Cuantos goles lleva un jugador (la pantalla de goleadores los pinta). Se calcula en la base con GROUP BY.
data class Goleador(val jugador: String, val goles: Int)
 
// Las tablas y los goleadores no se guardan: se CALCULAN a partir de los partidos. Guardar
// ademas la tabla seria tener dos fuentes de verdad que se pueden contradecir.
// Los equipos salen de los PROPIOS partidos: son los mismos objetos que llevan dentro (Torneo
// compara equipos por identidad) y vienen de la misma lectura de la base.
fun armarTorneo(partidos: List<PartidoDeLista>): Torneo {
    val equipos = partidos.flatMap { listOf(it.partido.local, it.partido.visitante) }.distinct()
    return Torneo("Copa Barrio", equipos).also { torneo ->
        partidos.forEach { torneo.registrar(it.partido).getOrThrow() }
    }
}

PartidosRepositoryRoom.kt entero, tal como queda. Hay cambios en los import, en la cabecera, en la siembra y en tres funciones, y el equipo(nombre) de antes pasa a ser una función local dentro de aDominio:

app/src/main/java/com/sebastiannarvaez/marcador/feature/partidos/data/PartidosRepositoryRoom.kt
package com.sebastiannarvaez.marcador.feature.partidos.data
 
import com.sebastiannarvaez.marcador.core.database.GolEntity
import com.sebastiannarvaez.marcador.core.database.JugadorEntity
import com.sebastiannarvaez.marcador.core.database.PartidoDao
import com.sebastiannarvaez.marcador.core.database.PartidoEntity
import com.sebastiannarvaez.marcador.feature.partidos.domain.Goleador
import com.sebastiannarvaez.marcador.feature.partidos.domain.PartidoDeLista
import com.sebastiannarvaez.marcador.feature.partidos.domain.PartidosRepository
import com.sebastiannarvaez.marcador.torneo.Equipo
import com.sebastiannarvaez.marcador.torneo.Gol
import com.sebastiannarvaez.marcador.torneo.Jugador
import com.sebastiannarvaez.marcador.torneo.Partido
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.launch
 
// El repositorio con Room detras. Implementa LA MISMA interfaz que el de memoria (ya borrado):
// ni los casos de uso ni los ViewModels cambiaron. Su trabajo es TRADUCIR entre filas
// (entidades) y dominio, y ser el unico que conoce el DAO.
//
// Desde la version 4 los equipos tambien salen de la base: su plantilla es la tabla `jugador`.
class PartidosRepositoryRoom(
    private val dao: PartidoDao,
    alcance: CoroutineScope,
) : PartidosRepository {
 
    // Primera vez que se abre la app: la base esta vacia y se llena con los datos de ejemplo.
    // Es un Job: quien lee ESPERA a que termine (`join`), o veria la base a medio llenar.
    // Los jugadores van PRIMERO y con su propia comprobacion: en un movil que ya tenia la
    // version 3, la migracion crea la tabla `jugador` vacia pero los partidos ya estan.
    private val sembrado = alcance.launch {
        if (dao.contarJugadores() == 0) {
            dao.insertarJugadores(
                DatosDeEjemplo.torneo.equipos.flatMap { equipo ->
                    equipo.plantilla.map { JugadorEntity(equipo = equipo.nombre, nombre = it.nombre, dorsal = it.dorsal) }
                },
            )
        }
        if (dao.contarPartidos() == 0) {
            dao.insertarPartidos(DatosDeEjemplo.partidos.map { PartidoEntity(it.id, it.partido.local.nombre, it.partido.visitante.nombre) })
            dao.insertarGoles(
                DatosDeEjemplo.partidos.flatMap { fila ->
                    fila.partido.eventos.filterIsInstance<Gol>().map {
                        GolEntity(partidoId = fila.id, minuto = it.minuto, jugador = it.jugador.nombre, equipo = it.equipo.nombre)
                    }
                },
            )
        }
    }
 
    // TRES tablas; `combine` reconstruye la lista cada vez que CUALQUIERA cambia: un gol nuevo
    // (INSERT en `gol`) o un jugador nuevo (INSERT en `jugador`) refrescan la lista sin recargar.
    // `onStart { sembrado.join() }`: no se empieza a leer hasta que la siembra termina; sin
    // jugadores no hay equipos con los que montar los partidos.
    override fun observarPartidos(): Flow<List<PartidoDeLista>> =
        combine(dao.observarPartidos(), dao.observarGoles(), dao.observarJugadores()) { partidos, goles, jugadores ->
            val equipos = equiposDesde(jugadores)
            val golesPorPartido = goles.groupBy { it.partidoId }
            partidos.map { PartidoDeLista(it.id, aDominio(it, golesPorPartido[it.id].orEmpty(), equipos)) }
        }.onStart { sembrado.join() }
 
    override suspend fun partido(id: Int): Partido? {
        sembrado.join()
        val fila = dao.partido(id) ?: return null
        return aDominio(fila, dao.golesDe(id), equiposDesde(dao.jugadores()))
    }
 
    override suspend fun registrarGol(partidoId: Int, gol: Gol) {
        sembrado.join()
        dao.insertarGol(GolEntity(partidoId = partidoId, minuto = gol.minuto, jugador = gol.jugador.nombre, equipo = gol.equipo.nombre, aMano = true))
    }
 
    override fun observarGoleadores(): Flow<List<Goleador>> =
        dao.observarGoleadores().map { filas -> filas.map { Goleador(it.jugador, it.goles) } }
 
    // La plantilla de UN equipo: las filas de la tabla `jugador` con ese nombre, como Jugador.
    override fun observarPlantilla(equipo: String): Flow<List<Jugador>> =
        dao.observarJugadores()
            .map { filas -> filas.filter { it.equipo == equipo }.map { Jugador(it.nombre, it.dorsal) } }
            .onStart { sembrado.join() }
 
    // Filas -> equipos. `groupBy` junta los jugadores de cada equipo y conserva el orden (el id).
    // `Equipo(...)` vuelve a pasar su `init`: si la tabla tuviera dos dorsales iguales, lanzaria.
    private fun equiposDesde(jugadores: List<JugadorEntity>): List<Equipo> =
        jugadores.groupBy { it.equipo }.map { (nombre, filas) -> Equipo(nombre, filas.map { Jugador(it.nombre, it.dorsal) }) }
 
    // Fila -> dominio. Los equipos se buscan POR NOMBRE en la lista que se acaba de construir:
    // `Equipo` no define `equals`, asi que `Torneo` solo reconoce las MISMAS instancias, y todos
    // los partidos de una lectura comparten los mismos equipos.
    private fun aDominio(fila: PartidoEntity, goles: List<GolEntity>, equipos: List<Equipo>): Partido {
        fun equipo(nombre: String): Equipo = equipos.firstOrNull { it.nombre == nombre } ?: error("Equipo desconocido: $nombre")
        val local = equipo(fila.local)
        val visitante = equipo(fila.visitante)
        return Partido(
            local, visitante,
            goles.map { gol ->
                val equipo = equipo(gol.equipo)
                Gol(gol.minuto, equipo.plantilla.first { it.nombre == gol.jugador }, equipo)
            },
        )
    }
}

En ListaViewModel.kt cambian dos líneas de la propiedad torneo (el resto del fichero no cambia):

app/src/main/java/com/sebastiannarvaez/marcador/feature/partidos/ui/ListaViewModel.kt
    val torneo: StateFlow<Torneo> = repositorio.observarPartidos()
        .map { armarTorneo(it) }
        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), Torneo("Copa Barrio", emptyList()))

En DatosModule.kt, el comentario de la receta partidos() (el resto no cambia; la receta sigue siendo @Provides porque recibe el alcance con su etiqueta):

app/src/main/java/com/sebastiannarvaez/marcador/di/DatosModule.kt
    // El repositorio de partidos, ahora con Room detras. Va con receta (@Provides) porque
    // recibe el alcance con su etiqueta. Quien pide PartidosRepository no se entera del cambio.

En el Falsos.kt de los tests, borra el import de Equipo y añade este junto a los demás:

app/src/test/java/com/sebastiannarvaez/marcador/core/Falsos.kt
import com.sebastiannarvaez.marcador.torneo.Jugador

Dentro de FakePartidosRepository, la propiedad equipos se sustituye por plantillas (una por nombre de equipo, que hace de tabla jugador):

app/src/test/java/com/sebastiannarvaez/marcador/core/Falsos.kt
    // Las plantillas, por nombre de equipo: hacen de tabla `jugador`.
    private val plantillas = MutableStateFlow(listOf(Ejemplo.rayo, Ejemplo.toros).associate { it.nombre to it.plantilla })

Y al final de la clase, después de observarGoleadores:

app/src/test/java/com/sebastiannarvaez/marcador/core/Falsos.kt
    override fun observarPlantilla(equipo: String): Flow<List<Jugador>> = plantillas.map { it[equipo].orEmpty() }

Los goles apuntan al jugador por su nombre (plantilla.first { it.nombre == gol.jugador } en aDominio): funciona porque los jugadores no se borran ni se renombran. En Formularios: el estado y las reglas añadirás la regla que impide dos jugadores con el mismo nombre en un equipo, para que esa búsqueda nunca sea ambigua.

Probarlo. Ejecuta la app (Run, variante gratisDebug) sin desinstalar: Android actualiza la instalación y Room migra la base de la versión 3 a la 4 al abrirla. La migración crea jugador vacía, y el repositorio la llena al arrancar.

  1. En Partidos, el partido 1 conserva el mismo resultado que anotaste al empezar.
  2. En Tablas, la tabla y los goleadores salen igual que antes de actualizar.
  3. En la Terminal, ./gradlew :app:testGratisDebugUnitTest sigue en verde.

Punto de control

  • ./gradlew :app:assembleGratisDebug termina en BUILD SUCCESSFUL y 4.json aparece en app/schemas/…/MarcadorDatabase/ junto a 1.json, 2.json y 3.json.
  • ./gradlew :app:testGratisDebugUnitTest termina en BUILD SUCCESSFUL y cuenta 16 tests (19 si hiciste el ejercicio del temporizador de Tests de un ViewModel: fakes, setMain y Turbine) y 0 fallos (esta lección no añade tests).
  • Instalada encima de la versión 3, la app conserva el resultado del partido 1, y Partidos y Tablas salen como antes.
  • Para ver la tabla con tus ojos: con la app en marcha, abre View → Tool Windows → App Inspection (en tu versión de Android Studio el menú puede llamarse distinto; si no lo encuentras, pulsa dos veces Shift y escribe «App Inspection»), elige el proceso de Marcador, pestaña Database Inspector, y abre marcador.db → jugador: tiene 8 filas, también en un móvil o emulador que ya tenía la app. Si no das con la ventana, sigue adelante: en la lección siguiente dibujas la plantilla y verás a los jugadores en pantalla.
  • No existe PartidosRepositoryEnMemoria.kt, y PartidosRepository ya no tiene la propiedad equipos.

En Una fila que se toca: la plantilla dibujas por fin esa plantilla en pantalla.

Comprueba lo que entendiste

5 preguntas · necesitas 4 para completar la lección

  1. 1.Subes la base a la versión 4 con una tabla nueva, jugador, y declaras AutoMigration(from = 3, to = 4). Un móvil que venía de la versión 3 abre la app. ¿Qué tiene la tabla jugador?

  2. 2.El repositorio siembra los partidos solo si contarPartidos() == 0. ¿Por qué la siembra de jugadores usa su propia comprobación, contarJugadores() == 0?

  3. 3.Equipo es una clase normal, sin equals. Lees la tabla dos veces y reconstruyes el mismo equipo con groupBy. ¿Qué devuelve primeraLectura.contains(equipoDeLaSegunda)?

  4. 4.¿Para qué sirve .onStart { sembrado.join() } al final de observarPartidos()?

  5. 5.armarTorneo deja de recibir la lista de equipos y los saca de los propios partidos. ¿Por qué?

Faltan 5 de 5