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

Hilt abre la base de datos

Al terminar: Que Hilt construya la base de datos de Room y entregue el DAO: @ApplicationContext, una receta con Context y el primer cualificador.

Al terminar esta lección, Marcador tiene sus tablas escritas (partidos y goles), la base de datos declarada y Hilt sabe abrirla: DatosModule entrega la base, su DAO y un alcance de corrutinas etiquetado. La app se ve exactamente igual que antes, porque todavía nadie le pide esas piezas a Hilt: eso lo hace la lección siguiente, El repositorio con Room.

Importa porque abrir una base de datos necesita un Context, y de los varios que hay solo uno vale: el que dura toda la app. Hilt lo da con una etiqueta, @ApplicationContext, y otra etiqueta que inventas tú, un cualificador, sirve para distinguir dos piezas del mismo tipo. Son las dos ideas nuevas de Hilt de esta lección. Temas: @ApplicationContext, cualificadores (annotation class), el DAO con GROUP BY y la receta de la base en DatosModule.

Dos piezas de Hilt que aún no conoces

La base necesita un Context. Abrir un fichero de la app requiere un Context (Context y Application). Una receta de un módulo pide lo que necesita entre sus paréntesis, igual que un constructor con @Inject. Pero Hilt no tiene un Context suelto que dar, porque hay varios: el de la Application, que vive mientras vive la app, y el de cada Activity, que muere al girar la pantalla. Se elige con una etiqueta que ya trae Hilt, @ApplicationContext, delante del parámetro: «dame el de la Application». Guardar el de una Activity en una pieza que dura toda la app sería una fuga (Fugas de memoria), y con esta etiqueta no puede pasar.

Dos piezas del mismo tipo: el cualificador. El repositorio de la lección siguiente necesitará un CoroutineScope para llenar la base la primera vez. Ese alcance dura lo que dura el proceso: nadie lo cancela, y está bien, porque esa carga no debe cortarse al cambiar de pantalla. Hilt busca las piezas por tipo: si mañana hubiera dos CoroutineScope, o dos Int, no sabría cuál dar. Un cualificador es una etiqueta que inventas tú: se escribe una vez, se pone en la receta que entrega la pieza y en el parámetro que la pide, y Hilt solo empareja los que llevan la misma.

Una anotación (@Provides, @Singleton…) es una etiqueta que se escribe delante de una clase, una función o un parámetro. Para inventar la tuya se escribe una annotation class: una clase sin cuerpo cuya única misión es existir como etiqueta. Se marca con @Qualifier («esta etiqueta es de las que Hilt empareja») y con @Retention(AnnotationRetention.BINARY), que dice cuánto tiempo se conserva la etiqueta: BINARY es «en el código ya compilado», que es lo único que Hilt necesita. Copia esas dos líneas tal cual cada vez.

Un ejemplo con datos ajenos a Marcador: un termómetro que debe leer la temperatura del horno y no la de la cocina, aunque las dos son un Int.

ejemplo mínimo: dos Int distinguidos con etiquetas (con comentarios)
@Qualifier                                   // esta anotación va a ser una etiqueta de Hilt
@Retention(AnnotationRetention.BINARY)       // se conserva en el código compilado
annotation class Cocina                      // la etiqueta «cocina»
 
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class Horno                       // la etiqueta «horno»
 
@Module
@InstallIn(SingletonComponent::class)
object TemperaturasModule {
    @Provides @Cocina                        // receta de un Int etiquetado «cocina»
    fun cocina(): Int = 22
 
    @Provides @Horno                         // receta de otro Int, etiquetado «horno»
    fun horno(): Int = 200
}
 
class Termometro @Inject constructor(@Horno private val grados: Int) {   // pide el Int «horno»
    fun leer() = "El horno marca $grados grados"
}

Hilt entrega a Termometro el 200, no el 22, y leer() devuelve El horno marca 200 grados. Sin las etiquetas habría dos recetas para el mismo tipo y Hilt se negaría a compilar, porque no sabe cuál elegir.

En Marcador la etiqueta se llama @AlcanceDeApp: marca un CoroutineScope pensado para trabajo que no es de ninguna pantalla. Hoy es el único, pero con la etiqueta quien lo pide dice exactamente cuál quiere, y nadie tiene que revisar nada si un día aparece otro.

El ranking, antes del ejercicio

GoleadorFila, que verás en el paso, no es una tabla. Una consulta con GROUP BY devuelve filas con un jugador y un total, y Room necesita una clase donde dejarlas: sus propiedades se llaman como los alias del SELECT. El ejercicio de abajo reproduce ese ranking sin base de datos.

