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

Room: entidades y DAO

Al terminar: Instalar Room y guardar los partidos: entidades, DAO y consultas con Flow.

Al terminar esta lección, tu proyecto tiene Room instalado y funcionando, y has guardado datos de verdad: una pequeña biblioteca de libros que sigue ahí cuando cierras la app del todo y la abres otra vez. Todavía no tocas los partidos de Marcador: eso lo hace El repositorio con Room, con lo que aprendas aquí.

Importa porque una app que olvida todo al cerrarse no sirve para guardar nada. Room es la capa de Jetpack para guardar datos con estructura (tablas, relaciones, consultas) sin escribir a mano el código que traduce entre filas y objetos. Por debajo usa SQLite, el motor de bases de datos pequeño que viene dentro de Android. Y en 2026 hay una trampa de versión: casi todo lo que encuentras en internet es Room 2, y esta lección usa Room 3, que cambia el paquete, exige el procesador KSP y obliga a elegir un driver.

La lección tiene cuatro partes: qué es Room (las tres piezas, un poco de SQL y un recetario de ejemplo), instalar Room (Gradle, sin escribir nada de Room), un ejercicio (la biblioteca) y el punto de control. Temas: entidades, DAO y consultas con Flow; la instalación de Room; recuadro de SQL mínimo.

Los tres componentes de Room

Dos palabras antes de seguir. Una base de datos es un conjunto de tablas guardado en un fichero del móvil. Una tabla es como una hoja de cálculo: cada fila es un elemento (un libro, un partido) y cada columna es un dato de ese elemento (el título, el id).

PiezaSe escribe comoEn Marcador
Entidaddata class con @EntityPartidoEntity, GolEntity (en la lección siguiente)
DAOinterface con @DaoPartidoDao
Base de datosabstract class con @DatabaseMarcadorDatabase

Las anotaciones (@Entity, @Dao…) son etiquetas como las de Hilt; aquí las lee el procesador de Room al compilar. Las entidades son clases aparte del dominio: Partido (con equipos, jugadores y una lista de eventos) no cabe en una fila. Una lista dentro de una columna no existe en SQL, así que los goles van en su propia tabla, gol, con una clave foránea (foreign key) que apunta al partido.

ejemplo mínimo
@Entity(tableName = "partido")
data class PartidoEntity(
    @PrimaryKey val id: Int,      // la clave primaria: identifica cada fila
    val local: String,            // una propiedad = una columna
    val visitante: String,
)
 
@Dao
interface PartidoDao {
    @Query("SELECT * FROM partido ORDER BY id")
    fun observarPartidos(): Flow<List<PartidoEntity>>   // emite la lista y vuelve a emitir al cambiar
}

En Room 3 todo es corrutina: las consultas que observan devuelven Flow y las puntuales son suspend. Room las ejecuta fuera del hilo principal por su cuenta, sin withContext.

Recuadro · SQL mínimo

SQL es el lenguaje de las bases de datos. Estas cuatro consultas muestran lo que necesitas ahora y en la lección siguiente (SELECT pide filas, * significa «todas las columnas», FROM dice de qué tabla, WHERE filtra y ORDER BY ordena):

SELECT * FROM partido ORDER BY id                       -- todas las filas de una tabla
SELECT * FROM gol WHERE partidoId = :partidoId          -- filtrar por columna (:parametro = argumento de la función)
SELECT COUNT(*) FROM partido                            -- contar
SELECT jugador, COUNT(*) AS goles FROM gol
  GROUP BY jugador ORDER BY goles DESC                  -- una fila por jugador, con su total

AS goles pone un alias: un nombre para la columna que calcula COUNT(*). Es el nombre que usarás en ORDER BY y el de la propiedad donde Room dejará el resultado. GROUP BY jugador junta las filas con el mismo jugador, y DESC ordena de mayor a menor. En esta lección solo usarás la primera; las otras llegan en la siguiente.

Room 3 frente a Room 2

