Tema 6: Persistencia con ROOM
Objetivos de este tema
- Entender el propósito de
ROOM
como solución de persistencia local en Android. - Crear entidades , DAOs y una base de datos usando
ROOM
. - Integrar
ROOM
con el patrón MVVM y el componenteViewModel
. - Realizar consultas básicas y avanzadas en
ROOM
. - Gestionar migraciones simples al evolucionar la base de datos.
6.1. Introducción a ROOM
ROOM
es una biblioteca de persistencia oficial de Android (parte de Jetpack) que facilita el acceso a la base de datos SQLite
. Proporciona una capa de abstracción sobre SQLite
, permitiendo escribir consultas de forma más segura y con menos código que usando SQLiteDatabase
directamente.
Ventajas de Room:
- Tipado seguro: Detecta errores en tiempo de compilación.
- Integración con
LiveData
y Coroutines: Perfecto para aplicar el patrón MVVM. - Sin boilerplate: No se necesita escribir manualmente
ContentValues
,Cursor
, etc y otro código innecesario o redundante. - Migraciones controladas: Facilita el cambio de versiones de la base de datos.
Información
ROOM
no se ejecuta en el hilo principal de la aplicación (UI). Siempre deberán utilizarse Coroutines, LiveData
y/o Flow
para evitar ANR (Application Not Responding).
6.2. Configuración de ROOM en el proyecto
En primer lugar, se añadirá el complemento KSP
en el archivo build.gradle.kts (Project:)
, alineando la versión de KSP con la versión de Kotlin del proyecto. Puedes encontrar una lista de las actualizaciones en la página de GitHub de KSP.
1plugins {
2 ...
3 id("com.google.devtools.ksp") version "2.2.0-2.0.2" apply false
4}
A continuación, habilita KSP
en el archivo build.gradle.kts (Module :app)
a nivel del módulo:
1plugins {
2 ...
3 id("com.google.devtools.ksp")
4}
Para terminar, seguiendo con el archivo build.gradle.kts (Module :app)
, se añadirán las siguientes dependencias para poder hacer uso de ROOM
.
1// ROOM dependencies
2implementation("androidx.room:room-runtime:2.7.2")
3implementation("androidx.room:room-ktx:2.7.2") // Soporte para Coroutines y Kotlin Extensions.
4ksp("androidx.room:room-compiler:2.7.2") // KSP para procesamiento de anotaciones.
5
6// ViewModel y LiveData
7implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.9.2")
8implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.9.2")
9implementation("androidx.compose.runtime:runtime-livedata:1.9.0")
10
11// Navigation Compose
12implementation("androidx.navigation:navigation-compose:2.9.3")
Recuerda sincronicar el Gradle con cada paso.
6.3. Modelo
Para simplificar, se omite el dominio y directamente se creará un package model para alojar las data classes que representarán las tablas.
1import androidx.room.Entity
2import androidx.room.PrimaryKey
3
4// Editorial.kt
5@Entity(tableName = "editorial")
6data class Editorial(
7 @PrimaryKey(autoGenerate = true) val idEd: Int = 0,
8 val name: String
9)
tableName
te permite cambiar el nombre de la tabla si no quieres que coincida con el nombre de la data class.autoGenerate = true
genera IDs automáticamente.
1import androidx.room.Entity
2import androidx.room.PrimaryKey
3
4// SuperHero.kt
5@Entity(tableName = "superhero")
6data class SuperHero(
7 @PrimaryKey(autoGenerate = true)
8 var idSuper: Int = 0,
9 var superName: String,
10 var realName: String,
11 var favorite: Boolean = false,
12 var idEditorial: Int = 0
13)
La siguiente clase representa la relación entre SuperHero
y Editorial
con cardinalidad 1:1. Observa que las clases que representan una relación no llevan la etiqueta @Entity
.
1import androidx.room.Embedded
2import androidx.room.Relation
3
4// SuperWithEditorial.kt
5data class SuperWithEditorial(
6 @Embedded val supers: SuperHero,
7 @Relation(
8 parentColumn = "idEditorial",
9 entityColumn = "idEd"
10 )
11 val editorial: Editorial
12)
@Embedded
se utiliza para incluir los campos de la entidad SuperHero en el objeto SuperWithEditorial.@Relation
se utiliza para definir la relación entre SuperHero y Editorial, especificando las columnas que se utilizan para enlazarlas.
6.4. Creación del DAO
El DAO(Data Access Object) define las operaciones que pueden realizarse sobre la base de datos (CRUD). Siguiendo con la ordenación según la Clean Architecture se creará el package data, dentro se creará la interface para el DAO.
1import androidx.lifecycle.LiveData
2import androidx.room.Dao
3import androidx.room.Delete
4import androidx.room.Insert
5import androidx.room.OnConflictStrategy
6import androidx.room.Query
7import androidx.room.Transaction
8import kotlinx.coroutines.flow.Flow
9
10// SupersDAO.kt
11@Dao
12interface SupersDAO {
13 // Versión de consultas que devuelven un FLOW.
14 @Transaction // Permite obtener datos de varias tablas relacionadas con una sola consulta.
15 @Query("SELECT * FROM SuperHero ORDER BY superName")
16 fun getSuperHerosWithEditorials(): Flow<List<SuperWithEditorial>>
17
18 @Query("SELECT * FROM Editorial")
19 fun getAllEditorials(): Flow<List<Editorial>>
20
21 // Versión de consultas que devuelven un LIVEDATA (...LD).
22 @Transaction
23 @Query("SELECT * FROM SuperHero ORDER BY superName")
24 fun getSuperHerosWithEditorialsLD(): LiveData<List<SuperWithEditorial>>
25
26 @Query("SELECT * FROM Editorial")
27 fun getAllEditorialsLD(): LiveData<List<Editorial>>
28
29 // Resto de consultas.
30
31 @Query("SELECT * FROM SuperHero WHERE idSuper = :idSuper")
32 suspend fun getSuperById(idSuper: Int): SuperHero?
33
34 @Query("SELECT * FROM Editorial WHERE idEd = :editorialId")
35 suspend fun getEditorialById(editorialId: Int): Editorial?
36
37 @Insert(onConflict = OnConflictStrategy.REPLACE)
38 suspend fun insertEditorial(editorial: Editorial)
39
40 @Insert(onConflict = OnConflictStrategy.REPLACE)
41 suspend fun insertSuperHero(superHero: SuperHero)
42
43 @Delete
44 suspend fun deleteSuperHero(superHero: SuperHero): Int
45}
@Transaction
permite obtener datos de varias tablas relacionadas con una sola consulta.@Query
permite realizar consultas SQL directamente sobre la base de datos.@Insert
permite insertar un nuevo registro en la base de datos. Devuelve comoLong
el ID del registro insertado, si se ha insertado correctamente.- El uso de
onConflictStrategy.REPLACE
permite reemplazar un registro existente si hay un conflicto de clave primaria. Si se intenta insertar unSuperHero
oEditorial
con un id que ya existe, se actualizará el registro existente, puede utilizarse para ahorrarse un método para actualizar (@Update
).
- El uso de
@Delete
permite eliminar un registro de la base de datos, devolvilendo comoInt
el número de filas afectadas.
Observa que se han duplicado dos métodos, esto se hace a modo didáctico para ilustrar el uso de Flow
y LiveData
.
6.5. Definición de la base de datos
Se creará a continuación una clase abstracta para definir la base de datos y conecta las entidades con los DAOs.
1import android.content.Context
2import androidx.room.Database
3import androidx.room.Room
4import androidx.room.RoomDatabase
5
6// AppDatabase.kt
7@Database(
8 entities = [SuperHero::class, Editorial::class],
9 version = 1,
10 exportSchema = true // Importante para migraciones
11)
12abstract class AppDatabase : RoomDatabase() {
13 abstract fun supersDAO(): SupersDAO // Conexión con DAO de SuperHéroes.
14
15 companion object {
16 @Volatile
17 private var INSTANCE: AppDatabase? = null
18
19 fun getInstance(context: Context): AppDatabase {
20 return INSTANCE ?: synchronized(this) {
21 val instance = Room.databaseBuilder(
22 context.applicationContext,
23 AppDatabase::class.java,
24 "SuperHeros.db"
25 ).fallbackToDestructiveMigration(true) // Solo en desarrollo.
26 .build()
27
28 INSTANCE = instance // Asigna la instancia a la variable volátil.
29 instance // Devuelve la instancia de la base de datos.
30 }
31 }
32 }
33}
@Volatile
asegura visibilidad del hilo.synchronized
evita creación múltiple de la instancia de la base de datos.exportSchema = true
genera un JSON con el esquema (necesario para migraciones).fallbackToDestructiveMigration(boolen)
se utiliza durante la configuración de la base de datos permitiendo controlar cómo se manejarán las migraciones cuando no se ha definido una estrategia.false
desactiva la migración destructiva, si no hay una migración definida entre dos versiones del esquema,ROOM
lanzará una excepción (IllegalStateException
) en lugar de borrar y recrear la base de datos. Por defecto.true
facilita los cambios rápidos del esquema sin tener que escribir migraciones cada vez, borra y crea la base de datos. Solo en desarrollo.
6.6. Uso de ROOM con ViewModel
Para respetar el patrón MVVM, nunca se accederá a Room desde la UI. Se utilizará un ViewModel
para iniciar la interacción Repository <-> Datasource <-> Framework. Al aplicar la capa intermedia (Repository - Datasource), inicialmente puede verse como una repetición o redundancia de código, pero tiene una lógica, y es separar la lógica de acceso al Framework del Repositoy, separando y facilitando así el acceso a distintas fuentes de datos.
Información
UI (Compose) <-> ViewModel <-> Repository <-> Datasource <-> [API (Retrofit) o DB (ROOM)]
Comenzando por el Datasource se creará en el package data la siguiente clase:
1import androidx.lifecycle.LiveData
2import kotlinx.coroutines.flow.Flow
3
4// LocalDatasource.kt
5class LocalDatasource(private val dao: SupersDAO) {
6 // Version FLOW.
7 val currentSupers: Flow<List<SuperWithEditorial>> = dao.getSuperHerosWithEditorials()
8 val currentEditorials: Flow<List<Editorial>> = dao.getAllEditorials()
9
10 // Version LIVEDATA.
11 val currentSupersLD: LiveData<List<SuperWithEditorial>> = dao.getSuperHerosWithEditorialsLD()
12 val currentEditorialsLD: LiveData<List<Editorial>> = dao.getAllEditorialsLD()
13
14 suspend fun deleteSuper(superHero: SuperHero): Int { // Returns the number of rows deleted.
15 return dao.deleteSuperHero(superHero)
16 }
17
18 suspend fun saveSuper(superHero: SuperHero) {
19 dao.insertSuperHero(superHero)
20 }
21
22 suspend fun getSuperById(superId: Int): SuperHero? = dao.getSuperById(superId)
23
24 suspend fun saveEditorial(editorial: Editorial) {
25 dao.insertEditorial(editorial)
26 }
27
28 suspend fun getEdById(editorialId: Int): Editorial? = dao.getEditorialById(editorialId)
29}
Se opta por el nombre LocalDatasource.kt
porque es la clase que da acceso al almacenamiento local. Ahora se creará Repository.kt
, que será en este caso muy similar, pero ya se verá su utilidad real.
1import androidx.lifecycle.LiveData
2import kotlinx.coroutines.flow.Flow
3
4// Reposity.kt
5class Repository(private val localDatasource: LocalDatasource) {
6 // Versión FLOW.
7 val currentSupers: Flow<List<SuperWithEditorial>> = localDatasource.currentSupers
8 val currentEditorials: Flow<List<Editorial>> = localDatasource.currentEditorials
9
10 // Versión LIVEDATA.
11 val currentSupersLD: LiveData<List<SuperWithEditorial>> = localDatasource.currentSupersLD
12 val currentEditorialsLD: LiveData<List<Editorial>> = localDatasource.currentEditorialsLD
13
14 suspend fun deleteSuper(superHero: SuperHero): Int {
15 return localDatasource.deleteSuper(superHero)
16 }
17
18 suspend fun saveSuper(superHero: SuperHero) {
19 localDatasource.saveSuper(superHero)
20 }
21
22 suspend fun getSuperById(superId: Int): SuperHero? = localDatasource.getSuperById(superId)
23
24 suspend fun saveEditorial(editorial: Editorial) {
25 localDatasource.saveEditorial(editorial)
26 }
27
28 suspend fun getEdById(editorialId: Int): Editorial? = localDatasource.getEdById(editorialId)
29}
A continuación, se creará el ViewModel compartido entre pantallas dónde se establecerá la conexión a la base de datos y la interacción con el repositorio. La clase SupersViewModel.kt
estará a la misma altural que la clase MainActivity.kt
en la estructura de árbol del proyecto.
1import android.app.Application
2import androidx.lifecycle.AndroidViewModel
3import androidx.lifecycle.LiveData
4import androidx.lifecycle.viewModelScope
5import kotlinx.coroutines.Deferred
6import kotlinx.coroutines.async
7import kotlinx.coroutines.flow.MutableStateFlow
8import kotlinx.coroutines.flow.StateFlow
9import kotlinx.coroutines.flow.catch
10import kotlinx.coroutines.launch
11
12// SupersViewModel.kt
13class SupersViewModel(application: Application) : AndroidViewModel(application) {
14 // Se inicializa el repositorio y el datasource.
15 private val repository: Repository
16 private val localDatasource: LocalDatasource
17
18 // Se exponen los StateFlow para que la UI observe los cambios.
19 private val _currentSupers = MutableStateFlow<List<SuperWithEditorial>>(emptyList())
20 val currentSupers: StateFlow<List<SuperWithEditorial>> = _currentSupers
21
22 private val _currentEditorials = MutableStateFlow<List<Editorial>>(emptyList())
23 val currentEditorials: StateFlow<List<Editorial>> = _currentEditorials
24
25 // Se exponen los LiveData según sea necesario.
26 val currentSupersLD: LiveData<List<SuperWithEditorial>>
27 val currentEditorialLD: LiveData<List<Editorial>>
28
29 init {
30 // Inicialización del repositorio y el datasource.
31 val database = AppDatabase.getInstance(application)
32 val dao = database.supersDAO()
33 localDatasource = LocalDatasource(dao)
34 repository = Repository(localDatasource)
35
36 // Carga inicial de superhéroes y editoriales, versión Flow.
37 loadSupers()
38 loadEditorials()
39
40 // Inicialización del LiveData para los superhéroes.
41 currentSupersLD = repository.currentSupersLD
42 currentEditorialLD = repository.currentEditorialsLD
43 }
44
45 // Se observan los StateFlow para que la UI pueda reaccionar a los cambios con Flow una vez
46 // que se hayan cargado los datos iniciales.
47 fun loadEditorials() {
48 viewModelScope.launch {
49 repository.currentEditorials
50 .catch { e -> e.printStackTrace() } // Manejo de errores.
51 .collect { editorials ->
52 _currentEditorials.value = editorials // Actualiza el StateFlow con las editoriales.
53 }
54 }
55 }
56
57 fun loadSupers() {
58 viewModelScope.launch {
59 repository.currentSupers
60 .catch { e -> e.printStackTrace() } // Manejo de errores.
61 .collect { supers ->
62 _currentSupers.value = supers // Actualiza el StateFlow con los superhéroes.
63 }
64 }
65 }
66
67 fun saveEditorial(editorial: Editorial) {
68 viewModelScope.launch {
69 repository.saveEditorial(editorial)
70 }
71 }
72
73 fun saveSuper(superHero: SuperHero) {
74 viewModelScope.launch {
75 repository.saveSuper(superHero)
76 }
77 }
78
79 suspend fun delSuper(superHero: SuperHero) : Int{
80 return deleteSuper(superHero).await()
81 }
82
83 // Esta función devuelve un Deferred para que se pueda esperar su resultado de forma asíncrona.
84 private fun deleteSuper(superHero: SuperHero): Deferred<Int> {
85 return viewModelScope.async {
86 repository.deleteSuper(superHero)
87 }
88 }
89
90 fun getSuperById(superId: Int): Deferred<SuperHero?> {
91 return viewModelScope.async { repository.getSuperById(superId) }
92 }
93
94 fun getEdById(editorialId: Int): Deferred<Editorial?> {
95 return viewModelScope.async { repository.getEdById(editorialId) }
96 }
97}
Este ViewModel
muestra dos formas de recuperar la información de la BD, una mediante MutableStateFlow
para la versión con Flows y otra utilizando LiveData
, aquí por motivos didácticos se tienen las dos a la vez, no es lo habitual, siempre se elegirá una única forma de trabajar, se recomienda el uso de Flows para Jetpack Compose.
6.6.1. Comsumir los datos desde la UI
Versión para Flow
En la versión para Flows, puede obtenerse el flugo de datos de la siguiente manera:
1@Composable
2fun MainScreen(navController: NavController, viewModel: SupersViewModel) {
3 val snackbarHostState = remember { SnackbarHostState() }
4 val scope = rememberCoroutineScope()
5
6 // Se recolecta el StateFlow del ViewModel para observar el flujo de datos
7 // de los superhéroes y las editoriales. Se puede usar collectAsState() o
8 // collectAsStateWithLifecycle() para obtener el estado actual.
9 val currentSupers by viewModel.currentSupers.collectAsStateWithLifecycle()
10 val currentEditorials by viewModel.currentEditorials.collectAsStateWithLifecycle()
11
12 ...
13}
Cuando se observa StateFlow
o Flow
se utilizan los método collectAsState()
o collectAsStateWithLifecycle()
, aquí tienes una comparativa entre ambos.
Característica | collectAsState() |
collectAsStateWithLifecycle() |
---|---|---|
Ciclo de vida | Siempre ecolecta, incluso estando en segundo plano | Solo recolectará cuando el estado de vida sea STARTED (pantalla visible) |
Consumo de recursos | Puede consumir batería innecesariamente | Más eficiente, pausa la recolección en segundo plano |
Uso recomendado | En apps simples o prototipos | Recomendado para producción |
Dependencia extra | No necesita dependencias adicionales | Necesita la librería androidx.lifecycle:lifecycle-viewmodel-compose |
collectAsStateWithLifecycle()
es la opción recomendada para aplicaciones reales y proyectos con ViewModel + Compose, permitiendo así una gestión eficiente del ciclo de vida.
Nota
Desde Compose BOM 2023.10.01 y Lifecycle 2.6.2+, Google añadió una serie de mejoras importantes, ahora, collectAsState()
dentro de un @Composable
respeta el ciclo de vida si se usa junto con ViewModel
y StateFlow
/MutableStateFlow
.
Esto quiere decir que collectAsState()
pausa la recolección cuando la pantalla no está en primer plano, igual que hacía collectAsStateWithLifecycle()
.
Una vez se obtienen los datos, ya se puede trabajar con ellos, por ejemplo, comprobar la existencia previa de editoriales para permitir añadir superhéroes.
1LaunchedEffect(currentEditorials.isEmpty()) {
2 delay(1_000) // Se espera un segundo para dar tiempo a que se carguen los datos.
3 if (currentEditorials.isEmpty()) {
4
5 snackbarHostState.showSnackbar(
6 message = "No hay editoriales disponibles, debe existir al menos una para poder añadir superhéroes.",
7 duration = SnackbarDuration.Short
8 )
9 }
10}
En este ejemplo se utiliza LaunchedEffect(key)
, esta es una función de Compose que permite lanzar una Coroutine cuando un componente se muestra (o vuelve a componerse bajo ciertas condiciones).
Se usa para ejecutar tareas asíncronas desde la UI, como:
- Llamar a una función suspendida del
ViewModel
. - Mostrar un
Snackbar
. - Ejecutar una tarea o acción tras un evento (ej: después de guardar).
El parámetro key del método se utilizará de la siguiente manera:
- Si el objeto pasado como key cambia, la acción se vuelve a ejecutar.
- Si la clave es
Unit
, se ejecutará solo una vez al entrar en composición.
En el caso de currentSupers
, que es una lista, se utilizará como tal:
1items(currentSupers) { oneSuper ->
2 ...
3}
Versión para LiveData
En el caso de utilizar LiveData
los datos se observan.
1// Se recolecta el LiveData del ViewModel
2val currentSupersLD by viewModel.currentSupersLD.observeAsState()
3val currentEditorialsLD by viewModel.currentEditorialLD.observeAsState()
En este caso deberán realizar más comprobaciónes, controlando los posibles nulos.
1if (currentEditorialsLD != null) {
2 LaunchedEffect(currentEditorialsLD!!.isEmpty()) {
3 delay(1_000) // Se espera un segundo para dar tiempo a que se carguen los datos.
4 if (currentEditorialsLD!!.isEmpty()) {
5
6 snackbarHostState.showSnackbar(
7 message = "No hay editoriales disponibles, debe existir al menos una para poder añadir superhéroes.",
8 duration = SnackbarDuration.Short
9 )
10 }
11 }
12}
También para la lista de superhéroes.
1if (currentSupersLD != null) {
2 items(currentSupersLD!!, key = { it.supers.idSuper }) { oneSuper ->
3 ...
4 }
5}