Ya vistas, y dos cosas nuevas. Para el ejercicio usarás: groupingBy { … }.eachCount() (Secuencias y formato de tablas) y compareByDescending { … }.thenBy { … } (La tabla de posiciones). Las dos nuevas: Map.toList() convierte un mapa en una lista de pares (Pair), y un par se desestructura con (jugador, total), como las dos partes de una lambda: { (jugador, total) -> … }. compareByDescending<Pair<String, Int>> { it.second } ordena pares por su segundo valor, de mayor a menor.

Reto · Ejercicio: el ranking de goleadores, sin base de datos

Fuera de Marcador, en el Kotlin Playground. Vas a reproducir con colecciones lo que hace la consulta GROUP BY del DAO.

Parte de una lista con el autor de cada gol, en este orden: Ana, Luis, Ana, Marta, Luis, Ana y Marta. Cuenta los goles de cada jugador, ordénalos de mayor a menor y, en caso de empate, por nombre, e imprime una línea por jugador. Lo que debe salir:

Ana: 3
Luis: 2
Marta: 2

Pista: contar cuántas veces sale cada elemento de una lista es lo que hace groupingBy { … }.eachCount().

Ver solución
solución: el GROUP BY con colecciones
fun main() {
    val goles = listOf("Ana", "Luis", "Ana", "Marta", "Luis", "Ana", "Marta")
 
    goles
        .groupingBy { it }.eachCount()                     // una entrada por jugador: el GROUP BY y el COUNT(*)
        .toList()
        .sortedWith(compareByDescending<Pair<String, Int>> { it.second }.thenBy { it.first })  // ORDER BY goles DESC, jugador
        .forEach { (jugador, total) -> println("$jugador: $total") }
}

Sale Ana: 3, Luis: 2 y Marta: 2. En la base, GROUP BY jugador agrupa, COUNT(*) cuenta y ORDER BY goles DESC, jugador ordena; la diferencia es que Room lo hace en SQLite, sin traer todas las filas a la memoria.

Paso · Las tablas, la base y las recetas de Hilt

Objetivo. Los partidos y sus goles tienen su esquema (versión 1) y DatosModule sabe entregar la base, el DAO y el alcance etiquetado. Todo compila. La app se ve igual: las recetas esperan a que alguien las pida, y Hilt lo permite.

app → kotlin+java → com.sebastiannarvaez.marcador
di
AlcanceDeApp.ktse crea
DatosModule.ktse modifica
core
databasepaquete nuevo
Entidades.ktse crea
PartidoDao.ktse crea
MarcadorDatabase.ktse crea
Cuatro ficheros se crean y uno se modifica; Room genera además un esquema (1.json) y código en build/.

Crear los ficheros: en app → kotlin+java, clic derecho sobre com.sebastiannarvaez.marcador → New → Package; el campo ya trae com.sebastiannarvaez.marcador: escribe .core.database al final (con el punto delante). core ya existe (la creaste con core.domain), así que Android Studio solo añade database dentro; si lo ves en una línea como core.database, es la misma carpeta (puede juntar en una línea las carpetas que solo tienen una hija). La base es una sola y la compartirán partidos, plantilla, goleadores y árbitros: no es de ninguna feature, por eso va en core y no dentro de una de ellas. Después, clic derecho sobre database → New → Kotlin Class/File → elige File y escribe el nombre (Entidades, PartidoDao, MarcadorDatabase). 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.

Intenta escribirlo tú

Piezas de este paso: las de Room ya las viste en el recetario de Room: entidades y DAO: @Entity con foreignKeys e indices, @PrimaryKey(autoGenerate = true), @Dao con @Query y @Insert, @Database con exportSchema = true, Room.databaseBuilder con su driver, las listas [ … ] dentro de una anotación y los alias AS. Lo único que cambia son los nombres: partidos y goles en vez de recetas e ingredientes. Las de Hilt son las de arriba: @Provides, @Singleton, @ApplicationContext y tu cualificador. Y para el alcance, CoroutineScope(SupervisorJob() + Dispatchers.Default): un alcance se monta sumando con + un Job, que dice qué pasa si una corrutina hija falla (con SupervisorJob, el fallo de una no cancela a las hermanas: Errores en corrutinas), y un dispatcher, que dice en qué hilos corre (Dispatchers y withContext); Dispatchers.Default son hilos de segundo plano.