Frente a Room 2 (el de casi todos los tutoriales), Room 3 usa el paquete androidx.room3, solo admite KSP como procesador (Room 2 admitía también kapt, un procesador más antiguo y lento) y exige elegir un driver de SQLite (Room 2 usaba el del sistema sin preguntar).

Un driver es la pieza que habla con SQLite. AndroidSQLiteDriver usa el SQLite de Android (no engorda la app, pero su versión depende del móvil); BundledSQLiteDriver incrusta uno (misma versión en todos los móviles, a cambio de unos MB). Marcador solo es Android y usa el del sistema, que llega con room3-runtime.

Las piezas de Room, en un recetario

Las piezas de Room, en un recetario inventado (recetas e ingredientes, no partidos ni goles). Antes del código, lo que estrena:

  • @Entity(tableName, foreignKeys, indices) y @PrimaryKey(autoGenerate = true) (la base asigna el id).
  • @ForeignKey(entity, parentColumns, childColumns, onDelete = CASCADE): la base impide un ingrediente de una receta que no existe, y CASCADE borra sus ingredientes al borrar la receta. @Index("recetaId") acelera «los ingredientes de esta receta».
  • @Dao, @Query("…") (con :parametro) y @Insert.
  • @Database(entities, version, exportSchema) y Room.databaseBuilder<T>(context, name).setDriver(…).build(). exportSchema = true escribe el esquema (la descripción de qué tablas y columnas tiene la base) en un fichero 1.json, uno por versión, al compilar.
  • contexto.getDatabasePath("recetario.db").absolutePath: Android decide en qué carpeta privada de la app va cada fichero de base de datos. getDatabasePath da la ruta de ese fichero (aunque todavía no exista) y absolutePath la convierte en texto, que es lo que pide name.
  • [ … ] dentro de una anotación: una lista, la forma que tienen las anotaciones de recibir varios valores (entities = [RecetaEntity::class, IngredienteEntity::class]). X::class es «la clase como valor»: la clase X misma, no un objeto suyo.
ejemplo mínimo: dos tablas unidas, un DAO y la base (con comentarios)
@Entity(tableName = "receta")                                  // una tabla llamada receta
data class RecetaEntity(
    @PrimaryKey(autoGenerate = true) val id: Long = 0,         // el id lo pone la base; 0 = «aún sin asignar»
    val nombre: String,
)
 
@Entity(
    tableName = "ingrediente",
    foreignKeys = [                                            // [ … ]: una lista dentro de la anotación
        ForeignKey(
            entity = RecetaEntity::class,                      // ::class: la clase como valor
            parentColumns = ["id"],                            // columna de la tabla padre…
            childColumns = ["recetaId"],                       // …a la que apunta esta columna
            onDelete = ForeignKey.CASCADE,                     // borrar la receta borra sus ingredientes
        ),
    ],
    indices = [Index("recetaId")],                             // acelera «los ingredientes de esta receta»
)
data class IngredienteEntity(
    @PrimaryKey(autoGenerate = true) val id: Long = 0,
    val recetaId: Long,
    val nombre: String,
)
 
@Dao
interface RecetaDao {
    @Query("SELECT * FROM receta ORDER BY id")
    fun observarRecetas(): Flow<List<RecetaEntity>>             // observar: Flow, emite otra vez cuando la tabla cambia
 
    @Query("SELECT * FROM ingrediente WHERE recetaId = :recetaId")  // :recetaId = el parámetro de la función
    suspend fun ingredientesDe(recetaId: Long): List<IngredienteEntity>  // lectura puntual: suspend
 
    @Query("SELECT COUNT(*) FROM receta")
    suspend fun contarRecetas(): Int
 
    @Insert
    suspend fun insertarReceta(receta: RecetaEntity)           // Room escribe el INSERT por ti
 
    @Insert
    suspend fun insertarIngredientes(ingredientes: List<IngredienteEntity>)
}
 
