Tema 4. ROOM: Persistencia local

Bloque 3 Persistencia y comunicación: ROOM + Retrofit2
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ódigoCriterio
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 identificador 2.1.20-1.0.31 indica 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.

Código fuenteVer 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 String en lugar de un Int. 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 campo id se ha cambiado a String para 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 (sin suspend): Room re-emite automáticamente los datos cada vez que cambia la tabla observada.
  • suspend fun para operaciones de escritura (@Insert, @Update, @Delete) y consultas de una sola vez.
Código fuenteVer 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é upsertConservandoFavorito y no @Upsert directamente? La anotación @Upsert de Room realiza un INSERT OR REPLACE a nivel SQL, que actualiza todas las columnas del objeto. Si el objeto que llega de la API tiene esFavorito = false (el valor por defecto), y el usuario había marcado ese libro como favorito, @Upsert sobreescribirá el true con false, perdiendo la preferencia del usuario. El patrón upsertConservandoFavorito resuelve esto: primero intenta insertar (ignorando los duplicados), y para los que ya existían, actualiza solo las columnas que vienen de la API, dejando esFavorito intacto.

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, como upsertConservandoFavorito; en un DAO abstract class, la función no debe ser abstract.
  • Si la función es suspend, Room ejecuta la transacción de forma asíncrona sin bloquear el hilo principal. Si devuelve un Flow, 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/catch dentro del propio cuerpo, Room considera que la función terminó bien y confirma los cambios.

Importante

Resumen: usa @Transaction cuando una función del DAO agrupa varias operaciones que deben aplicarse juntas, o cuando una @Query devuelve una clase con @Relation. Para un @Insert, @Update o @Delete aislado 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 Date a Long (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

@Relation se 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 generoId en la tabla de libros, y luego usar @Relation para 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 propiedad value. 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ámetro replay indica cuántos valores anteriores recibe un colector nuevo. Con replay = 0 es el que se usa para los eventos de un solo disparo, como el Snackbar de _eventos en LibrosViewModel.

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 con started e initialValue, que no suspende y permite elegir cuándo se activa la consulta (ver la tabla de SharingStarted en 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), collectAsStateWithLifecycle vuelve 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 LocalDataSource y el repositorio exponen Flows fríos. El ViewModel los convierte en calientes: stateIn para el estado y SharedFlow para 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.

Código fuenteVer 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.

Código fuenteVer 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#

Estructura de directoriosVer 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.kt

Desarrollo 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 AppContainer en 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 LocalRepository se modifica el método getLibros() para que devuelva la lista estática de libros, dejerá se ser suspend y se utilizará para simular una carga de datos en Room antes de integrar la API en T5. También deberás modificar el método cargarLibros() de LibrosViewModel para que llame a getLibros() y actualice el estado de la UI en consecuencia.
  • Añade un FloatActionButton en 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 ViewModel de detalle para que consulte a LibrosRepository y 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 ViewModel de favoritos para que consulte a LibrosRepository y obtenga solo los libros marcados como favoritos.

Referencias#

Calendar  Última modificación: sábado, 7 de noviembre de 2026