Hazlo en este orden:

  1. Las entidades (Entidades.kt): una tabla partido con el id, el local y el visitante; una tabla gol con un id que pone la base, el id de su partido (unido a la tabla de partidos, con índice, y que se borra si se borra el partido), el minuto, el jugador y el equipo; y GoleadorFila, la forma de una fila del ranking: un jugador y sus goles.
  2. El DAO (PartidoDao): observar todos los partidos por id, todos los goles por minuto y el ranking de goleadores (de más a menos goles y, si empatan, por nombre); y, una sola vez, contar los partidos, leer un partido por id, leer los goles de un partido, insertar una lista de partidos, una de goles y un gol suelto.
  3. La base (MarcadorDatabase), en la versión 1 y exportando su esquema, con su DAO, y una función, crearBaseDeDatos, que la abre en el fichero marcador.db con el driver de SQLite de Android.
  4. La etiqueta @AlcanceDeApp (un fichero nuevo, AlcanceDeApp.kt, en el paquete di, junto a DatosModule: di guarda lo que sirve para montar las piezas con Hilt, y la etiqueta es una de ellas).
  5. Las recetas de DatosModule, junto a la de partidos(), que no se toca: una para la base (única en toda la app, con @ApplicationContext), una para el DAO (la base ya lo guarda, no hace falta que sea única) y una para el alcance etiquetado (también único).

Lo que debes ver: la app igual que antes. Los bloques de Room traen la línea package y no los import: Android Studio los propone con Option+Enter sobre el nombre en rojo. Cuidado con los de Room: son androidx.room3.…, no androidx.room. Son los mismos de la lección anterior:

los imports de Room y de SQLite (los propone el IDE)
import androidx.room3.Dao
import androidx.room3.Database
import androidx.room3.Entity
import androidx.room3.ForeignKey
import androidx.room3.Index
import androidx.room3.Insert
import androidx.room3.PrimaryKey
import androidx.room3.Query
import androidx.room3.Room
import androidx.room3.RoomDatabase
import androidx.sqlite.driver.AndroidSQLiteDriver

Cada fichero usa solo los suyos: Entidades.kt, los de @Entity, @PrimaryKey, @ForeignKey e @Index; el DAO, @Dao, @Query e @Insert; la base, @Database, Room, RoomDatabase y AndroidSQLiteDriver.

Intenta primero. Cuando termines, compara con la solución.Mostrar el código
app/src/main/java/com/sebastiannarvaez/marcador/core/database/Entidades.kt
@Entity(tableName = "partido")
data class PartidoEntity(
    @PrimaryKey val id: Int,
    val local: String,
    val visitante: String,
)
 
@Entity(
    tableName = "gol",
    foreignKeys = [
        ForeignKey(
            entity = PartidoEntity::class,
            parentColumns = ["id"],
            childColumns = ["partidoId"],
            onDelete = ForeignKey.CASCADE,
        ),
    ],
    indices = [Index("partidoId")],
)
data class GolEntity(
    @PrimaryKey(autoGenerate = true) val id: Long = 0,
    val partidoId: Int,
    val minuto: Int,
    val jugador: String,
    val equipo: String,
)
 
data class GoleadorFila(val jugador: String, val goles: Int)

GoleadorFila no es una tabla: es la forma del resultado de una consulta con GROUP BY (la del ejercicio de arriba), y sus nombres deben coincidir con los alias del SELECT.

app/src/main/java/com/sebastiannarvaez/marcador/core/database/PartidoDao.kt
@Dao
interface PartidoDao {
 
    @Query("SELECT * FROM partido ORDER BY id")
    fun observarPartidos(): Flow<List<PartidoEntity>>
 
    @Query("SELECT * FROM gol ORDER BY minuto, id")
    fun observarGoles(): Flow<List<GolEntity>>
 
    @Query("SELECT jugador, COUNT(*) AS goles FROM gol GROUP BY jugador ORDER BY goles DESC, jugador")
    fun observarGoleadores(): Flow<List<GoleadorFila>>
 
    @Query("SELECT COUNT(*) FROM partido")
    suspend fun contarPartidos(): Int
 
    @Query("SELECT * FROM partido WHERE id = :id")
    suspend fun partido(id: Int): PartidoEntity?
 
    @Query("SELECT * FROM gol WHERE partidoId = :partidoId ORDER BY minuto, id")
    suspend fun golesDe(partidoId: Int): List<GolEntity>
 
    @Insert
    suspend fun insertarPartidos(partidos: List<PartidoEntity>)
 
    @Insert
    suspend fun insertarGoles(goles: List<GolEntity>)
 
    @Insert
    suspend fun insertarGol(gol: GolEntity)
}

Los Flow del DAO necesitan import kotlinx.coroutines.flow.Flow, el mismo de Flow: scan y combine.