@Database(entities = [RecetaEntity::class, IngredienteEntity::class], version = 1, exportSchema = true)
abstract class RecetarioDatabase : RoomDatabase() {            // abstract: KSP genera RecetarioDatabase_Impl
    abstract fun recetaDao(): RecetaDao                        // la puerta al DAO
}
 
fun crearRecetario(contexto: Context): RecetarioDatabase =
    Room.databaseBuilder<RecetarioDatabase>(
        context = contexto.applicationContext,                 // nunca la Activity: la base vive más que ella
        name = contexto.getDatabasePath("recetario.db").absolutePath,
    )
        .setDriver(AndroidSQLiteDriver())                      // Room 3 pide elegir driver: el SQLite de Android
        .build()

Los import no van en el ejemplo: Android Studio los propone con Option+Enter sobre el nombre en rojo. Cuidado con los de Room: son androidx.room3.…, no androidx.room. Estos son los que usarás:

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: las entidades, los de @Entity, @PrimaryKey, @ForeignKey e @Index; el DAO, @Dao, @Query e @Insert; la base, @Database, Room, RoomDatabase y AndroidSQLiteDriver.

Falta ver cómo se usa una base desde una pantalla. Un composable no tiene un Context a mano, y crearRecetario lo necesita para saber en qué carpeta del móvil guardar el fichero. LocalContext.current es la forma de pedir el Context dentro de un composable (fuera de él ya lo conoces de Context y Application):

ejemplo mínimo: una base de Room en una pantalla (con comentarios)
@Composable
fun Recetario() {
    val contexto = LocalContext.current                              // el Context, dentro de un composable
    val dao = remember { crearRecetario(contexto).recetaDao() }      // se abre una vez, no en cada recomposición (abrir la base es caro)
    val flujo = remember { dao.observarRecetas() }                   // el Flow del DAO, también una sola vez: si no, cada recomposición pediría uno nuevo
    val recetas by flujo.collectAsStateWithLifecycle(initialValue = emptyList())  // vacía hasta que llega la primera lista
    val alcance = rememberCoroutineScope()                           // para llamar a funciones suspend desde un botón
    Column {
        Button(onClick = { alcance.launch { dao.insertarReceta(RecetaEntity(nombre = "Tortilla")) } }) { Text("Guardar tortilla") }
        recetas.forEach { Text(it.nombre) }                          // una línea por fila de la tabla
    }
}

Cada vez que pulsas el botón, la tabla receta gana una fila, el Flow emite la lista nueva y la pantalla se redibuja con una línea más. Si cierras la app y la abres, las recetas siguen ahí: están en el disco, no en la memoria.

Prepara el proyecto · Instala Room

Objetivo. Que :app tenga las tres piezas que Room necesita además de KSP: el plugin de Room, la librería de Room y su procesador. Aquí solo preparas Gradle y compruebas que compila: el ejercicio de después ya usa Room.

Sigue la receta de Los ficheros de Gradle de tu proyecto y la del plugin de JSON e instalar kotlinx.serialization. Estrena un plugin que configura una librería y un segundo procesador para el ksp(…) que ya tienes. KSP no se vuelve a instalar: ya está en [versions], en [plugins], en la raíz y en :app.

Gradle Scripts
libs.versions.toml (Version Catalog "libs")se modifica: [versions], [libraries] y [plugins]
build.gradle.kts (Project: Marcador)se modifica: plugins, con apply false
build.gradle.kts (Module :app)se modifica: plugins, bloque room3 y dependencies
Los tres ficheros de esta instalación, tal como los ves en la vista Android.

1 · Qué instalas y por qué ahora. Tres piezas, que van en pareja. Hacen falta ya porque el ejercicio siguiente escribe una entidad, y sin el procesador esas anotaciones no generan nada.

  • Procesador de Room (androidx.room3:room3-compiler): lee tus @Entity, @Dao y @Database y escribe las clases _Impl de tu DAO y de tu base (por ejemplo RecetaDao_Impl). Es una herramienta de compilación, no viaja en la app.
  • Librería de Room (androidx.room3:room3-runtime): lo que la app lleva dentro (Room.databaseBuilder y el motor que habla con SQLite).
  • Plugin de Room (androidx.room3): dice al procesador en qué carpeta guardar los esquemas (1.json, 2.json…). Sin él Room compila, pero no exporta nada, y las migraciones lo necesitan.

