Saltar al contenido
K
Jetpack y arquitecturaPaso a paso40 min de lectura · 20 min de máquina Básico

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):

  1. Observar: dos tablas, una lista. Cada partido necesita sus goles, que están en otra tabla. combine junta los dos Flow del DAO y rehace la lista cuando cualquiera cambia: un gol nuevo (un INSERT en gol) refresca la pantalla sin recargar nada a mano. groupBy agrupa los goles por partidoId, y orEmpty() da una lista vacía si un partido aún no tiene goles.
  2. Escribir: convertir y guardar. registrarGol recibe un Gol del dominio, lo convierte en una fila y llama al DAO. El id no se da: la base lo asigna (autoGenerate).
  3. Sembrar: llenar la base la primera vez. Al abrir la app por primera vez la base está vacía. sembrado es una corrutina que, si la tabla partido no tiene filas, inserta los 18 partidos de ejemplo y sus goles; corre en segundo plano, por eso el repositorio recibe un CoroutineScope. Es un Job: partido(id) y registrarGol llaman a sembrado.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:

ejemplo mínimo: sembrar una vez y esperar (con comentarios)
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 ese Job en una propiedad.
  • contenido(): una función suspend que espera a que termine la siembra y devuelve una copia de la lista.
  • En main (con runBlocking): crea la despensa con el alcance de runBlocking e imprime lo que devuelve contenido(), 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
solución: sembrar y esperar
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.

app → kotlin+java → com.sebastiannarvaez.marcador
di
DatosModule.ktse modifica
feature
partidos
domain
Partidos.ktse modifica
data
PartidosRepositoryEnMemoria.ktse modifica
PartidosRepositoryRoom.ktse crea
Un fichero se crea y tres se modifican; Room guarda los datos en marcador.db.

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.kt y el repositorio en memoria: PartidosRepository gana observarGoleadores(), que Room resuelve con la consulta GROUP BY del 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 receta partidos() 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 @Provides y no con @Inject porque PartidosRepositoryRoom tiene un parámetro con valor por defecto (la lista de equipos), que Hilt no entiende. Quien pide un PartidosRepository no se entera del cambio.
  • MarcadorApplication no 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:

  1. Los goleadores en la interfaz: PartidosRepository gana la función de observar goleadores, como una lista de Goleador (jugador y goles), y el repositorio en memoria la cumple contando los goles de sus partidos, en el mismo orden que el ranking.
  2. El repositorio (PartidosRepositoryRoom), que recibe el DAO, un CoroutineScope y la lista de equipos (esta última con override 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.
  3. La receta partidos() de DatosModule: sustitúyela para que entregue PartidosRepositoryRoom, 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.

los imports de PartidosRepositoryRoom.kt (los propone el IDE)
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.launch
Intenta 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.

app/src/main/java/com/sebastiannarvaez/marcador/feature/partidos/domain/Partidos.kt
    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).

app/src/main/java/com/sebastiannarvaez/marcador/feature/partidos/data/PartidosRepositoryEnMemoria.kt
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.

app/src/main/java/com/sebastiannarvaez/marcador/feature/partidos/data/PartidosRepositoryRoom.kt
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")
}
app/src/main/java/com/sebastiannarvaez/marcador/di/DatosModule.kt
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:assembleGratisDebug

Debe 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:assembleGratisDebug termina en BUILD 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 (am es el gestor de actividades de Android; ese es el id de la variante gratisDebug; 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. 1.PartidosRepositoryRoom llama a sembrado.join() antes de pedir un partido o registrar un gol. ¿Para qué?

  2. 2.observarPartidos del repositorio de Room usa combine con dos Flow del DAO. ¿Para qué?

  3. 3.¿Qué hace el repositorio de Room con una fila de partido para devolver un Partido del dominio?

  4. 4.En DatosModule, la receta partidos() pasa a entregar PartidosRepositoryRoom. ¿Qué ocurre con la receta que entregaba la lista en memoria?

  5. 5.¿Por qué PartidosRepositoryRoom se entrega con una receta @Provides y no con @Inject en su constructor?

Faltan 5 de 5