app/src/main/java/com/sebastiannarvaez/marcador/core/database/MarcadorDatabase.kt
@Database(
    entities = [PartidoEntity::class, GolEntity::class],
    version = 1,
    exportSchema = true,
)
abstract class MarcadorDatabase : RoomDatabase() {
    abstract fun partidoDao(): PartidoDao
}
 
fun crearBaseDeDatos(contexto: Context): MarcadorDatabase =
    Room.databaseBuilder<MarcadorDatabase>(
        context = contexto.applicationContext,
        name = contexto.getDatabasePath("marcador.db").absolutePath,
    )
        .setDriver(AndroidSQLiteDriver())
        .build()

getDatabasePath("marcador.db") da la ruta completa del fichero en la carpeta privada de la app. El Context de crearBaseDeDatos es android.content.Context.

app/src/main/java/com/sebastiannarvaez/marcador/di/AlcanceDeApp.kt
package com.sebastiannarvaez.marcador.di
 
import javax.inject.Qualifier
 
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class AlcanceDeApp

Fichero nuevo: clic derecho sobre di → New → Kotlin Class/File → elige File y escribe AlcanceDeApp.

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.PartidosRepositoryEnMemoria
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 partidos(): PartidosRepository = PartidosRepositoryEnMemoria()
 
    @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)
}

En el fichero, la receta partidos() se queda como estaba; añade las tres de debajo y los imports nuevos.

Qué hace cada receta:

  • baseDeDatos es @Singleton: una sola base para toda la app, porque abrir dos sobre el mismo fichero da problemas. Recibe el Context de la Application con @ApplicationContext; no hay que tocar MarcadorApplication, porque Hilt ya conoce ese Context desde que la anotaste con @HiltAndroidApp.
  • partidoDao no lleva @Singleton: el DAO lo guarda la propia base, y pedirlo dos veces es barato. Pide MarcadorDatabase y Hilt usa la receta de arriba.
  • alcanceDeApp entrega el CoroutineScope con la etiqueta, único en toda la app.

Compila desde la Terminal de Android Studio (View → Tool Windows → Terminal):

./gradlew :app:assembleGratisDebug

Debe terminar en BUILD SUCCESSFUL, aunque nadie use todavía la base, el DAO ni el alcance: Hilt revisa una receta cuando alguien la pide, y hasta la lección siguiente nadie lo hace. Ahora KSP ha trabajado con tus tablas; mira lo que ha producido.

  • El esquema. Room ha escrito 1.json en app/schemas. Compruébalo en la Terminal: ls app/schemas/com.sebastiannarvaez.marcador.core.database.MarcadorDatabase. Debe salir 1.json. La vista Android no suele mostrar esa carpeta; la Terminal sí la confirma.
  • El código generado. ls app/build/generated/ksp/gratisDebug/kotlin/com/sebastiannarvaez/marcador/core/database muestra MarcadorDatabase_Impl.kt y PartidoDao_Impl.kt: los ficheros que Room ha escrito por ti, con las consultas del DAO ya traducidas. No los edites: se regeneran en cada compilación.

Si algo falla, los errores típicos de Room y KSP (falta la línea ksp(…), imports androidx.room en vez de androidx.room3, falta el plugin de Room) están en la caja de Room: entidades y DAO; con MarcadorDatabase dicen lo mismo con otros nombres.

Punto de control

  • ./gradlew :app:assembleGratisDebug termina en BUILD SUCCESSFUL, y 1.json existe en app/schemas/com.sebastiannarvaez.marcador.core.database.MarcadorDatabase.
  • MarcadorDatabase_Impl.kt y PartidoDao_Impl.kt aparecen en app/build/generated/ksp/gratisDebug/….
  • Si ejecutas la app (variante gratisDebug), se ve igual que antes: los 18 partidos de la lista, y los goles se siguen perdiendo al cerrar la app. La base todavía no se usa; la lección siguiente la conecta.
  • No desinstales la app: la lección siguiente guarda en ella los primeros datos.

Comprueba lo que entendiste

5 preguntas · necesitas 4 para completar la lección

  1. 1.¿Por qué la receta de la base de datos en DatosModule pide el Context con @ApplicationContext?

  2. 2.¿Para qué sirve la etiqueta @AlcanceDeApp (un cualificador) de CoroutineScope?

  3. 3.Hay dos recetas que entregan un Int, una con @Cocina y otra con @Horno, y Termometro pide @Horno private val grados: Int. ¿Qué recibe?

  4. 4.Al terminar la lección, DatosModule tiene recetas para la base, el DAO y el alcance, y nadie las pide todavía. ¿Qué pasa al compilar?

  5. 5.GoleadorFila no es una tabla de la base. ¿Qué es?

Faltan 5 de 5