2 · Catálogo. Abre Gradle Scripts → libs.versions.toml (Version Catalog "libs") y añade las líneas al final de cada sección (el KSP y el Hilt que ya tienes se quedan donde están):

catálogo: líneas nuevas (con comentarios)
[versions]
# Una sola versión para librería, procesador y plugin: se publican juntos y deben coincidir
room = "3.0.3"
 
[libraries]
# Grupo androidx.room3 (no androidx.room: es Room 3) y nombres con el 3
androidx-room-runtime = { group = "androidx.room3", name = "room3-runtime", version.ref = "room" }
# El procesador va en [libraries] porque es una dependencia de Maven más; la pedirá ksp(…)
androidx-room-compiler = { group = "androidx.room3", name = "room3-compiler", version.ref = "room" }
 
[plugins]
# Con su id oficial y su propia versión (aquí no se reutiliza «kotlin»)
room = { id = "androidx.room3", version.ref = "room" }

3 · Raíz, con apply false. Abre Gradle Scripts → build.gradle.kts (Project: Marcador) y añade una línea al final del bloque plugins. La raíz «carga» cada plugin una sola vez para todo el proyecto, sin aplicarlo, porque no compila nada:

build.gradle.kts (Project: Marcador): antes
plugins {
    alias(libs.plugins.android.application) apply false
    alias(libs.plugins.kotlin.compose) apply false
    alias(libs.plugins.jetbrains.kotlin.jvm) apply false
    alias(libs.plugins.kotlin.serialization) apply false
    alias(libs.plugins.ksp) apply false
    alias(libs.plugins.hilt) apply false
}
build.gradle.kts (Project: Marcador): después (con comentarios)
plugins {
    alias(libs.plugins.android.application) apply false
    alias(libs.plugins.kotlin.compose) apply false
    alias(libs.plugins.jetbrains.kotlin.jvm) apply false
    alias(libs.plugins.kotlin.serialization) apply false
    alias(libs.plugins.ksp) apply false
    alias(libs.plugins.hilt) apply false
    alias(libs.plugins.room) apply false   // nueva: el plugin de Room, igual
}

Tu bloque puede tener otro orden; añade la línea al final.

4 · Módulo :app. Abre Gradle Scripts → build.gradle.kts (Module :app). Tres cambios, de arriba abajo: un plugin (sin apply false) que añades al final de tu bloque plugins, sin sustituirlo (el bloque de abajo muestra solo algunos de los que ya tienes, para que veas dónde va), un bloque room3 { … } entre android { … } y dependencies { … }, y dos dependencias al principio de dependencies (el orden no afecta a Gradle; así todos las encontramos en el mismo sitio):

build.gradle.kts (Module :app): lo nuevo (con comentarios)
plugins {
    alias(libs.plugins.android.application)
    alias(libs.plugins.kotlin.compose)
    alias(libs.plugins.ksp)     // ya lo tienes: lo instalaste con Hilt
    alias(libs.plugins.hilt)
    alias(libs.plugins.room)    // nuevo: aplica el plugin de Room; es el que crea el bloque room3
}
 
room3 {
    // Carpeta de los esquemas: app/schemas ($projectDir es la carpeta de :app).
    // Es parte de tu proyecto: la lección de migraciones compara dos esquemas, no la borres
    schemaDirectory("$projectDir/schemas")
}
 
dependencies {
    implementation(libs.androidx.room.runtime)  // dependencia normal: viaja dentro de la app
    // Línea clave: «ksp» (la misma de Hilt) ejecuta también este procesador al compilar, y se
    // queda fuera de la app. Sin ella nadie genera la clase _Impl de tu base
    ksp(libs.androidx.room.compiler)
    // … el resto de tus dependencias, sin tocar
}

