Tema 4. ROOM: Persistencia local

  • Bloque: B3 — Persistencia y comunicación: ROOM + Retrofit2
  • Duración aproximada: 12 horas
  • 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.
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.
RA2-f Se han almacenado y recuperado datos utilizando algún mecanismo de persistencia.
RA2-g Se han realizado pruebas de interacción usuario-aplicación para optimizar las aplicaciones desarrolladas.
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 de un formato a otro.
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 v5. Ejemplo práctico Tema 3 .

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.

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}

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:

┌──────────────────────────────────────────────────────────┐
│                      @Database                           │
│  (AppDatabase — punto de entrada a la base de datos)     │
│                                                          │
│  ┌─────────────────────┐   ┌────────────────────────┐    │
│  │       @Entity       │   │         @Dao           │    │
│  │   (tabla en BD)     │   │  (operaciones sobre    │    │
│  │                     │   │     la tabla)          │    │
│  │  data class Libro   │   │  @Query, @Insert, ...  │    │
│  └─────────────────────┘   └────────────────────────┘    │
└──────────────────────────────────────────────────────────┘

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.

 1import androidx.room.ColumnInfo
 2import androidx.room.Entity
 3import androidx.room.PrimaryKey
 4
 5// ─── datal/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/Retrofit, estos campos quedan a false (valor por defecto)
36    @ColumnInfo(name = "es_favorito")
37    val esFavorito: Boolean = false,
38
39    @ColumnInfo(name = "leido")
40    val leido: Boolean = false
41)

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.
 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}

¿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.


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.


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}

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

@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.

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 OBLIGATORIO con @Relation
51    // Room ejecuta múltiples consultas internamente y @Transaction garantiza
52    // que todas leen el mismo estado consistente de la base de datos
53    @Transaction
54    @Query("SELECT * FROM generos ORDER BY nombre ASC")
55    fun observarGenerosConLibros(): Flow<List<GeneroConLibros>>
56
57    @Transaction
58    @Query("SELECT * FROM generos WHERE generoId = :id")
59    fun observarGeneroConLibros(id: String): Flow<GeneroConLibros?>
60
61    @Insert(onConflict = OnConflictStrategy.REPLACE)
62    suspend fun insertarGenero(genero: Genero)
63
64    @Insert(onConflict = OnConflictStrategy.REPLACE)
65    suspend fun insertarLibros(libros: List<LibroGenero>)
66}
67
68// ─── Actualizar la @Database con las nuevas entidades ────────────────────────────────────────────
69@Database(
70    entities = [
71        Libro::class,
72        Genero::class,
73        LibroGenero::class
74    ],
75    version = 2,           // incrementar la versión al añadir tablas
76    exportSchema = true
77)
78@TypeConverters(Converters::class)
79abstract class AppDatabase : RoomDatabase() {
80    abstract fun libroDao(): LibrosDao
81    abstract fun generoDao(): GeneroDao
82}

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}

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}

ViewModel consumiendo el repositorio con stateIn#

 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    // stateIn convierte el Flow frío del repositorio en un StateFlow caliente
10    // SharingStarted.WhileSubscribed(5_000): el upstream se cancela 5 segundos
11    // después de que el último suscriptor desaparezca. Esto cubre rotaciones de
12    // pantalla (< 5s) sin mantener recursos cuando la app va a segundo plano.
13    val uiState: StateFlow<LibrosUiState> =
14        repository.observarLibros()
15            .map<List<Libro>, LibrosUiState> {
16                cargarAutores()   // cargar autores para el filtro de la UI
17                LibrosUiState.Exito(it)
18            }
19            .catch { error -> emit(LibrosUiState.Error(error.message ?: "Error")) }
20            .stateIn(
21                scope = viewModelScope,
22                started = SharingStarted.WhileSubscribed(5_000),
23                initialValue = LibrosUiState.Cargando
24            )
25
26    fun cargarAutores() {
27        viewModelScope.launch {
28            val autores = repository.obtenerAutores() ?: emptyList()
29            _autores.value = listOf("Todos") + autores
30        }
31    }
32
33    fun actualizarBusqueda(texto: String) { _busqueda.value = texto }
34
35    fun toggleFavorito(libro: Libro) {
36        viewModelScope.launch {
37            repository.toggleFavorito(libro.id)
38            // No es necesario actualizar el uiState manualmente:
39            // el Flow de Room detecta el cambio y emite la nueva lista automáticamente
40        
41            _eventos.emit(
42                LibrosEvento.MostrarSnackbar(
43                    if (libro.esFavorito) "\"${libro.titulo}\" eliminado de favoritos"
44                    else "\"${libro.titulo}\" añadido a favoritos"
45                )
46            )
47        }
48    }
49
50    companion object {
51        val Factory: ViewModelProvider.Factory = viewModelFactory {
52            initializer {
53                val app = checkNotNull(
54                    this[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY]
55                ) as AppDummyApplication
56                LibrosViewModel(app.container.librosRepository)
57            }
58        }
59    }
60}

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.

  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}

Estructura de paquetes al final de T4#

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: martes, 28 de julio de 2026