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#

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.

 1// settings.gradle.kts — comprobar que está el repositorio de Google
 2pluginManagement {
 3    repositories {
 4        google()
 5        mavenCentral()
 6        gradlePluginPortal()
 7    }
 8}
 9
10// build.gradle.kts (nivel de proyecto)
11plugins {
12    id("com.google.devtools.ksp") version "2.1.20-1.0.31" apply false
13    id("androidx.room") version "2.7.1"              apply false
14}
 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.7.1")
18    implementation("androidx.room:room-ktx:2.7.1")       // extensiones de corrutinas
19    ksp("androidx.room:room-compiler:2.7.1")             // generador de código (KSP, NO kapt)
20
21    // Testing de Room
22    testImplementation("androidx.room:room-testing:2.7.1")
23    androidTestImplementation("androidx.room:room-testing:2.7.1")
24}

Compatibilidad KSP–Kotlin: la versión de KSP 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. Consulta las releases de KSP en GitHub para encontrar la versión correspondiente.


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 Pelicula(...)
val cursor = db.rawQuery(               @Dao interface PeliculaDao {
    "SELECT * FROM peliculas", null)      @Query("SELECT * FROM peliculas")
val peliculas = mutableListOf<...>()      fun observarTodas(): Flow<List<Pelicula>>
while (cursor.moveToNext()) {           }
    peliculas.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 Pelicula│   │  @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.

 1// data/model/Pelicula.kt
 2// Esta misma clase se reutilizará en B3-T5 con Retrofit2
 3import androidx.room.ColumnInfo
 4import androidx.room.Entity
 5import androidx.room.PrimaryKey
 6
 7@Entity(tableName = "peliculas")
 8data class Pelicula(
 9
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 AppFlix usamos el id de TMDB, así que autoGenerate = false
14    @PrimaryKey
15    val id: Int,
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 = "genero")
23    val genero: String,
24
25    @ColumnInfo(name = "puntuacion")
26    val puntuacion: Double,
27
28    @ColumnInfo(name = "sinopsis")
29    val sinopsis: String = "",
30
31    @ColumnInfo(name = "poster_url")
32    val posterUrl: String? = null,
33
34    // Campo exclusivamente local: no existe en la API de TMDB
35    // Al deserializar con Gson/Retrofit, este campo queda a false (valor por defecto)
36    @ColumnInfo(name = "es_favorita")
37    val esFavorita: Boolean = false
38)

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 = "peliculas",
4    indices = [
5        Index(value = ["titulo"]),                           // búsqueda por título
6        Index(value = ["genero", "puntuacion"], unique = false)  // búsqueda combinada
7    ]
8)
9data class Pelicula(...)

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.
 1// data/datasource/local/PeliculaDao.kt
 2import androidx.room.*
 3import kotlinx.coroutines.flow.Flow
 4
 5@Dao
 6interface PeliculaDao {
 7
 8    // ─── Consultas reactivas (Flow, SIN suspend) ──────────────────────────────
 9    // Room observa la tabla y emite una nueva lista cada vez que hay cambios
10
11    @Query("SELECT * FROM peliculas ORDER BY puntuacion DESC")
12    fun observarTodas(): Flow<List<Pelicula>>
13
14    @Query("SELECT * FROM peliculas WHERE es_favorita = 1 ORDER BY titulo ASC")
15    fun observarFavoritas(): Flow<List<Pelicula>>
16
17    @Query("SELECT * FROM peliculas WHERE titulo LIKE '%' || :busqueda || '%' ORDER BY puntuacion DESC")
18    fun observarPorTitulo(busqueda: String): Flow<List<Pelicula>>
19
20    // Observar una sola película (devuelve null si no existe)
21    @Query("SELECT * FROM peliculas WHERE id = :id")
22    fun observarPorId(id: Int): Flow<Pelicula?>
23
24    // ─── Consulta puntual (suspend, SIN Flow) ─────────────────────────────────
25    // Obtiene el valor una sola vez, sin observar cambios posteriores
26
27    @Query("SELECT * FROM peliculas WHERE id = :id")
28    suspend fun obtenerPorId(id: Int): Pelicula?
29
30    // ─── Escritura (siempre suspend) ─────────────────────────────────────────
31
32    // @Insert con OnConflictStrategy.IGNORE: si la película ya existe, la ignora
33    // Devuelve el rowId de cada fila insertada (-1 si fue ignorada por conflicto)
34    @Insert(onConflict = OnConflictStrategy.IGNORE)
35    suspend fun insertarIgnorando(peliculas: List<Pelicula>): List<Long>
36
37    // @Insert con REPLACE: elimina la fila existente y la reinserta
38    // ⚠️ Provoca pérdida de campos locales como esFavorita — usar con cuidado
39    @Insert(onConflict = OnConflictStrategy.REPLACE)
40    suspend fun insertarReemplazando(pelicula: Pelicula)
41
42    // @Upsert (disponible desde Room 2.5): intenta INSERT, si hay conflicto hace UPDATE
43    // ⚠️ También sobreescribe esFavorita si el objeto tiene esFavorita=false
44    @Upsert
45    suspend fun upsert(pelicula: Pelicula)
46
47    @Update
48    suspend fun actualizar(pelicula: Pelicula)
49
50    @Delete
51    suspend fun eliminar(pelicula: Pelicula)
52
53    // @Query de escritura: permite actualizaciones selectivas de columnas concretas
54    // Solución al problema de esFavorita: actualiza solo los campos de la API
55    @Query("""
56        UPDATE peliculas SET
57            titulo     = :titulo,
58            genero     = :genero,
59            puntuacion = :puntuacion,
60            sinopsis   = :sinopsis,
61            poster_url = :posterUrl
62        WHERE id = :id
63    """)
64    suspend fun actualizarDesdeRed(
65        id: Int,
66        titulo: String,
67        genero: String,
68        puntuacion: Double,
69        sinopsis: String,
70        posterUrl: String?
71    )
72
73    // Toggle de favorita: invierte el valor booleano en la base de datos
74    @Query("UPDATE peliculas SET es_favorita = NOT es_favorita WHERE id = :id")
75    suspend fun toggleFavorita(id: Int)
76
77    // @Transaction: garantiza que varias operaciones se ejecutan de forma atómica
78    // Si alguna falla, todas se revierten (rollback)
79    @Transaction
80    suspend fun upsertConservandoFavorita(peliculas: List<Pelicula>) {
81        val resultados = insertarIgnorando(peliculas)
82        // Para cada película que ya existía (resultado == -1L), actualizar solo los campos de la API
83        peliculas.forEachIndexed { index, pelicula ->
84            if (resultados[index] == -1L) {
85                actualizarDesdeRed(
86                    id = pelicula.id,
87                    titulo = pelicula.titulo,
88                    genero = pelicula.genero,
89                    puntuacion = pelicula.puntuacion,
90                    sinopsis = pelicula.sinopsis,
91                    posterUrl = pelicula.posterUrl
92                )
93            }
94        }
95    }
96}