5 · Sync Now. Pulsa Sync Now. Debe terminar sin errores rojos; la primera vez descarga un plugin y dos librerías, así que necesita internet y tarda más.

6 · Comprueba. Que Sync termine bien no prueba que Room esté bien instalado. Abre la Terminal (View → Tool Windows → Terminal) y compila:

./gradlew :app:assembleGratisDebug

Debe terminar en BUILD SUCCESSFUL. La Terminal de Android Studio borra del pantallazo las líneas > Task … según avanzan, así que al final solo ves BUILD SUCCESSFUL y el número de tareas; si quieres verlas, añade --console=plain al final y saldrá > Task :app:kspGratisDebugKotlin, que es KSP ejecutándose sobre la variante gratisDebug (la de Gradle: flavors y variantes), como ya hacía con Hilt. Todavía no hay ninguna clase de Room que procesar, así que Room no genera nada; con el ejercicio de abajo sí.

En la vista Android no aparece nada nuevo: lo que genera el procesador vive en build/ (app/build/generated/ksp/…), que no se muestra. Lo comprobarás con la Terminal.

Tu primera base: la biblioteca

Ya tienes Room instalado y sabes qué hace cada pieza. Ahora la usas en una base propia, fuera de Marcador.

Reto · Ejercicio: la biblioteca

En la carpeta ejercicios de tu proyecto (la que creaste en La rotación y la muerte del proceso), crea la subcarpeta biblioteca: clic derecho sobre com.sebastiannarvaez.marcador → New → Package → añade .ejercicios.biblioteca al final del nombre que aparece ya escrito. Después, clic derecho sobre esa carpeta → New → Kotlin Class/File → File → BibliotecaDatabase, y lo mismo con Biblioteca. La primera línea de cada fichero debe ser package com.sebastiannarvaez.marcador.ejercicios.biblioteca.

En BibliotecaDatabase.kt: una tabla libro con un id que pone la base y un título; un DAO que observa todos los libros ordenados por id y que inserta un libro; una base en la versión 1 que exporta su esquema, con su DAO; y una función que la abre en el fichero biblioteca.db con el driver de SQLite de Android.

En Biblioteca.kt: una pantalla que abre esa base una sola vez, muestra «Libros guardados: N», un botón «Añadir libro» que guarda «Libro N+1» y, debajo, una línea por libro con su id y su título; y su vista previa.

El panel de vista previa no tiene disco ni base de datos: ejecuta la vista previa en el emulador (triángulo verde junto a @Preview → Run 'BibliotecaPreview', como en Estado en Compose). Lo que debes ver: «Libros guardados: 0». Pulsa tres veces «Añadir libro»: «Libros guardados: 3» y debajo 1. Libro 1, 2. Libro 2 y 3. Libro 3. Cierra la app desde recientes (deslízala hacia arriba) y vuelve a ejecutar la vista previa: siguen los tres. Al terminar, elige otra vez app en el selector de arriba.

Pistas, solo con lo visto: el recetario tiene la entidad, el DAO, la base y la función que la abre; la pantalla es el ejemplo de Recetario, con una línea por fila. No borres esta subcarpeta ni sus libros: la lección de migraciones la cambia.

Ver solución
app/src/main/java/com/sebastiannarvaez/marcador/ejercicios/biblioteca/BibliotecaDatabase.kt
package com.sebastiannarvaez.marcador.ejercicios.biblioteca
 
import android.content.Context
import androidx.room3.Dao
import androidx.room3.Database
import androidx.room3.Entity
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
import kotlinx.coroutines.flow.Flow
 
@Entity(tableName = "libro")
data class LibroEntity(
    @PrimaryKey(autoGenerate = true) val id: Long = 0,
    val titulo: String,
)
 
@Dao
interface LibroDao {
    @Query("SELECT * FROM libro ORDER BY id")
    fun observarLibros(): Flow<List<LibroEntity>>
 
