Tema 4. ROOM: Persistencia local
- Exposición aproximada
- 6 h
- Aplicación de referencia
- AppDummy · biblioteca personal
- Resultados de aprendizaje
- RA2RA3
- RA2. Desarrolla aplicaciones para dispositivos móviles analizando y empleando las tecnologías y librerías específicas.
- RA3. Desarrolla programas que integran contenidos multimedia analizando y empleando las tecnologías y librerías específicas.
Criterios de evaluación que se trabajan (9)
| Código | Criterio |
|---|---|
| RA2-a | Se ha generado la estructura de clases necesaria para la aplicación. |
| RA2-b | Se han analizado y utilizado las clases que modelan ventanas, menús, alertas y controles para el desarrollo de aplicaciones gráficas sencillas. |
| RA2-f | Se han utilizado las clases necesarias para establecer conexiones con almacenes de datos garantizando la persistencia. |
| RA2-g | Se han realizado pruebas de interacción usuario-aplicación para optimizar las aplicaciones desarrolladas a partir de emuladores. |
| RA2-i | Se han documentado los procesos necesarios para el desarrollo de las aplicaciones. |
| RA3-c | Se han utilizado clases para la conversión de datos multimedia. |
| RA3-d | Se han utilizado clases para procesar datos multimedia. |
| RA3-e | Se han utilizado clases para el control de eventos, tipos de media y excepciones, entre otros. |
| RA3-h | Se han depurado y documentado los programas desarrollados. |
Dependencias necesarias#
Se continúa desde de la última versión de AppDummy, si no la tienes, puedes descargarla desde el punto de partida que muestra en la ficha del tema.
Room utiliza KSP (Kotlin Symbol Processing) como procesador de anotaciones para generar código en tiempo de compilación. A partir de Kotlin 2.0, KAPT queda obsoleto para Room; siempre se debe usar KSP.
Importante
La versión KSP1, inferior a la versión 2.3.0, ha quedado obsoleta y no se recomienda su uso. Se recomienda actualizar a KSP2 para proyectos nuevos o existentes, que desvincula la versión de KSP de la versión del compilador de Kotlin. Esto permite actualizar KSP sin necesidad de actualizar Kotlin, y viceversa.
Consulta las releases de KSP en GitHub para encontrar la versión correspondiente.
1// settings.gradle.kts — comprobar que está el repositorio de Google
2pluginManagement {
3 repositories {
4 google()
5 mavenCentral()
6 gradlePluginPortal()
7 }
8}
9
10// o ...
11pluginManagement {
12 repositories {
13 google {
14 content {
15 includeGroupByRegex("com\\.android.*")
16 includeGroupByRegex("com\\.google.*")
17 includeGroupByRegex("androidx.*")
18 }
19 }
20 mavenCentral()
21 gradlePluginPortal()
22 }
23}1// build.gradle.kts (nivel de proyecto)
2plugins {
3 id("com.google.devtools.ksp") version "2.3.10" apply false
4 id("androidx.room") version "2.8.4" apply false
5} 1// build.gradle.kts (módulo app)
2plugins {
3 id("com.android.application")
4 id("org.jetbrains.kotlin.android")
5 id("com.google.devtools.ksp") // procesador de anotaciones de Room
6 id("androidx.room") // plugin de Room para gestión de esquemas
7}
8
9// Configuración del plugin de Room — exportar esquemas de la BD
10// Imprescindible para poder escribir migraciones en el futuro
11room {
12 schemaDirectory("$projectDir/schemas")
13}
14
15dependencies {
16 // Room — todos los artefactos deben tener la misma versión
17 implementation("androidx.room:room-runtime:2.8.4")
18 implementation("androidx.room:room-ktx:2.8.4") // extensiones de corrutinas
19 ksp("androidx.room:room-compiler:2.8.4") // generador de código (KSP, NO kapt)
20
21 // Testing de Room
22 testImplementation("androidx.room:room-testing:2.8.4")
23 androidTestImplementation("androidx.room:room-testing:2.8.4")
24}Atención
Compatibilidad KSP–Kotlin: la versión de KSP1 tiene el formato
<versión_kotlin>-<versión_ksp>. El identificador2.1.20-1.0.31indica que es compatible con Kotlin 2.1.20. Si actualizas Kotlin, debes actualizar también la versión de KSP.
1. ¿Qué es Room y por qué usarlo?#
Room es la biblioteca de persistencia recomendada por Google para Android. Proporciona una capa de abstracción sobre SQLite que simplifica enormemente el trabajo con bases de datos:
- Genera automáticamente el código SQL necesario a partir de anotaciones en las clases Kotlin.
- Verifica las consultas SQL en tiempo de compilación, detectando errores antes de ejecutar la app.
- Se integra de forma nativa con corrutinas y Flow, permitiendo observar cambios en la base de datos de forma reactiva.
- Facilita las migraciones de esquema cuando evoluciona la estructura de la base de datos.
Sin Room (SQLite puro) Con Room
────────────────────── ─────────
val db = openOrCreateDatabase(...) @Entity data class Libro(...)
val cursor = db.rawQuery( @Dao interface LibrosDao {
"SELECT * FROM libros", null). @Query("SELECT * FROM libros")
val libros = mutableListOf<...>() fun observarTodos(): Flow<List<Libro>>
while (cursor.moveToNext()) { }
libros.add(...) @Database(...) abstract class AppDatabase
} // Room genera todo el resto ↑
cursor.close()
// Errores SQL → solo en runtime ❌ // Errores SQL → en compilación ✅Componentes de Room#
Room se compone de tres elementos principales que trabajan juntos:
2. @Entity: definir una tabla#
La anotación @Entity sobre un data class le indica a Room que esa clase representa una tabla en la base de datos. Cada propiedad del data class se convierte en una columna.
Ver Libro.kt
1import androidx.room.ColumnInfo
2import androidx.room.Entity
3import androidx.room.PrimaryKey
4
5// ─── data/model/Libro.kt ────────────────────────────────────────────────────────────────────────
6// Esta misma clase se reutilizará en B3-T5 con Retrofit2
7
8@Entity(tableName = "libros")
9data class Libro(
10 // @PrimaryKey identifica de forma única cada fila
11 // autoGenerate = true: Room asigna el id automáticamente (incremento)
12 // autoGenerate = false (por defecto): el id lo proporcionamos nosotros
13 // En AppDummy usamos el id de https://openlibrary.org/, así que autoGenerate = false
14 @PrimaryKey
15 val id: String,
16
17 // @ColumnInfo permite personalizar el nombre de la columna en SQLite
18 // Si no se especifica, Room usa el nombre de la propiedad
19 @ColumnInfo(name = "titulo")
20 val titulo: String,
21
22 @ColumnInfo(name = "autor")
23 val autor: String,
24
25 @ColumnInfo(name = "year")
26 val year: Int? = 1900,
27
28 @ColumnInfo(name = "isbn")
29 val isbn: String,
30
31 @ColumnInfo(name = "cover")
32 val cover: String? = null,
33
34 // Campos exclusivamente locales: no existe en la API de https://openlibrary.org/
35 // Al deserializar con Gson quedan a false: Gson no aplica los valores por defecto
36 // de Kotlin, pero false es el valor por defecto de Boolean en la JVM
37 @ColumnInfo(name = "es_favorito")
38 val esFavorito: Boolean = false,
39
40 @ColumnInfo(name = "leido")
41 val leido: Boolean = false
42)Importante
Observa que han habido cambios en algunos campos, como es el identificador de la entidad, que ahora es un
Stringen lugar de unInt. Esto se debe a que la API de OpenLibrary utiliza identificadores alfanuméricos (por ejemplo,/works/OL45883W) en lugar de enteros. Por lo tanto, el campoidse ha cambiado aStringpara reflejar correctamente los datos de la API.
Reglas importantes sobre @Entity#
El nombre de la tabla (tableName) debe ser único en la base de datos. Si no se especifica, Room usa el nombre de la clase en minúsculas. Toda entidad debe tener exactamente una propiedad marcada con @PrimaryKey. Room no soporta de forma nativa tipos complejos como List<String> o LocalDate; para usarlos se necesitan TypeConverters (ver sección 5).
1// @Entity con índices para mejorar el rendimiento de búsquedas frecuentes
2@Entity(
3 tableName = "libros",
4 indices = [
5 Index(value = ["titulo"]), // búsqueda por título
6 Index(value = ["autor", "isbn"], unique = false) // búsqueda combinada
7 ]
8)
9data class Libro(...)3. @Dao: operaciones sobre la base de datos#
DAO (Data Access Object) es una interfaz que declara las operaciones disponibles sobre una o más tablas. Room genera automáticamente la implementación en tiempo de compilación.
La regla fundamental: Flow vs suspend#
La regla más importante para usar Room con corrutinas:
Flow<T>para consultas de observación (sinsuspend): Room re-emite automáticamente los datos cada vez que cambia la tabla observada.suspend funpara operaciones de escritura (@Insert,@Update,@Delete) y consultas de una sola vez.
Ver LibrosDao.kt
1import androidx.room.*
2import kotlinx.coroutines.flow.Flow
3
4// ─── data/datasource/local/LibrosDao.kt ──────────────────────────────────────────────────────────
5@Dao
6interface LibrosDao {
7 // ─── Consultas reactivas (Flow, SIN suspend) ──────────────────────────────
8 // Room observa la tabla y emite una nueva lista cada vez que hay cambios
9
10 @Query("SELECT * FROM libros ORDER BY year DESC")
11 fun observarTodos(): Flow<List<Libro>>
12
13 @Query("SELECT * FROM libros WHERE es_favorito = 1 ORDER BY titulo ASC")
14 fun observarFavoritos(): Flow<List<Libro>>
15
16 @Query("SELECT * FROM libros WHERE titulo LIKE '%' || :busqueda || '%' ORDER BY year DESC")
17 fun observarPorTitulo(busqueda: String): Flow<List<Libro>>
18
19 // Observar un solo libro (devuelve null si no existe)
20 @Query("SELECT * FROM libros WHERE id = :id")
21 fun observarPorId(id: String): Flow<Libro?>
22
23 // ─── Consulta puntual (suspend, SIN Flow) ─────────────────────────────────
24 // Obtiene el valor una sola vez, sin observar cambios posteriores
25
26 @Query("SELECT * FROM libros WHERE id = :id")
27 suspend fun obtenerPorId(id: String): Libro?
28
29 @Query("SELECT DISTINCT autor FROM libros ORDER BY autor ASC")
30 suspend fun obtenerAutores(): List<String>?
31
32 // ─── Escritura (siempre suspend) ─────────────────────────────────────────
33
34 // @Insert con OnConflictStrategy.IGNORE: si el libro ya existe, lo ignora
35 // Devuelve el rowId de cada fila insertada (-1 si fue ignorada por conflicto)
36 @Insert(onConflict = OnConflictStrategy.IGNORE)
37 suspend fun insertarIgnorando(libros: List<Libro>): List<Long>
38
39 // @Insert con REPLACE: elimina la fila existente y la reinserta
40 // Provoca pérdida de campos locales como esFavorito — usar con cuidado
41 @Insert(onConflict = OnConflictStrategy.REPLACE)
42 suspend fun insertarReemplazando(libro: Libro)
43
44 // @Upsert (disponible desde Room 2.5): intenta INSERT, si hay conflicto hace UPDATE
45 // También sobreescribe esFavorito si el objeto tiene esFavorito=false
46 @Upsert
47 suspend fun upsert(libro: Libro)
48
49 @Update
50 suspend fun actualizar(libro: Libro)
51
52 @Delete
53 suspend fun eliminar(libro: Libro)
54
55 // @Query de escritura: permite actualizaciones selectivas de columnas concretas
56 // Solución al problema de esFavorito: actualiza solo los campos de la API
57 @Query("""
58 UPDATE libros SET
59 titulo = :titulo,
60 autor = :autor,
61 year = :year,
62 isbn= :isbn,
63 cover = :cover
64 WHERE id = :id
65 """)
66 suspend fun actualizarDesdeRed(
67 id: String,
68 titulo: String,
69 autor: String,
70 year: Int?,
71 isbn: String,
72 cover: String?
73 )
74
75 // Toggle de favorito: invierte el valor booleano en la base de datos
76 @Query("UPDATE libros SET es_favorito = NOT es_favorito WHERE id = :id")
77 suspend fun toggleFavorito(id: String)
78
79 // @Transaction: garantiza que varias operaciones se ejecutan de forma atómica
80 // Si alguna falla, todas se revierten (rollback)
81 @Transaction
82 suspend fun upsertConservandoFavorito(libros: List<Libro>) {
83 val resultados = insertarIgnorando(libros)
84 // Para cada libro que ya existía (resultado == -1L), actualizar solo los campos de la API
85 libros.forEachIndexed { index, libro ->
86 if (resultados[index] == -1L) {
87 actualizarDesdeRed(
88 id = libro.id,
89 titulo = libro.titulo,
90 autor = libro.autor,
91 year = libro.year,
92 isbn = libro.isbn,
93 cover = libro.cover
94 )
95 }
96 }
97 }
98}Información
¿Por qué
upsertConservandoFavoritoy no@Upsertdirectamente? La anotación@Upsertde Room realiza unINSERT OR REPLACEa nivel SQL, que actualiza todas las columnas del objeto. Si el objeto que llega de la API tieneesFavorito = false(el valor por defecto), y el usuario había marcado ese libro como favorito,@Upsertsobreescribirá eltrueconfalse, perdiendo la preferencia del usuario. El patrónupsertConservandoFavoritoresuelve esto: primero intenta insertar (ignorando los duplicados), y para los que ya existían, actualiza solo las columnas que vienen de la API, dejandoesFavoritointacto.
Transacciones con @Transaction#
Una transacción es un conjunto de operaciones sobre la base de datos que se trata como una unidad indivisible: o se completan todas, o no se aplica ninguna. Si una de las operaciones falla a mitad, la base de datos vuelve al estado en que estaba antes de empezar (rollback). Así se evita que la base de datos quede en un estado intermedio e incoherente.
La anotación @Transaction marca una función de un DAO para que Room la ejecute dentro de una transacción de SQLite. Room se encarga de abrir la transacción al entrar en la función, de confirmarla (commit) si la función termina con normalidad y de revertirla si se lanza una excepción en su cuerpo.
¿Cuándo se utiliza?#
| Caso | Dónde se coloca | Qué garantiza |
|---|---|---|
| Varias operaciones que deben ir juntas | Función con cuerpo (no abstracta) que llama a otras funciones del DAO | Atomicidad: todas las escrituras se aplican o ninguna |
Consultas que devuelven una clase con @Relation (ver punto 6) |
Junto a @Query |
Room lanza varias consultas internas (padre + hijos); la transacción hace que todas lean el mismo estado de la base de datos |
| Consultas con resultados muy grandes | Junto a @Query |
Lectura coherente aunque el resultado se lea en varios bloques (CursorWindow) mientras otra parte de la app escribe |
¿Cuándo NO hace falta?#
Las funciones anotadas con @Insert, @Update, @Delete y @Upsert, así como las @Query que ejecutan INSERT, UPDATE o DELETE, ya se ejecutan siempre dentro de una transacción. Añadirles @Transaction no tiene ningún efecto. La anotación solo aporta algo cuando se combinan varias operaciones o en los casos de lectura de la tabla anterior.
Ejemplo: por qué importa en upsertConservandoFavorito#
La función upsertConservandoFavorito del DAO anterior realiza dos pasos por cada sincronización: primero inserta los libros nuevos y después actualiza los que ya existían. Sin @Transaction, cada llamada interna sería una transacción independiente:
1// ─── Sin @Transaction (INCORRECTO para este caso) ────────────────────────────────────────────────
2suspend fun upsertConservandoFavorito(libros: List<Libro>) {
3 val resultados = insertarIgnorando(libros) // Transacción 1: se confirma aquí
4 // Si se produce una excepción en este punto (p. ej. un valor nulo inesperado),
5 // los libros nuevos ya están guardados, pero los existentes NO se han actualizado:
6 // la base de datos queda con datos a medio sincronizar.
7 libros.forEachIndexed { index, libro ->
8 if (resultados[index] == -1L) {
9 actualizarDesdeRed(/* ... */) // Transacciones 2, 3, 4...: una por libro
10 }
11 }
12}
13
14// ─── Con @Transaction (CORRECTO) ─────────────────────────────────────────────────────────────────
15@Transaction
16suspend fun upsertConservandoFavorito(libros: List<Libro>) {
17 // Todo el cuerpo es UNA sola transacción:
18 // si algo falla, se deshacen también las inserciones del primer paso.
19 val resultados = insertarIgnorando(libros)
20 libros.forEachIndexed { index, libro ->
21 if (resultados[index] == -1L) {
22 actualizarDesdeRed(/* ... */)
23 }
24 }
25}Además de la coherencia, agrupar muchas escrituras en una única transacción suele ser más rápido, porque SQLite confirma los cambios en disco una sola vez en lugar de una vez por operación.
Reglas de uso#
- La función anotada debe tener cuerpo. En un DAO declarado como
interface, Kotlin permite escribir funciones con implementación, comoupsertConservandoFavorito; en un DAOabstract class, la función no debe serabstract. - Si la función es
suspend, Room ejecuta la transacción de forma asíncrona sin bloquear el hilo principal. Si devuelve unFlow, la transacción se aplica cada vez que se ejecuta la consulta, no al llamar a la función. - Dentro de la transacción solo deben ir operaciones sobre la base de datos. No se deben hacer llamadas de red (Retrofit) ni esperas largas: Room ejecuta las transacciones de escritura de una en una, y las demás quedan en cola hasta que termina la actual.
- La transacción solo se revierte si la excepción sale de la función. Si se captura con
try/catchdentro del propio cuerpo, Room considera que la función terminó bien y confirma los cambios.
Importante
Resumen: usa
@Transactioncuando una función del DAO agrupa varias operaciones que deben aplicarse juntas, o cuando una@Querydevuelve una clase con@Relation. Para un@Insert,@Updateo@Deleteaislado no es necesaria.
Interactivo Escrituras, favoritos y @Transaction https://documentation.javiercarrasco.es/media/pmdm/simuladores/room/ Pantalla completa
4. @Database: la base de datos#
La clase @Database es el punto de entrada a la base de datos. Debe ser abstracta y extender RoomDatabase. Declara las entidades que gestiona y la versión del esquema.
1import androidx.room.Database
2import androidx.room.RoomDatabase
3
4// ─── data/datasource/local/AppDatabase.kt ────────────────────────────────────────────────────────
5@Database(
6 entities = [Libro::class], // lista de todas las @Entity de la app
7 version = 1, // versión del esquema — incrementar al hacer cambios
8 exportSchema = true // exportar esquema a /schemas para migraciones
9)
10abstract class AppDatabase : RoomDatabase() {
11 // Room genera la implementación concreta en tiempo de compilación
12 abstract fun libroDao(): LibrosDao
13}La instancia de AppDatabase debe ser un singleton: crear múltiples instancias para la misma base de datos es un error en Room. La creación se realiza en el AppContainer (ver sección 8).
Versiones y migraciones#
Cada vez que se modifica el esquema (añadir columnas, cambiar tipos, añadir tablas), se debe incrementar el número de versión y proporcionar una migración. Para proyectos principalmente centrados en el aprendizaje donde los datos pueden perderse sin problema, se usa fallbackToDestructiveMigration:
1// Creación del singleton de la base de datos (dentro de de AppDatabase)
2companion object {
3 @Volatile
4 private var INSTANCE: AppDatabase? = null
5
6 fun getDatabase(context: Context): AppDatabase {
7 return INSTANCE ?: synchronized(this) {
8 Room.databaseBuilder(
9 context.applicationContext,
10 AppDatabase::class.java,
11 "appdummy_database"
12 )
13 // Para proyectos de aprendizaje: si cambia el esquema, borra y recrea la BD
14 // En producción real se escribirían objetos Migration en lugar de esto
15 .fallbackToDestructiveMigration(dropAllTables = true)
16 .build()
17 .also { INSTANCE = it }
18 }
19 }
20}Dentro de un companion object se usa @Volatile para garantizar que la instancia de la base de datos sea visible para todos los hilos, y synchronized para evitar condiciones de carrera al crear la instancia.
Vídeo Entidad, DAO y Database https://documentation.javiercarrasco.es/media/pmdm/videos/LC_B3_T4_02_entidad_dao_database.mp4 Descargar
5. TypeConverters: tipos no soportados nativamente#
SQLite solo almacena tipos básicos: INTEGER, REAL, TEXT, BLOB. Para cualquier otro tipo en Kotlin hay que proporcionar un TypeConverter: una clase con funciones que convierten el tipo personalizado a un tipo soportado por SQLite, y viceversa.
1import androidx.room.TypeConverter
2import com.google.gson.Gson
3import com.google.gson.reflect.TypeToken
4
5// ─── data/datasource/local/Converters.kt ─────────────────────────────────────────────────────────
6
7class Converters {
8 // Ejemplo 1: List<String> ↔ String (JSON)
9 // Útil para almacenar listas sencillas como géneros, tags, etc.
10 @TypeConverter
11 fun stringListToJson(value: List<String>): String = Gson().toJson(value)
12
13 @TypeConverter
14 fun jsonToStringList(value: String): List<String> {
15 val type = object : TypeToken<List<String>>() {}.type
16 return Gson().fromJson(value, type)
17 }
18
19 // Ejemplo 2: java.util.Date ↔ Long (timestamp en milisegundos)
20 @TypeConverter
21 fun dateToTimestamp(date: java.util.Date?): Long? = date?.time
22
23 @TypeConverter
24 fun timestampToDate(value: Long?): java.util.Date? =
25 value?.let { java.util.Date(it) }
26}Información
En este ejemplo se usan Gson para convertir listas a JSON y viceversa. También se muestra cómo convertir
DateaLong(timestamp) y viceversa. Room aplicará automáticamente estos conversores cuando encuentre propiedades de esos tipos en las entidades.Para el uso de Gson, será necesario tener la dependencia en tu
build.gradle.kts(se adelanta al T5):1// Gson — deserialización de JSON a objetos Kotlin/Java 2implementation("com.google.code.gson:gson:2.14.0")
1@Database(
2 entities = [Libro::class],
3 version = 1,
4 exportSchema = true
5)
6@TypeConverters(Converters::class) // registrar los converters
7abstract class AppDatabase : RoomDatabase() {
8 // ...6. Relaciones entre tablas: 1:N con @Relation#
Room soporta relaciones entre entidades mediante la anotación @Relation. El ejemplo más común en AppDummy sería relacionar un género con los libros: un género tiene muchos libros (relación 1:N).
Nota
@Relationse usa en clases de resultado (POJOs o data classes), no en entidades. No es una anotación sobre la tabla en sí, sino sobre el objeto que combina los datos de varias tablas para devolverlos juntos.
Atención
No se aplican relaciones en el ejemplo AppDummy original, pero se muestra aquí como ejemplo de uso avanzado de Room. En la práctica, la relación entre libros y géneros podría implementarse con un campo
generoIden la tabla de libros, y luego usar@Relationpara obtener todos los libros de un género.
1// ─── Entidades (tablas) ──────────────────────────────────────────────────────────────────────────
2@Entity(tableName = "generos")
3data class Genero(
4 @PrimaryKey val generoId: Int,
5 val nombre: String,
6 val descripcion: String = ""
7)
8
9@Entity(
10 tableName = "libros_generos",
11 foreignKeys = [
12 ForeignKey(
13 entity = Genero::class,
14 parentColumns = ["generoId"],
15 childColumns = ["generoOwnerId"],
16 // CASCADE: si se elimina un Genero, se eliminan sus libros asociadas
17 onDelete = ForeignKey.CASCADE
18 )
19 ]
20)
21data class LibroGenero(
22 @PrimaryKey val libroId: String,
23 // index = true mejora el rendimiento de las JOIN
24 @ColumnInfo(index = true) val generoOwnerId: Int,
25 val titulo: String,
26 val autor: String
27)
28
29// ─── POJO de relación (NO es @Entity — no crea tabla) ────────────────────────────────────────────
30// Representa el resultado de combinar un Genero con todos sus libros
31data class GeneroConLibros(
32
33 // @Embedded: incluye todas las columnas de Genero en el resultado
34 @Embedded val genero: Genero,
35
36 // @Relation: Room genera automáticamente la consulta JOIN
37 // parentColumn: columna de la tabla padre (Genero.generoId)
38 // entityColumn: columna de la tabla hija que referencia al padre
39 @Relation(
40 parentColumn = "generoId",
41 entityColumn = "generoOwnerId"
42 )
43 val libros: List<LibroGenero>
44)
45
46// ─── DAO con @Transaction ────────────────────────────────────────────────────────────────────────
47@Dao
48interface GeneroDao {
49
50 // @Transaction es imprescindible con @Relation (ver punto 3, «Transacciones con @Transaction»)
51 // Room ejecuta múltiples consultas internamente y @Transaction garantiza
52 // que todas leen el mismo estado consistente de la base de datos.
53 // Si se omite, Room muestra un aviso al compilar.
54 @Transaction
55 @Query("SELECT * FROM generos ORDER BY nombre ASC")
56 fun observarGenerosConLibros(): Flow<List<GeneroConLibros>>
57
58 @Transaction
59 @Query("SELECT * FROM generos WHERE generoId = :id")
60 fun observarGeneroConLibros(id: Int): Flow<GeneroConLibros?>
61
62 // @Upsert y NO @Insert(REPLACE): REPLACE borra la fila existente y la vuelve a
63 // insertar, y ese borrado dispara el onDelete = CASCADE de libros_generos:
64 // volver a guardar un género que ya existe eliminaría todos sus libros.
65 // @Upsert actualiza la fila sin borrarla, así que los hijos se conservan.
66 @Upsert
67 suspend fun insertarGenero(genero: Genero)
68
69 @Insert(onConflict = OnConflictStrategy.REPLACE)
70 suspend fun insertarLibros(libros: List<LibroGenero>)
71}
72
73// ─── Actualizar la @Database con las nuevas entidades ────────────────────────────────────────────
74@Database(
75 entities = [
76 Libro::class,
77 Genero::class,
78 LibroGenero::class
79 ],
80 version = 2, // incrementar la versión al añadir tablas
81 exportSchema = true
82)
83@TypeConverters(Converters::class)
84abstract class AppDatabase : RoomDatabase() {
85 abstract fun libroDao(): LibrosDao
86 abstract fun generoDao(): GeneroDao
87}Uso de GeneroConLibros en el ViewModel#
1// En el ViewModel: consumir la relación 1:N desde la UI
2class GeneroViewModel(private val repository: GeneroRepository) : ViewModel() {
3
4 val generosConLibros: StateFlow<List<GeneroConLibros>> =
5 repository.observarGenerosConLibros()
6 .stateIn(
7 scope = viewModelScope,
8 started = SharingStarted.WhileSubscribed(5_000),
9 initialValue = emptyList()
10 )
11}
12
13// En el Composable
14@Composable
15fun PantallaGeneros(viewModel: GeneroViewModel = viewModel(factory = ...)) {
16 val generosConLibros by viewModel.generosConLibros.collectAsStateWithLifecycle()
17
18 LazyColumn {
19 items(generosConLibros) { generoConLibros ->
20 Text(
21 text = "${generoConLibros.genero.nombre} " +
22 "(${generoConLibros.libros.size} libros)",
23 style = MaterialTheme.typography.titleMedium
24 )
25 generoConLibros.libros.forEach { libro ->
26 Text(" • ${libro.titulo} (★${libro.autor})")
27 }
28 }
29 }
30}Vídeo Relaciones 1:N https://documentation.javiercarrasco.es/media/pmdm/videos/B3_T4_06_relaciones_1N.mp4 Descargar
Interactivo Relaciones y CASCADE https://documentation.javiercarrasco.es/media/pmdm/simuladores/room/#relaciones Pantalla completa
7. Integración con la arquitectura MVVM#
La capa de datos de Room se integra con la arquitectura definida en el Bloque 2 a través de LocalDataSource y LibrosRepository.
LocalDataSource: wrapper del DAO#
LocalDataSource encapsula el DAO y proporciona una interfaz limpia al repositorio. El repositorio no conoce Room directamente; solo conoce LocalDataSource:
1// ─── data/datasource/local/LocalDataSource.kt ────────────────────────────────────────────────────
2class LocalDataSource(private val dao: LibrosDao) {
3 // Consultas reactivas — exponen el Flow del DAO
4 fun observarTodos(): Flow<List<Libro>> = dao.observarTodos()
5 fun observarFavoritos(): Flow<List<Libro>> = dao.observarFavoritos()
6 fun observarPorTitulo(busqueda: String): Flow<List<Libro>> =
7 dao.observarPorTitulo(busqueda)
8
9 fun observarPorId(id: String): Flow<Libro?> = dao.observarPorId(id)
10
11 // Escritura — delega directamente en el DAO
12 suspend fun upsertConservandoFavorito(libros: List<Libro>) =
13 dao.upsertConservandoFavorito(libros)
14
15 suspend fun toggleFavorito(id: String) = dao.toggleFavorito(id)
16 suspend fun insertarIgnorando(libros: List<Libro>) = dao.insertarIgnorando(libros)
17 suspend fun obtenerAutores(): List<String>? = dao.obtenerAutores()
18 suspend fun obtenerPorId(id: String): Libro? = dao.obtenerPorId(id)
19}LibrosRepository con datos locales (versión solo-ROOM)#
Para este tema, el repositorio trabaja únicamente con datos locales. En T6 se añadirá RemoteDataSource para la sincronización con la API:
1// ─── data/repository/LibrosRepository.kt ─────────────────────────────────────────────────────────
2class LibrosRepository(private val localDataSource: LocalDataSource) {
3
4 // La UI siempre observa desde Room
5 fun observarLibros(): Flow<List<Libro>> = localDataSource.observarTodos()
6 fun observarFavoritos(): Flow<List<Libro>> = localDataSource.observarFavoritos()
7 fun observarPorId(id: String): Flow<Libro?> = localDataSource.observarPorId(id)
8
9 suspend fun toggleFavorito(id: String) = localDataSource.toggleFavorito(id)
10 suspend fun insertarIgnorando(libros: List<Libro>) = localDataSource.insertarIgnorando(libros)
11 suspend fun obtenerAutores(): List<String>? = localDataSource.obtenerAutores()
12 suspend fun obtenerPorId(id: String): Libro? = localDataSource.obtenerPorId(id)
13}Flow frío y Flow caliente#
Todas las consultas reactivas del DAO devuelven un Flow<T>, y el ViewModel las transforma con stateIn en un StateFlow. Para entender el motivo por el que se hace esta conversión hay que distinguir los dos tipos de flujo que ofrece Kotlin: fríos (cold) y calientes (hot).
Nota
En el Anexo B2-A1 se presentaron brevemente ambos tipos. Aquí se retoman aplicados a Room, que es donde la diferencia tiene consecuencias prácticas.
Flow frío: una ejecución por cada colector#
Un Flow frío es perezoso: su código no se ejecuta al crearlo, sino cuando alguien lo recolecta con collect (o con collectAsStateWithLifecycle en Compose). La documentación de Kotlin lo compara con una receta: describe cómo producir los valores, pero solo empieza a producirlos cuando se recolecta. Además, cada colector lanza una ejecución nueva e independiente desde el principio.
Son flujos fríos los creados con el builder flow { }, con flowOf(...) o con asFlow(). También lo es el Flow que devuelve una consulta de Room: cada colector ejecuta su propia consulta SELECT y registra su propio observador sobre la tabla.
Flow caliente: un flujo compartido por todos#
Un Flow caliente existe y conserva su estado con independencia de que haya colectores. Recolectarlo no vuelve a ejecutar el código que produce los datos: todos los colectores comparten las mismas emisiones. Kotlin ofrece dos tipos de flujo caliente:
StateFlow: siempre tiene un valor actual, accesible mediante la propiedadvalue. Un colector nuevo recibe inmediatamente ese último valor y, después, los cambios posteriores. Es el que se usa para el estado de la UI.SharedFlow: no tiene valor actual por defecto; el parámetroreplayindica cuántos valores anteriores recibe un colector nuevo. Conreplay = 0es el que se usa para los eventos de un solo disparo, como el Snackbar de_eventosenLibrosViewModel.
Un flujo frío se convierte en caliente con los operadores stateIn, que devuelve un StateFlow, y shareIn, que devuelve un SharedFlow.
Diferencias entre ambos#
| Característica | Flow frío | Flow caliente (StateFlow / SharedFlow) |
|---|---|---|
| ¿Cuándo produce datos? | Solo cuando se recolecta | Con independencia de los colectores (con stateIn/shareIn, según la estrategia SharingStarted) |
| Varios colectores | Cada uno lanza su propia ejecución | Todos comparten la misma ejecución |
| Valor actual | No tiene | StateFlow sí (value); SharedFlow no |
| Un colector nuevo recibe… | La secuencia completa desde el principio | StateFlow: el último valor; SharedFlow: los replay últimos valores |
| Finalización | Cuando termina el bloque que produce los datos (el Flow de Room no termina: sigue observando la tabla) |
Nunca termina por sí solo: la recolección acaba al cancelar su ámbito |
| Ejemplos | flow { }, flowOf(), consultas Flow del DAO |
MutableStateFlow, MutableSharedFlow, resultado de stateIn y shareIn |
Ejemplo: la misma consulta, fría y caliente#
El siguiente ejemplo simula una consulta con un println para ver cuántas veces se ejecuta. Se puede probar en Kotlin Playground o en un archivo Scratch de Android Studio que use el classpath del módulo app:
1import kotlinx.coroutines.*
2import kotlinx.coroutines.flow.*
3
4fun main() = runBlocking {
5 // Flow frío que simula una consulta del DAO
6 val consulta: Flow<List<String>> = flow {
7 println("► Consulta ejecutada") // se imprime una vez por ejecución
8 emit(listOf("Proyecto Hail Mary", "Juego de tronos"))
9 }
10 println("Flow creado: todavía no se ha ejecutado nada")
11
12 // 1) FLOW FRÍO: dos colectores → dos ejecuciones
13 consulta.collect { println("Colector A recibe $it") }
14 consulta.collect { println("Colector B recibe $it") }
15
16 // 2) FLOW CALIENTE: stateIn ejecuta la consulta una sola vez y comparte el resultado
17 val ambito = CoroutineScope(Dispatchers.Default) // hace el papel de viewModelScope
18 val estado: StateFlow<List<String>> = consulta.stateIn(ambito) // suspende hasta el primer valor
19 println("Colector C lee ${estado.value}") // valor actual: no vuelve a consultar
20 estado.take(1).collect { println("Colector D recibe $it") } // recibe el último valor
21 ambito.cancel() // como al destruirse el ViewModel
22}Salida por consola:
Flow creado: todavía no se ha ejecutado nada
► Consulta ejecutada
Colector A recibe [Proyecto Hail Mary, Juego de tronos]
► Consulta ejecutada
Colector B recibe [Proyecto Hail Mary, Juego de tronos]
► Consulta ejecutada
Colector C lee [Proyecto Hail Mary, Juego de tronos]
Colector D recibe [Proyecto Hail Mary, Juego de tronos]Crear el Flow no ejecuta nada. Cada collect sobre el flujo frío repite la consulta. La tercera ejecución la lanza stateIn, y los colectores C y D ya no provocan ninguna más: leen el valor que el StateFlow tiene guardado.
Información
En el ejemplo se usa la variante
stateIn(scope), que arranca el flujo al momento y suspende hasta recibir el primer valor. En el ViewModel se usa la variante constartedeinitialValue, que no suspende y permite elegir cuándo se activa la consulta (ver la tabla deSharingStarteden el Anexo B2-A1).
¿Por qué stateIn en el ViewModel?#
Si la pantalla recolectara directamente repository.observarLibros(), que es un Flow frío:
- Cada colector lanzaría su propia consulta a Room y registraría su propio observador sobre la tabla.
- No habría valor actual: habría que indicar un valor inicial en cada colector de la UI.
- Cada vez que la pantalla deja de estar visible y vuelve (por ejemplo, al girar el dispositivo),
collectAsStateWithLifecyclevuelve a recolectar y la consulta se repite desde cero.
FLOW FRÍO (Flow<List<Libro>> del DAO)
Colector A ──collect──► SELECT #1 ──► su propia lista
Colector B ──collect──► SELECT #2 ──► su propia lista
FLOW CALIENTE (StateFlow<LibrosUiState> creado con stateIn)
┌──► Colector A (valor actual)
SELECT #1 ──► uiState ──┼──► Colector B (valor actual)
└──► Colector C (valor actual)Con stateIn, el ViewModel mantiene una única suscripción a Room dentro de viewModelScope y la comparte con todos los colectores. Siempre ofrece un valor (LibrosUiState.Cargando mientras llega el primero) y, gracias a SharingStarted.WhileSubscribed(5_000), sobrevive a la rotación sin repetir la consulta.
Importante
Regla práctica: el DAO, el
LocalDataSourcey el repositorio exponen Flows fríos. El ViewModel los convierte en calientes:stateInpara el estado ySharedFlowpara los eventos. La UI solo recolecta flujos calientes.
Interactivo Flow frío frente a StateFlow https://documentation.javiercarrasco.es/media/pmdm/simuladores/flujos/ Pantalla completa
Interactivo SharingStarted y ciclo de vida https://documentation.javiercarrasco.es/media/pmdm/simuladores/flujos/#sharingstarted Pantalla completa
ViewModel consumiendo el repositorio con stateIn#
El LibrosViewModel aplica lo anterior: convierte el Flow frío del repositorio en un StateFlow caliente con stateIn (ver Flow frío y Flow caliente) y usa un SharedFlow para los eventos del Snackbar.
Ver LibrosViewModel
1// ─── screens/listado/LibrosViewModel.kt ──────────────────────────────────────────────────────────
2class LibrosViewModel(
3 private val repository: LibrosRepository
4) : ViewModel() {
5
6 private val _busqueda = MutableStateFlow("")
7 val busqueda: StateFlow<String> = _busqueda.asStateFlow()
8
9 private val _autores = MutableStateFlow(listOf("Todos"))
10 val autores: StateFlow<List<String>> = _autores.asStateFlow()
11
12 // Eventos de un solo disparo (Snackbar), como en T2 §6
13 private val _eventos = MutableSharedFlow<LibrosEvento>(replay = 0, extraBufferCapacity = 1)
14 val eventos: SharedFlow<LibrosEvento> = _eventos.asSharedFlow()
15
16 // stateIn convierte el Flow frío del repositorio en un StateFlow caliente
17 // SharingStarted.WhileSubscribed(5_000): el upstream se cancela 5 segundos
18 // después de que el último suscriptor desaparezca. Esto cubre rotaciones de
19 // pantalla (< 5s) sin mantener recursos cuando la app va a segundo plano.
20 val uiState: StateFlow<LibrosUiState> =
21 repository.observarLibros()
22 .map<List<Libro>, LibrosUiState> {
23 cargarAutores() // cargar autores para el filtro de la UI
24 LibrosUiState.Exito(it)
25 }
26 .catch { error -> emit(LibrosUiState.Error(error.message ?: "Error")) }
27 .stateIn(
28 scope = viewModelScope,
29 started = SharingStarted.WhileSubscribed(5_000),
30 initialValue = LibrosUiState.Cargando
31 )
32
33 fun cargarAutores() {
34 viewModelScope.launch {
35 val autores = repository.obtenerAutores() ?: emptyList()
36 _autores.value = listOf("Todos") + autores
37 }
38 }
39
40 fun actualizarBusqueda(texto: String) { _busqueda.value = texto }
41
42 fun toggleFavorito(libro: Libro) {
43 viewModelScope.launch {
44 repository.toggleFavorito(libro.id)
45 // No es necesario actualizar el uiState manualmente:
46 // el Flow de Room detecta el cambio y emite la nueva lista automáticamente
47
48 _eventos.emit(
49 LibrosEvento.MostrarSnackbar(
50 if (libro.esFavorito) "\"${libro.titulo}\" eliminado de favoritos"
51 else "\"${libro.titulo}\" añadido a favoritos"
52 )
53 )
54 }
55 }
56
57 companion object {
58 val Factory: ViewModelProvider.Factory = viewModelFactory {
59 initializer {
60 val app = checkNotNull(
61 this[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY]
62 ) as AppDummyApplication
63 LibrosViewModel(app.container.librosRepository)
64 }
65 }
66 }
67}Vídeo Flujo reactivo con Room https://documentation.javiercarrasco.es/media/pmdm/videos/B3_T4_03_room_flow_reactivo.mp4 Descargar
Interactivo Estado frente a eventos: StateFlow y SharedFlow https://documentation.javiercarrasco.es/media/pmdm/simuladores/flujos/#eventos Pantalla completa
8. AppContainer: inyección de dependencias manual#
Sin Hilt ni Koin, el patrón recomendado es crear un AppContainer en la clase Application que centraliza la creación y gestión del ciclo de vida de las dependencias:
1// ─── data/di/AppContainer.kt ─────────────────────────────────────────────────────────────────────
2interface AppContainer {
3 val librosRepository: LibrosRepository
4}
5
6class DefaultAppContainer(context: Context) : AppContainer {
7
8 // Room — singleton de la base de datos
9 // by lazy: se crea una sola vez la primera vez que se accede
10 private val database by lazy {
11 AppDatabase.getDatabase(context)
12 }
13
14 private val localDataSource: LocalDataSource by lazy {
15 LocalDataSource(database.libroDao())
16 }
17
18 override val librosRepository: LibrosRepository by lazy {
19 LibrosRepository(localDataSource)
20 }
21}1// ─── AppDummyApplication.kt ──────────────────────────────────────────────────────────────────────
2class AppDummyApplication : Application() {
3 lateinit var container: AppContainer
4
5 override fun onCreate() {
6 super.onCreate()
7 container = DefaultAppContainer(this)
8 }
9}Registrar la clase Application personalizada en AndroidManifest.xml:
1<application
2 android:name=".AppDummyApplication"
3 android:label="@string/app_name"
4 ... >9. Testing de Room#
Room proporciona soporte específico para tests mediante bases de datos en memoria: se crean para el test y se destruyen al terminar, sin dejar rastro en el disco del dispositivo.
Ver LibroDaoTest
1// ─── LibroDaoTest.kt - com.ejemplo.appdummy (androidTest) ────────────────────────────────────────
2@RunWith(AndroidJUnit4::class)
3class LibroDaoTest {
4
5 private lateinit var database: AppDatabase
6 private lateinit var dao: LibrosDao
7
8 @Before
9 fun crearBaseDeDatos() {
10 // inMemoryDatabaseBuilder: BD temporal en RAM para tests
11 database = Room.inMemoryDatabaseBuilder(
12 ApplicationProvider.getApplicationContext(),
13 AppDatabase::class.java
14 )
15 .allowMainThreadQueries() // solo en tests — simplifica la escritura
16 .build()
17 dao = database.libroDao()
18 }
19
20 @After
21 fun cerrarBaseDeDatos() {
22 database.close()
23 }
24
25 @Test
26 fun insertarYRecuperarLibro() = runTest {
27 val libro = Libro(
28 id = "550",
29 titulo = "Proyecto Hail Mary",
30 autor = "Andy Weir",
31 year = 2021,
32 isbn = "9788418037016",
33 cover = "https://covers.openlibrary.org/b/isbn/9788418037016-L.jpg"
34 )
35 dao.upsert(libro)
36
37 val resultado = dao.obtenerPorId("550")
38 assertEquals("Proyecto Hail Mary", resultado?.titulo)
39 assertFalse(resultado!!.esFavorito) // por defecto es false
40 }
41
42 @Test
43 fun toggleFavoritaInvierteElEstado() = runTest {
44 val libro = Libro(
45 id = "2",
46 titulo = "Juego de tronos",
47 autor = "George R.R. Martin",
48 year = 1996,
49 isbn = "9780307951182",
50 cover = "https://covers.openlibrary.org/b/isbn/9780307951182-L.jpg"
51 )
52 dao.upsert(libro)
53
54 // Primera llamada: false → true
55 dao.toggleFavorito("2")
56 assertTrue(dao.obtenerPorId("2")!!.esFavorito)
57
58 // Segunda llamada: true → false
59 dao.toggleFavorito("2")
60 assertFalse(dao.obtenerPorId("2")!!.esFavorito)
61 }
62
63 @Test
64 fun upsertConservandoFavoritoNoSobreescribeFavorito() = runTest {
65 // 1. Insertar libro con esFavorito = true
66 val libroOriginal = Libro(
67 "3",
68 "Festín de cuervos",
69 "George R.R. Martin",
70 2005,
71 "9780307951212",
72 "https://covers.openlibrary.org/b/isbn/9780307951212-L.jpg",
73 true,
74 false
75 )
76 dao.upsert(libroOriginal)
77
78 // 2. Llamar a upsertConservandoFavorito con el mismo id pero esFavorito = false
79 // (como llegaría de la API)
80 val libroDeApi = libroOriginal.copy(
81 titulo = "Festín de cisnes", // título actualizado
82 year = 2026, // año actualizado
83 esFavorito = false // la API no sabe que el usuario la marcó como favorito
84 )
85 dao.upsertConservandoFavorito(listOf(libroDeApi))
86
87 // 3. Verificar que el título se actualizó pero esFavorita sigue siendo true
88 val resultado = dao.obtenerPorId("3")
89 assertEquals("Festín de cisnes", resultado?.titulo)
90 assertEquals(2026, resultado?.year)
91 assertTrue(resultado!!.esFavorito) // ✅ se conservó el favorito del usuario
92 }
93
94 @Test
95 fun observarFavoritasEmiteSoloLasMarcadas() = runTest {
96 // Insertar varios libros, algunos favoritos
97 dao.upsert(
98 Libro(
99 id = "1",
100 titulo = "Proyecto Hail Mary",
101 autor = "Andy Weir",
102 year = 2021,
103 isbn = "9788418037016",
104 cover = "https://covers.openlibrary.org/b/isbn/9788418037016-L.jpg",
105 esFavorito = true,
106 leido = false
107 )
108 )
109 dao.upsert(
110 Libro(
111 id = "2",
112 titulo = "Juego de tronos",
113 autor = "George R.R. Martin",
114 year = 1996,
115 isbn = "9780307951182",
116 cover = "https://covers.openlibrary.org/b/isbn/9780307951182-L.jpg",
117 esFavorito = true,
118 leido = true
119 )
120 )
121 dao.upsert(
122 Libro(
123 "3",
124 "Festín de cuervos",
125 "George R.R. Martin",
126 2005,
127 "9780307951212",
128 "https://covers.openlibrary.org/b/isbn/9780307951212-L.jpg",
129 false,
130 false
131 ),
132 )
133
134 // first() recoge el primer valor emitido por el Flow y cancela la colección
135 val favoritas = dao.observarFavoritos().first()
136 assertEquals(2, favoritas.size)
137 assertTrue(favoritas.all { it.esFavorito })
138 }
139}Vídeo Favorito perdido https://documentation.javiercarrasco.es/media/pmdm/videos/B3_T4_03b_favorito_perdido.mp4 Descargar
Estructura de paquetes al final de T4#
Ver la estructura de paquetes
com.ejemplo.appdummy/
├── AppDummyApplication.kt
├── MainActivity.kt
│
├── data/
│ ├── model/
│ │ └── Libro.kt ← @Entity (se extenderá en T5 con @SerializedName)
│ ├── datasource/
│ │ └── local/
│ │ ├── AppDatabase.kt ← @Database
│ │ ├── Converters.kt ← @TypeConverter
│ │ ├── LibrosDao.kt ← @Dao
│ │ └── LocalDataSource.kt ← wrapper del DAO
│ ├── repository/
│ │ └── LibrosRepository.kt ← solo LocalDataSource (T5 añadirá Remote)
│ └── di/
│ └── AppContainer.kt ← DefaultAppContainer + AppContainer interface
│
├── screens/
│ ├── detalle/
│ │ ├── DetalleUiState.kt
│ │ ├── DetalleViewModel.kt
│ │ └── PantallaDetalle.kt
│ ├── favoritos/
│ │ ├── FavoritosViewModel.kt
│ │ └── PantallaFavoritos.kt
│ └── listado/
│ ├── LibrosUiState.kt
│ ├── LibrosViewModel.kt ← usa stateIn() con SharingStarted.WhileSubscribed
│ └── PantallaListado.kt
│
└── navegacion/
├── AppDummyBottomBar.kt
├── AppNavigation.kt
├── ItemsNavegacion.kt
└── Rutas.ktDesarrollo práctico guiado: AppDummy con Room 💻#
Llegados a este punto, la app ya tiene persistencia local con Room y puede guardar los libros favoritos entre reinicios. En T5 se integrará la API de OpenLibrary para obtener los datos reales. Además, deberás realizar los siguientes ajustes para que la app funcione correctamente con Room:
- Modifica todos los factory de los ViewModel para que obtengan el repositorio desde
AppContaineren lugar de crearlo directamente. Esto permite que todas las dependencias se gestionen desde un único punto, facilitando pruebas y mantenimiento. - El botón “Reintentar” de la pantalla de error de momento no será necesario, por lo que puedes eliminarlo o comentarlo.
- En
LocalRepositoryse modifica el métodogetLibros()para que devuelva la lista estática de libros, dejerá se sersuspendy se utilizará para simular una carga de datos en Room antes de integrar la API en T5. También deberás modificar el métodocargarLibros()deLibrosViewModelpara que llame agetLibros()y actualice el estado de la UI en consecuencia. - Añade un
FloatActionButtonen la pantalla de listado de libros que permita de momento realizar la carga de libros desde la lista estática. Este botón será reemplazado en T5 por la carga desde la API. - Igual que favoritos, deberás implementar la funcionalidad de marcar libros como leídos.
- Ajusta la forma de obtener la información de un libro en la pantalla de detalle para que se obtenga desde Room y no desde la lista estática. Esto implica modificar el
ViewModelde detalle para que consulte aLibrosRepositoryy obtenga el libro correspondiente por su ID. - También deberás modificar la vista de favoritos para que obtenga la lista de libros favoritos desde Room en lugar de la lista completa. Esto implica actualizar el
ViewModelde favoritos para que consulte aLibrosRepositoryy obtenga solo los libros marcados como favoritos.
Referencias#
- Guardar datos en una base de datos local con Room — Android Developers
- Definir datos con entidades de Room — Android Developers
- Acceder a los datos con DAOs de Room — Android Developers
- Relaciones entre objetos en Room — Android Developers
- Releases de Room — Android Developers
- Releases de KSP — GitHub
- StateFlow y SharedFlow — Android Developers
- Flujos fríos (cold flows) — Documentación de Kotlin
- Anexo B2-A1 — Referencia: StateFlow, corrutinas y arquitectura MVVM
- Anexo B3-A1 — Referencia: Room avanzado, migraciones, testing y API Key segura