¿Por qué upsertConservandoFavorita 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 esFavorita = false (el valor por defecto), y el usuario había marcado esa película como favorita, @Upsert sobreescribirá el true con false, perdiendo la preferencia del usuario. El patrón upsertConservandoFavorita resuelve esto: primero intenta insertar (ignorando los duplicados), y para los que ya existían, actualiza solo las columnas que provienen de la API, dejando esFavorita 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.

 1// data/datasource/local/AppDatabase.kt
 2import androidx.room.Database
 3import androidx.room.RoomDatabase
 4
 5@Database(
 6    entities = [Pelicula::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 peliculaDao(): PeliculaDao
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 académicos donde los datos pueden perderse sin problema, se usa fallbackToDestructiveMigration:

 1// En el AppContainer — creación del singleton de la base de datos
 2val database: AppDatabase = Room.databaseBuilder(
 3    context.applicationContext,
 4    AppDatabase::class.java,
 5    "appflix_database"
 6)
 7// Para proyectos de aprendizaje: si cambia el esquema, borra y recrea la BD
 8// En producción real se escribirían objetos Migration en lugar de esto
 9.fallbackToDestructiveMigration(dropAllTables = true)
10.build()

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.

 1// data/datasource/local/Converters.kt
 2import androidx.room.TypeConverter
 3import com.google.gson.Gson
 4import com.google.gson.reflect.TypeToken
 5
 6class Converters {
 7
 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}
27
28// Registrar los converters en la @Database
29@Database(entities = [Pelicula::class], version = 1, exportSchema = true)
30@TypeConverters(Converters::class)   // ← aquí se registran
31abstract class AppDatabase : RoomDatabase() {
32    abstract fun peliculaDao(): PeliculaDao
33}

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 AppFlix sería relacionar un género con sus películas: un género tiene muchas películas (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.

 1// ─── Entidades (tablas) ────────────────────────────────────────────────────
 2
 3@Entity(tableName = "generos")
 4data class Genero(
 5    @PrimaryKey val generoId: Int,
 6    val nombre: String,
 7    val descripcion: String = ""
 8)
 9
10@Entity(
11    tableName = "peliculas_categorias",
12    foreignKeys = [
13        ForeignKey(
14            entity = Genero::class,
15            parentColumns = ["generoId"],
16            childColumns = ["generoOwnerId"],
17            // CASCADE: si se elimina un Genero, se eliminan sus películas asociadas
18            onDelete = ForeignKey.CASCADE
19        )
20    ]
21)
22data class PeliculaCategoria(
23    @PrimaryKey val peliculaId: Int,
24    // index = true mejora el rendimiento de las JOIN
25    @ColumnInfo(index = true) val generoOwnerId: Int,
26    val titulo: String,
27    val puntuacion: Double
28)
29
30// ─── POJO de relación (NO es @Entity — no crea tabla) ─────────────────────
31// Representa el resultado de combinar un Genero con todas sus películas
32data class GeneroConPeliculas(
33
34    // @Embedded: incluye todas las columnas de Genero en el resultado
35    @Embedded val genero: Genero,
36
37    // @Relation: Room genera automáticamente la consulta JOIN
38    // parentColumn: columna de la tabla padre (Genero.generoId)
39    // entityColumn: columna de la tabla hija que referencia al padre
40    @Relation(
41        parentColumn = "generoId",
42        entityColumn = "generoOwnerId"
43    )
44    val peliculas: List<PeliculaCategoria>
45)
46
47// ─── DAO con @Transaction ──────────────────────────────────────────────────
48@Dao
49interface GeneroDao {
50
51    // @Transaction es OBLIGATORIO con @Relation
52    // Room ejecuta múltiples consultas internamente y @Transaction garantiza
53    // que todas leen el mismo estado consistente de la base de datos
54    @Transaction
55    @Query("SELECT * FROM generos ORDER BY nombre ASC")
56    fun observarGenerosConPeliculas(): Flow<List<GeneroConPeliculas>>
57
58    @Transaction
59    @Query("SELECT * FROM generos WHERE generoId = :id")
60    fun observarGeneroConPeliculas(id: Int): Flow<GeneroConPeliculas?>
61
62    @Insert(onConflict = OnConflictStrategy.REPLACE)
63    suspend fun insertarGenero(genero: Genero)
64
65    @Insert(onConflict = OnConflictStrategy.REPLACE)
66    suspend fun insertarPeliculas(peliculas: List<PeliculaCategoria>)
67}
68
69// ─── Actualizar la @Database con las nuevas entidades ─────────────────────
70@Database(
71    entities = [
72        Pelicula::class,
73        Genero::class,
74        PeliculaCategoria::class
75    ],
76    version = 2,           // incrementar la versión al añadir tablas
77    exportSchema = true
78)
79@TypeConverters(Converters::class)
80abstract class AppDatabase : RoomDatabase() {
81    abstract fun peliculaDao(): PeliculaDao
82    abstract fun generoDao(): GeneroDao
83}

Uso de GeneroConPeliculas 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 generosConPeliculas: StateFlow<List<GeneroConPeliculas>> =
 5        repository.observarGenerosConPeliculas()
 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 generosConPeliculas by viewModel.generosConPeliculas.collectAsStateWithLifecycle()
17
18    LazyColumn {
19        items(generosConPeliculas) { generoConPeliculas ->
20            Text(
21                text = "${generoConPeliculas.genero.nombre} " +
22                       "(${generoConPeliculas.peliculas.size} películas)",
23                style = MaterialTheme.typography.titleMedium
24            )
25            generoConPeliculas.peliculas.forEach { pelicula ->
26                Text("  • ${pelicula.titulo} (★${pelicula.puntuacion})")
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 PeliculasRepository.

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: PeliculaDao) {
 3
 4    // Consultas reactivas — exponen el Flow del DAO
 5    fun observarTodas(): Flow<List<Pelicula>> = dao.observarTodas()
 6    fun observarFavoritas(): Flow<List<Pelicula>> = dao.observarFavoritas()
 7    fun observarPorTitulo(busqueda: String): Flow<List<Pelicula>> =
 8        dao.observarPorTitulo(busqueda)
 9    fun observarPorId(id: Int): Flow<Pelicula?> = dao.observarPorId(id)
10
11    // Escritura — delega directamente en el DAO
12    suspend fun upsertConservandoFavorita(peliculas: List<Pelicula>) =
13        dao.upsertConservandoFavorita(peliculas)
14
15    suspend fun toggleFavorita(id: Int) = dao.toggleFavorita(id)
16}

PeliculasRepository 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/PeliculasRepository.kt
 2class PeliculasRepository(
 3    private val localDataSource: LocalDataSource
 4) {
 5    // La UI siempre observa desde Room
 6    fun observarPeliculas(): Flow<List<Pelicula>> = localDataSource.observarTodas()
 7    fun observarFavoritas(): Flow<List<Pelicula>> = localDataSource.observarFavoritas()
 8    fun observarPorId(id: Int): Flow<Pelicula?> = localDataSource.observarPorId(id)
 9
10    suspend fun toggleFavorita(id: Int) = localDataSource.toggleFavorita(id)
11
12    // En T6 se añadirá: suspend fun sincronizar()
13}

ViewModel consumiendo el repositorio con stateIn#

 1// ui/listado/PeliculasViewModel.kt
 2class PeliculasViewModel(
 3    private val repository: PeliculasRepository
 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<PeliculasUiState> =
14        repository.observarPeliculas()
15            .map<List<Pelicula>, PeliculasUiState> { PeliculasUiState.Exito(it) }
16            .catch { error -> emit(PeliculasUiState.Error(error.message ?: "Error")) }
17            .stateIn(
18                scope = viewModelScope,
19                started = SharingStarted.WhileSubscribed(5_000),
20                initialValue = PeliculasUiState.Cargando
21            )
22
23    fun actualizarBusqueda(texto: String) { _busqueda.value = texto }
24
25    fun toggleFavorita(id: Int) {
26        viewModelScope.launch {
27            repository.toggleFavorita(id)
28            // No es necesario actualizar el uiState manualmente:
29            // el Flow de Room detecta el cambio y emite la nueva lista automáticamente
30        }
31    }
32
33    companion object {
34        val Factory: ViewModelProvider.Factory = viewModelFactory {
35            initializer {
36                val app = checkNotNull(
37                    this[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY]
38                ) as AppFlixApplication
39                PeliculasViewModel(app.container.peliculasRepository)
40            }
41        }
42    }
43}

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// AppContainer.kt
 2interface AppContainer {
 3    val peliculasRepository: PeliculasRepository
 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: AppDatabase by lazy {
11        Room.databaseBuilder(
12            context.applicationContext,
13            AppDatabase::class.java,
14            "appflix_database"
15        )
16        .fallbackToDestructiveMigration(dropAllTables = true)
17        .build()
18    }
19
20    private val localDataSource: LocalDataSource by lazy {
21        LocalDataSource(database.peliculaDao())
22    }
23
24    override val peliculasRepository: PeliculasRepository by lazy {
25        PeliculasRepository(localDataSource)
26    }
27}
28
29// AppFlixApplication.kt
30class AppFlixApplication : Application() {
31    lateinit var container: AppContainer
32
33    override fun onCreate() {
34        super.onCreate()
35        container = DefaultAppContainer(this)
36    }
37}

Registrar la clase Application personalizada en AndroidManifest.xml:

1<application
2    android:name=".AppFlixApplication"
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// androidTest/java/.../PeliculaDaoTest.kt
 2@RunWith(AndroidJUnit4::class)
 3class PeliculaDaoTest {
 4
 5    private lateinit var database: AppDatabase
 6    private lateinit var dao: PeliculaDao
 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.peliculaDao()
18    }
19
20    @After
21    fun cerrarBaseDeDatos() {
22        database.close()
23    }
24
25    @Test
26    fun insertarYRecuperarPelicula() = runTest {
27        val pelicula = Pelicula(
28            id = 550,
29            titulo = "Fight Club",
30            genero = "Drama",
31            puntuacion = 8.4,
32            sinopsis = "Un insomne y un vendedor ambulante de jabón..."
33        )
34        dao.upsert(pelicula)
35
36        val resultado = dao.obtenerPorId(550)
37        assertEquals("Fight Club", resultado?.titulo)
38        assertFalse(resultado!!.esFavorita)   // por defecto es false
39    }
40
41    @Test
42    fun toggleFavoritaInvierteElEstado() = runTest {
43        val pelicula = Pelicula(id = 1, titulo = "Test", genero = "Drama",
44            puntuacion = 7.0, sinopsis = "Sinopsis de prueba")
45        dao.upsert(pelicula)
46
47        // Primera llamada: false → true
48        dao.toggleFavorita(1)
49        assertTrue(dao.obtenerPorId(1)!!.esFavorita)
50
51        // Segunda llamada: true → false
52        dao.toggleFavorita(1)
53        assertFalse(dao.obtenerPorId(1)!!.esFavorita)
54    }
55
56    @Test
57    fun upsertConservandoFavoritaNoSobreescribeFavorita() = runTest {
58        // 1. Insertar película con esFavorita = true
59        val peliculaOriginal = Pelicula(id = 1, titulo = "Dune", genero = "Sci-Fi",
60            puntuacion = 8.0, sinopsis = "Original", esFavorita = true)
61        dao.upsert(peliculaOriginal)
62
63        // 2. Llamar a upsertConservandoFavorita con el mismo id pero esFavorita = false
64        //    (como llegaría de la API)
65        val peliculaDeApi = peliculaOriginal.copy(
66            titulo = "Dune: Parte 2",
67            puntuacion = 8.5,
68            esFavorita = false   // la API no sabe que el usuario la marcó como favorita
69        )
70        dao.upsertConservandoFavorita(listOf(peliculaDeApi))
71
72        // 3. Verificar que el título se actualizó pero esFavorita sigue siendo true
73        val resultado = dao.obtenerPorId(1)
74        assertEquals("Dune: Parte 2", resultado?.titulo)
75        assertEquals(8.5, resultado?.puntuacion)
76        assertTrue(resultado!!.esFavorita)   // ✅ se conservó el favorito del usuario
77    }
78
79    @Test
80    fun observarFavoritasEmiteSoloLasMarcadas() = runTest {
81        // Insertar varias películas, algunas favoritas
82        dao.upsert(Pelicula(id = 1, titulo = "A", genero = "x", puntuacion = 7.0,
83            sinopsis = "", esFavorita = true))
84        dao.upsert(Pelicula(id = 2, titulo = "B", genero = "x", puntuacion = 6.0,
85            sinopsis = "", esFavorita = false))
86        dao.upsert(Pelicula(id = 3, titulo = "C", genero = "x", puntuacion = 8.0,
87            sinopsis = "", esFavorita = true))
88
89        // first() recoge el primer valor emitido por el Flow y cancela la colección
90        val favoritas = dao.observarFavoritas().first()
91        assertEquals(2, favoritas.size)
92        assertTrue(favoritas.all { it.esFavorita })
93    }
94}

Estructura de paquetes al final de T4#

com.ejemplo.appflix/
├── AppFlixApplication.kt
├── MainActivity.kt
├── data/
│   ├── model/
│   │   └── Pelicula.kt              ← @Entity (se extenderá en T5 con @SerializedName)
│   ├── datasource/
│   │   └── local/
│   │       ├── AppDatabase.kt       ← @Database
│   │       ├── PeliculaDao.kt       ← @Dao
│   │       ├── LocalDataSource.kt   ← wrapper del DAO
│   │       └── Converters.kt        ← @TypeConverter
│   ├── repository/
│   │   └── PeliculasRepository.kt   ← solo LocalDataSource (T5 añadirá Remote)
│   └── di/
│       └── AppContainer.kt          ← DefaultAppContainer + AppContainer interface
└── ui/
    ├── listado/
    │   ├── PantallaListado.kt
    │   ├── PeliculasViewModel.kt    ← usa stateIn() con SharingStarted.WhileSubscribed
    │   └── PeliculasUiState.kt
    ├── detalle/
    │   ├── PantallaDetalle.kt
    │   └── DetalleViewModel.kt
    ├── favoritos/
    │   ├── PantallaFavoritos.kt
    │   └── FavoritosViewModel.kt
    └── navegacion/
        ├── AppNavigation.kt
        └── Rutas.kt

Actividades prácticas#

Actividad 4.1 — AppFlix con persistencia local (7 h) Integrar Room en el proyecto AppFlix: definir la entidad Pelicula, el DAO con las operaciones necesarias, la AppDatabase, el LocalDataSource, actualizar el AppContainer y el Repository. La app debe persistir los datos entre reinicios y permitir marcar/desmarcar favoritos que sobrevivan al cierre de la aplicación.

Actividad 4.2 — Tests del DAO (3 h) Escribir los cuatro tests de la sección 9 y añadir uno propio: verificar que observarPorTitulo("Dune") devuelve solo las películas cuyo título contiene “Dune”, independientemente de mayúsculas.

Actividad 4.3 — Hito vertebrador H4 (2 h) Demostración en clase: reiniciar la aplicación y verificar que los favoritos se conservan. Explicar oralmente qué ocurriría si se usase @Upsert directamente en lugar de upsertConservandoFavorita.


Pruebas de evaluación#

Prueba T4.1 — Diseño del esquema (20 min) Dado el enunciado “App de recetas de cocina: cada receta tiene nombre, dificultad, tiempo y una lista de ingredientes; el usuario puede guardar recetas como favoritas”, el alumno diseña en papel las tablas necesarias con Room (@Entity, @PrimaryKey, relaciones si las hay) y el @Dao con las cinco operaciones básicas.

Prueba T4.2 — Depuración de código Room (20 min) Se entrega un @Dao con cuatro errores: una consulta de observación definida como suspend fun en lugar de Flow, un @Insert con REPLACE donde se necesita conservar esFavorita, un @Transaction ausente en una consulta con @Relation, y un TypeConverter con los métodos de conversión invertidos. El alumno identifica, explica y corrige.

Prueba T4.3 — Defensa oral (5 min) El profesor pregunta: "¿Por qué observarTodas() devuelve Flow<List<Pelicula>> sin suspend, pero toggleFavorita() sí es suspend?", "¿Qué pasaría si crearas dos instancias de AppDatabase en lugar de un singleton?", "¿Cuándo usarías @Insert(REPLACE) y cuándo upsertConservandoFavorita?"


Referencias#

Calendar  Última modificación: viernes, 26 de junio de 2026