    @Insert
    suspend fun insertar(libro: LibroEntity)
}
 
@Database(entities = [LibroEntity::class], version = 1, exportSchema = true)
abstract class BibliotecaDatabase : RoomDatabase() {
    abstract fun libroDao(): LibroDao
}
 
fun crearBiblioteca(contexto: Context): BibliotecaDatabase =
    Room.databaseBuilder<BibliotecaDatabase>(
        context = contexto.applicationContext,
        name = contexto.getDatabasePath("biblioteca.db").absolutePath,
    )
        .setDriver(AndroidSQLiteDriver())
        .build()
app/src/main/java/com/sebastiannarvaez/marcador/ejercicios/biblioteca/Biblioteca.kt
package com.sebastiannarvaez.marcador.ejercicios.biblioteca
 
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
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.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.launch
 
@Composable
fun Biblioteca(modifier: Modifier = Modifier) {
    val contexto = LocalContext.current
    val dao = remember { crearBiblioteca(contexto).libroDao() }
    val flujo = remember { dao.observarLibros() }
    val libros by flujo.collectAsStateWithLifecycle(initialValue = emptyList())
    val alcance = rememberCoroutineScope()
 
    Column(modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
        Text("Libros guardados: ${libros.size}")
        Button(onClick = { alcance.launch { dao.insertar(LibroEntity(titulo = "Libro ${libros.size + 1}")) } }) {
            Text("Añadir libro")
        }
        libros.forEach { Text("${it.id}. ${it.titulo}") }
    }
}
 
@Preview(showBackground = true)
@Composable
private fun BibliotecaPreview() {
    MaterialTheme { Biblioteca() }
}

La base se abre una sola vez gracias a remember. Guardar es una función suspend, por eso va dentro de launch; y leer es un Flow, por eso la lista se actualiza sola cada vez que insertas.

Compila desde la Terminal de Android Studio (View → Tool Windows → Terminal) y mira lo que KSP ha producido:

./gradlew :app:assembleGratisDebug

Debe terminar en BUILD SUCCESSFUL. Ahora KSP sí ha trabajado.

  • El esquema. Room ha escrito 1.json en app/schemas. Compruébalo en la Terminal (ls lista los ficheros de una carpeta): ls app/schemas/com.sebastiannarvaez.marcador.ejercicios.biblioteca.BibliotecaDatabase. Debe salir 1.json. La vista Android está pensada para código y recursos, y esta carpeta no es ninguno de los dos, así que lo normal es que no la veas en el árbol; la Terminal lo confirma sin depender de eso.
  • El código generado. ls app/build/generated/ksp/gratisDebug/kotlin/com/sebastiannarvaez/marcador/ejercicios/biblioteca muestra BibliotecaDatabase_Impl.kt y LibroDao_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.

Punto de control

  • ./gradlew :app:assembleGratisDebug termina en BUILD SUCCESSFUL (con --console=plain verás pasar > Task :app:kspGratisDebugKotlin).
  • ls app/schemas muestra la carpeta com.sebastiannarvaez.marcador.ejercicios.biblioteca.BibliotecaDatabase, con 1.json dentro.
  • La vista previa de la biblioteca, ejecutada en el emulador, conserva los libros al cerrar la app y volver a abrirla.
  • Marcador se ve igual que antes: todavía no ha cambiado nada de la app.

La lección siguiente escribe la base de Marcador: El repositorio con Room.

Comprueba lo que entendiste

4 preguntas · necesitas 3 para completar la lección

  1. 1.¿Qué papel tiene un DAO en Room?

  2. 2.Una consulta del DAO devuelve Flow<List<PartidoEntity>>. ¿Qué ocurre cuando alguien inserta una fila en una tabla de esa consulta?

  3. 3.En un DAO de Room 3, ¿cuál de estas funciones se declara con suspend y cuál devuelve Flow?

  4. 4.Olvidas la línea ksp(libs.androidx.room.compiler) en app/build.gradle.kts. ¿Qué ocurre?

Faltan 4 de 4