El repositorio con Room
Al terminar: Que PartidosRepositoryRoom traduzca la base al dominio y que Hilt lo entregue en lugar de la lista en memoria.
Al terminar esta lección, los partidos de Marcador viven en una base de datos SQLite y no en una lista en memoria. Registras un gol, cierras la app del todo, la abres otra vez y el gol sigue ahí. El repositorio cambia por dentro y ni los casos de uso ni los ViewModels se enteran.
Importa porque hasta ahora cada vez que cerrabas Marcador perdía los goles. La base ya está declarada y Hilt sabe abrirla (Hilt abre la base de datos); aquí escribes la pieza que falta, el repositorio, la clase que une el DAO con el resto de la app, y le dices a Hilt que entregue ese en lugar de la lista en memoria. Esta lección deja la base en la versión 1 de su esquema. La siguiente, Room: migrar la base de datos, la cambia con datos ya guardados. Temas: el repositorio que traduce filas a partidos, la siembra de la base y cambiar una receta de Hilt.
El repositorio: lo que une el DAO con la app
El DAO devuelve entidades (PartidoEntity, GolEntity), pero la app trabaja con Partido y Gol. Quien traduce es PartidosRepositoryRoom, la segunda implementación de PartidosRepository y la única clase que conoce el DAO.
Hace tres cosas (el código completo está en el paso):
- Observar: dos tablas, una lista. Cada partido necesita sus goles, que están en otra tabla.
combinejunta los dosFlowdel DAO y rehace la lista cuando cualquiera cambia: un gol nuevo (unINSERTengol) refresca la pantalla sin recargar nada a mano.groupByagrupa los goles porpartidoId, yorEmpty()da una lista vacía si un partido aún no tiene goles. - Escribir: convertir y guardar.
registrarGolrecibe unGoldel dominio, lo convierte en una fila y llama al DAO. Elidno se da: la base lo asigna (autoGenerate). - Sembrar: llenar la base la primera vez. Al abrir la app por primera vez la base está vacía.
sembradoes una corrutina que, si la tablapartidono tiene filas, inserta los 18 partidos de ejemplo y sus goles; corre en segundo plano, por eso el repositorio recibe unCoroutineScope. Es unJob:partido(id)yregistrarGolllaman asembrado.join()para esperar a que termine; sin esa espera, pedir el partido 1 antes de que exista daría «No existe el partido».
Y la traducción: aDominio construye un Partido a partir de las filas y busca cada equipo en el catálogo por nombre: la fila solo guarda el nombre del equipo (un texto), pero el dominio necesita el Equipo entero, con su plantilla, y el catálogo ya los tiene. Hay que recuperar ese mismo Equipo, no crear una copia con el mismo nombre, porque Equipo no define equals (la función que decide cuándo dos objetos cuentan como iguales) y Torneo solo reconoce las mismas instancias.
La siembra, con el recetario de Room: entidades y DAO y los mismos datos inventados:
val sembrado: Job = alcance.launch { // launch devuelve un Job
if (dao.contarRecetas() == 0) dao.insertarReceta(RecetaEntity(nombre = "Tortilla")) // solo si la base está vacía
}
suspend fun ingredientes(id: Long): List<IngredienteEntity> {
sembrado.join() // espera a que termine la siembra
return dao.ingredientesDe(id)
}Ejercicio · sembrar y esperar
Antes de tocar Marcador, practica la espera con Kotlin puro. Usarás launch y Job.join() (Alcance, Job y cancelación), delay (una pausa que no bloquea) y runBlocking, que arranca un programa con corrutinas desde main.
Reto · Ejercicio: una despensa que se llena sola
En el Kotlin Playground, fuera de tu proyecto. Vas a reproducir la siembra con una despensa en lugar de una base.
Despensa(alcance: CoroutineScope): tiene una lista de estantes que empieza vacía. Nada más crearla lanza, en el alcance que recibe, una corrutina que espera 100 milisegundos y, si la lista sigue vacía, la llena con «Harina» y «Aceite». Guarda eseJoben una propiedad.contenido(): una funciónsuspendque espera a que termine la siembra y devuelve una copia de la lista.- En
main(conrunBlocking): crea la despensa con el alcance derunBlockinge imprime lo que devuelvecontenido(), pedido nada más crearla.
La consola debe mostrar:
[Harina, Aceite]Pista: sin la espera, contenido() se ejecutaría antes de que acabe el delay y saldría []. Quita la línea de la espera para verlo.
Ver solución
import kotlinx.coroutines.*
class Despensa(alcance: CoroutineScope) {
private val estantes = mutableListOf<String>()
private val llenado = alcance.launch { // el Job de la siembra
delay(100) // tarda un poco, como una base real
if (estantes.isEmpty()) estantes += listOf("Harina", "Aceite")
}
suspend fun contenido(): List<String> {
llenado.join() // espera a que la siembra haya terminado
return estantes.toList()
}
}
fun main() = runBlocking {
val despensa = Despensa(this)
println(despensa.contenido()) // [Harina, Aceite]
}Sale [Harina, Aceite]. Es exactamente lo que hacen sembrado y sembrado.join() en el repositorio. Sin el join, el programa imprimiría [].
Paso · Escribe el repositorio y cámbialo en Hilt
Objetivo. PartidosRepositoryRoom guarda los partidos y sus goles en marcador.db, y Hilt lo entrega en lugar de la lista en memoria. La app se ve igual que antes, pero un gol registrado sobrevive a cerrar la app.
Crear el fichero: clic derecho sobre el paquete data (en app → kotlin+java → com.sebastiannarvaez.marcador → feature → partidos) → New → Kotlin Class/File → elige File y escribe PartidosRepositoryRoom. Va junto al repositorio en memoria porque es otra forma de guardar los mismos partidos: lo que habla con la base está en data, y la base misma, que comparten varias features, quedó en core.database. El título de los bloques de abajo (app/src/main/java/…) es la ruta del fichero en tu proyecto, dentro del nodo app → kotlin+java que acabas de ver.
Qué cambia fuera de PartidosRepositoryRoom, y por qué.
Partidos.kty el repositorio en memoria:PartidosRepositoryganaobservarGoleadores(), que Room resuelve con la consultaGROUP BYdel DAO y que usará la pantalla de goleadores en Navegación en Marcador. Toda implementación debe tener el método, así que la lista en memoria también lo recibe; ya nadie la usa, pero sigue siendo válida.DatosModule: la recetapartidos()deja de entregar la lista en memoria y entrega el repositorio de Room. Esa receta sustituye a la que tenías: no queda ninguna de memoria. Pide el DAO y el alcance etiquetado, que Hilt ya sabe dar. Va con@Providesy no con@InjectporquePartidosRepositoryRoomtiene un parámetro con valor por defecto (la lista de equipos), que Hilt no entiende. Quien pide unPartidosRepositoryno se entera del cambio.MarcadorApplicationno se toca.
Intenta escribirlo tú
Piezas de este paso: las de Room y las de Hilt las tienes de la lección anterior; el repositorio usa las funciones de colecciones y de corrutinas del recuadro de abajo.
Ya vistas: Job.join() (en el ejercicio de arriba); filterIsInstance (Lambdas con listas: filter, map y reglas); compareByDescending { … }.thenBy { … } (La tabla de posiciones); groupingBy { … }.eachCount() (Secuencias y formato de tablas); groupBy { … } (como groupingBy, pero guarda en un mapa las propias filas de cada grupo, no solo cuántas son); orEmpty() (una lista que puede ser null pasa a ser una lista vacía); leer un mapa con mapa[clave] (da null si la clave no está); first { … } (el primer elemento que cumple la condición, y falla si no hay ninguno); firstOrNull { … } (lo mismo, pero da null si no hay); error("texto") (lanza una excepción con ese mensaje); y it.key e it.value (la clave y el valor de una entrada de un mapa).
Hazlo en este orden:
- Los goleadores en la interfaz:
PartidosRepositorygana la función de observar goleadores, como una lista deGoleador(jugador y goles), y el repositorio en memoria la cumple contando los goles de sus partidos, en el mismo orden que el ranking. - El repositorio (
PartidosRepositoryRoom), que recibe el DAO, unCoroutineScopey la lista deequipos(esta última conoverride val, porque la interfaz la declara): al crearse, si la base está vacía, la llena con los partidos de ejemplo y sus goles; las lecturas y el registro de un gol esperan a que termine eso; convierte las filas en partidos del dominio, y falla con «Equipo desconocido: Pumas» si una fila nombra un equipo que no conoce. Los goleadores los saca del DAO. - La receta
partidos()deDatosModule: sustitúyela para que entreguePartidosRepositoryRoom, con el DAO y el alcance etiquetado que le pide.
Lo que debes ver: la app igual que antes; un gol registrado sigue ahí tras cerrar la app y abrirla. Los imports de PartidosRepositoryRoom.kt los propone el IDE con Option+Enter; estos son de tu código (los de la base, el dominio y :torneo, que no son de Room), por si dudas de cuál elegir. DatosDeEjemplo no hace falta importarlo: está en la misma carpeta, data.
import com.sebastiannarvaez.marcador.core.database.GolEntity
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.Partido
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launchIntenta primero. Cuando termines, compara con la solución.Mostrar el código
Los dos primeros bloques son trozos de ficheros que ya existen: el resto de cada fichero no cambia.
fun observarGoleadores(): Flow<List<Goleador>>
}
data class Goleador(val jugador: String, val goles: Int)En el fichero, busca la } que cierra la interfaz PartidosRepository y sustitúyela por estas líneas (data class Goleador va después de la interfaz).
import com.sebastiannarvaez.marcador.feature.partidos.domain.Goleador
import kotlinx.coroutines.flow.map
override fun observarGoleadores(): Flow<List<Goleador>> = lista.map { partidos ->
partidos.flatMap { it.partido.eventos.filterIsInstance<Gol>() }
.groupingBy { it.jugador.nombre }.eachCount()
.map { Goleador(it.key, it.value) }
.sortedWith(compareByDescending<Goleador> { it.goles }.thenBy { it.jugador })
}Los dos import van con los demás; el método va al final de la clase, antes de la } que la cierra.
class PartidosRepositoryRoom(
private val dao: PartidoDao,
alcance: CoroutineScope,
override val equipos: List<Equipo> = DatosDeEjemplo.torneo.equipos,
) : PartidosRepository {
private val sembrado = alcance.launch {
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)
}
},
)
}
}
override fun observarPartidos(): Flow<List<PartidoDeLista>> =
combine(dao.observarPartidos(), dao.observarGoles()) { partidos, goles ->
val golesPorPartido = goles.groupBy { it.partidoId }
partidos.map { PartidoDeLista(it.id, aDominio(it, golesPorPartido[it.id].orEmpty())) }
}
override suspend fun partido(id: Int): Partido? {
sembrado.join()
val fila = dao.partido(id) ?: return null
return aDominio(fila, dao.golesDe(id))
}
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))
}
override fun observarGoleadores(): Flow<List<Goleador>> =
dao.observarGoleadores().map { filas -> filas.map { Goleador(it.jugador, it.goles) } }
private fun aDominio(fila: PartidoEntity, goles: List<GolEntity>): Partido {
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)
},
)
}
private fun equipo(nombre: String): Equipo =
equipos.firstOrNull { it.nombre == nombre } ?: error("Equipo desconocido: $nombre")
}package com.sebastiannarvaez.marcador.di
import android.content.Context
import com.sebastiannarvaez.marcador.core.database.MarcadorDatabase
import com.sebastiannarvaez.marcador.core.database.PartidoDao
import com.sebastiannarvaez.marcador.core.database.crearBaseDeDatos
import com.sebastiannarvaez.marcador.feature.partidos.data.PartidosRepositoryRoom
import com.sebastiannarvaez.marcador.feature.partidos.domain.PartidosRepository
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
@Module
@InstallIn(SingletonComponent::class)
object DatosModule {
@Provides
@Singleton
fun baseDeDatos(@ApplicationContext contexto: Context): MarcadorDatabase = crearBaseDeDatos(contexto)
@Provides
fun partidoDao(base: MarcadorDatabase): PartidoDao = base.partidoDao()
@Provides
@Singleton
@AlcanceDeApp
fun alcanceDeApp(): CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
@Provides
@Singleton
fun partidos(dao: PartidoDao, @AlcanceDeApp alcance: CoroutineScope): PartidosRepository =
PartidosRepositoryRoom(dao, alcance)
}En el fichero, sustituye la receta partidos() de memoria por la de arriba (la que pide el DAO y el alcance etiquetado), cambia el import de PartidosRepositoryEnMemoria por el de PartidosRepositoryRoom y deja las otras tres recetas como están. Ya nadie usa la lista en memoria, pero el fichero se queda en el proyecto por ahora: lo borrarás en La plantilla, guardada en Room.
Cuando tengas los cuatro ficheros, compila desde la Terminal:
./gradlew :app:assembleGratisDebugDebe terminar en BUILD SUCCESSFUL. Si Android Studio marca algo en rojo, suele ser un import de Room sin elegir: los de Room son androidx.room3.…, no androidx.room. Los errores típicos de Room y KSP están en la caja de Room: entidades y DAO.
Ejecuta la app con la variante gratisDebug activa (Run; el emulador se arranca como en La Activity) y sigue el punto de control.
Punto de control
./gradlew :app:assembleGratisDebugtermina enBUILD SUCCESSFUL.- La pestaña Partidos muestra los 18 partidos de ejemplo, igual que antes.
- En la pestaña Marcador, «Gol Rayo FC» sube el partido 1 de 3-2 a 4-2 en Partidos. Cierra la app del todo, de una de las dos formas: deslizándola hacia arriba en recientes (como en la lección anterior) o, en la Terminal de Android Studio, con
adb shell am force-stop com.sebastiannarvaez.marcador.debug(ames el gestor de actividades de Android; ese es el id de la variantegratisDebug; sirve desde cualquier carpeta). Ábrela: la fila del partido 1 sigue en 4-2. La pestaña Marcador empieza otra vez en 0-0: el directo es nuevo, lo que persiste es la fila de Partidos. Antes de esta lección el gol se perdía. - No desinstales la app ni borres sus datos: la lección siguiente necesita esta base, con su gol, en la versión 1.
Comprueba lo que entendiste
5 preguntas · necesitas 4 para completar la lección
1.
PartidosRepositoryRoomllama asembrado.join()antes de pedir un partido o registrar un gol. ¿Para qué?2.
observarPartidosdel repositorio de Room usacombinecon dosFlowdel DAO. ¿Para qué?3.¿Qué hace el repositorio de Room con una fila de
partidopara devolver unPartidodel dominio?4.En
DatosModule, la recetapartidos()pasa a entregarPartidosRepositoryRoom. ¿Qué ocurre con la receta que entregaba la lista en memoria?5.¿Por qué
PartidosRepositoryRoomse entrega con una receta@Providesy no con@Injecten su constructor?