Persistencia local con Room y flujos de datos reactivos en Android
SQLite, Room, Entity, DAO, Database, Flow, StateFlow, inyección manual de dependencias y migraciones
Tecnologías: Kotlin, Jetpack Compose, SQLite, Room, corrutinas, Flow y Android Lifecycle.
Introducción
Las aplicaciones móviles deben conservar información aunque la interfaz se recree o el proceso que las ejecuta termine. Un formulario puede necesitar recuperar su contenido después de una rotación; un inventario, una lista de tareas o un conjunto de registros deben permanecer disponibles al volver a abrir la aplicación. Son problemas relacionados, pero requieren mecanismos distintos.
Esta guía estudia la persistencia estructurada en el dispositivo mediante SQLite y Room. Explica cómo modelar tablas, ejecutar operaciones CRUD, observar cambios de la base con Flow y llevar esos datos hasta Jetpack Compose mediante StateFlow. También aborda la construcción de dependencias y la evolución segura del esquema.
Los ejemplos utilizan productos de un catálogo para ilustrar las técnicas. No es necesario haber desarrollado una aplicación de inventario para seguir las explicaciones.
Al finalizar, se espera que el estudiante pueda justificar qué información debe persistirse, diseñar entidades y DAO, interpretar consultas SQL, integrar Room mediante un repositorio, gestionar estados reactivos y reconocer el riesgo de migraciones destructivas.
Lo imprescindible para el examen
Distinguir Room, DataStore y estado de UI; interpretar Entity/DAO/Database; explicar Flow, stateIn y colección con ciclo de vida; reconocer transacciones y migraciones.
Se evalúa la capacidad de explicar, interpretar fragmentos y justificar decisiones. Las versiones puntuales de librerías, URLs, nombres de proyecto y comandos accesorios quedan como contexto de consulta.
Criterios transversales para interpretar esta guía
Principio transferible. Conviene distinguir la responsabilidad de una tecnología de la forma concreta en que se utilizó en la práctica. Un ejemplo de ViewModel, Retrofit, Hilt o WebSocket ilustra una solución posible; su configuración exacta no es una regla universal.
Decisión del proyecto. Los nombres, rutas, versiones, intervalos, tamaños y elecciones de librerías del caso original sirven para interpretar el código. Salvo indicación expresa, no se exige memorizarlos para el examen.
Alcance del código. Los fragmentos son recursos didácticos; pueden omitir imports, dependencias, permisos, detalles de manejo de errores o infraestructura. Comprender un fragmento no equivale a comprobar que compila como proyecto independiente.
Referencias cruzadas. La síntesis transversal de la colección reúne las distinciones entre estado y evento, persistencia, reintentos, idempotencia y límites de las pruebas.
1. Persistencia, estado y ciclo de vida
El estado de interfaz describe lo que una pantalla necesita mostrar en un momento determinado. La persistencia almacena información para recuperarla posteriormente, incluso después de que el proceso de la aplicación termine.
| Mecanismo | Uso principal | Rotación | Terminación del proceso |
|---|---|---|---|
remember |
Estado local durante la composición | No se conserva por sí solo | No |
rememberSaveable |
Estado pequeño que puede restaurarse | Habitualmente sí | Puede restaurarse si existe estado guardado |
ViewModel |
Estado y operaciones de una pantalla | Sí, ante cambios de configuración | No sobrevive por sí mismo |
SavedStateHandle |
Recuperación de datos pequeños del ViewModel | Sí | Puede recuperar estado guardado bajo las condiciones del sistema |
| Room / SQLite | Persistencia estructurada | Sí | Sí |
La persistencia en una base local no depende de mantener viva una Activity ni un ViewModel. Los registros permanecen en almacenamiento del dispositivo hasta que se eliminan, se limpia el almacenamiento de la aplicación, se desinstala (salvo mecanismos de copia o restauración) o se produce otra acción que los modifique.
rememberSaveable y SavedStateHandle no sustituyen una base de datos para colecciones de registros. Están orientados a valores relativamente pequeños que se puedan guardar y restaurar mediante los mecanismos de estado de Android.
1.1 Alternativas de almacenamiento
| Necesidad | Alternativa frecuente | Ejemplo |
|---|---|---|
| Preferencias pequeñas | DataStore | Tema oscuro, orden preferido |
| Datos relacionales consultables | SQLite mediante Room | Productos, pedidos, notas |
| Archivos binarios o documentos | Almacenamiento de archivos | Fotografías, PDFs |
| Información compartida entre dispositivos | Servicio remoto, con o sin caché local | Cuenta sincronizada |
Una base de datos local conserva datos en ese dispositivo. Por sí misma no los sincroniza con otros teléfonos ni realiza copias de seguridad externas.
2. Fundamentos de SQLite
SQLite es un motor de base de datos relacional integrado en Android. Sus datos se guardan normalmente en archivos administrados por la aplicación. No hace falta instalar un servidor independiente.
Una tabla está organizada en columnas y filas. Las columnas representan atributos definidos en un esquema, y las filas representan registros.
productos
┌────┬──────────────────┬──────────┬──────────┐
│ id │ nombre │ precio │ cantidad │
├────┼──────────────────┼──────────┼──────────┤
│ 1 │ Libreta │ 59.00 │ 12 │
│ 2 │ Lápiz │ 10.50 │ 30 │
│ 3 │ Carpeta │ 45.00 │ 0 │
└────┴──────────────────┴──────────┴──────────┘
2.1 Identidad y tipos
La clave primaria identifica cada fila. Puede asignarse automáticamente o provenir de otra estrategia, según el diseño. No debe suponerse que el orden de inserción sea el orden de consulta.
SQLite utiliza clases de almacenamiento como INTEGER, REAL, TEXT, BLOB y NULL. La conversión desde tipos Kotlin debe ser compatible con las columnas o disponer de convertidores apropiados.
Importante sobre cantidades monetarias: el ejemplo usa Double para mostrar los conceptos básicos. En una aplicación real, almacenar dinero como una cantidad entera de unidades mínimas, o adoptar una representación decimal definida, evita problemas frecuentes de precisión de punto flotante.
2.2 Operaciones CRUD en SQL
-- Leer registros en un orden explícito
SELECT * FROM productos ORDER BY nombre ASC;
-- Crear un registro
INSERT INTO productos (nombre, precio, cantidad)
VALUES ('Cuaderno', 85.0, 4);
-- Modificar un registro específico
UPDATE productos SET cantidad = 3 WHERE id = 1;
-- Eliminar un registro específico
DELETE FROM productos WHERE id = 1;
CRUD significa crear, leer, actualizar y eliminar. En SQL se relaciona con INSERT, SELECT, UPDATE y DELETE. Omitir WHERE en un UPDATE o DELETE puede afectar todas las filas; no es siempre incorrecto, pero debe ser intencional.
Una consulta sin ORDER BY no garantiza un orden determinado. El orden debe declararse cuando sea relevante para la experiencia de usuario.
2.3 Parámetros y seguridad
Una consulta debe recibir los valores externos mediante parámetros enlazados. Concatenar entradas del usuario directamente en SQL puede producir errores e inyección SQL.
SELECT * FROM productos WHERE id = :id;
En Room, :id corresponde al parámetro id del método DAO. Este mecanismo evita construir el SQL mediante interpolación de cadenas para ese valor.
3. Room: una capa sobre SQLite
Room permite describir entidades y operaciones mediante clases, interfaces y anotaciones Kotlin. Genera la implementación de acceso a datos y valida consultas estáticas durante la compilación.
Sus tres componentes principales son:
| Componente | Responsabilidad |
|---|---|
Entity |
Representar el esquema de una tabla o registro persistido |
DAO (Data Access Object) |
Declarar consultas y operaciones de acceso |
RoomDatabase |
Configurar la base y proporcionar los DAO |
ViewModel
│
▼
Repositorio ── modelos de dominio
│
▼
DAO (Room) ───► SQLite
▲
│
RoomDatabase
Room reduce código repetitivo, pero no elimina la necesidad de comprender SQL, modelado, errores ni migraciones.
3.1 Entity: representar una tabla
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "productos")
data class ProductoEntity(
@PrimaryKey(autoGenerate = true)
val id: Int = 0,
val nombre: String,
val precio: Double,
val cantidad: Int
)
@Entity declara la entidad. tableName define explícitamente el nombre de la tabla y @PrimaryKey identifica su clave primaria. En este ejemplo, autoGenerate = true permite que Room y SQLite asignen la clave al insertar. Para claves primarias enteras no asignadas de este tipo, el valor 0 puede actuar como señal de generación automática.
La clase describe cómo se almacena la información. No necesita representar todas las propiedades derivadas de un modelo de dominio.
3.2 Datos almacenados y datos derivados
data class Producto(
val id: Int,
val nombre: String,
val precio: Double,
val cantidad: Int
) {
val agotado: Boolean get() = cantidad == 0
val valorEnInventario: Double get() = precio * cantidad
}
agotado y valorEnInventario se calculan a partir de otros campos. En este ejemplo, no es necesario guardarlos en columnas adicionales porque pueden derivarse cuando se necesitan.
Esto evita posibles inconsistencias: si cambia cantidad y no se actualiza una columna separada agotado, la base almacenaría datos contradictorios. No es una prohibición absoluta de guardar valores derivados: algunos sistemas los materializan por rendimiento, con reglas explícitas para mantenerlos sincronizados.
4. DAO: declarar consultas y escrituras
import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.Query
import androidx.room.Update
import kotlinx.coroutines.flow.Flow
@Dao
interface ProductoDao {
@Query("SELECT * FROM productos ORDER BY nombre COLLATE NOCASE ASC")
fun observarTodos(): Flow<List<ProductoEntity>>
@Query("SELECT * FROM productos WHERE id = :id")
fun observarPorId(id: Int): Flow<ProductoEntity?>
@Query("SELECT * FROM productos WHERE id = :id")
suspend fun obtenerPorId(id: Int): ProductoEntity?
@Insert
suspend fun insertar(producto: ProductoEntity): Long
@Update
suspend fun actualizar(producto: ProductoEntity): Int
@Delete
suspend fun borrar(producto: ProductoEntity): Int
}
El DAO es una interfaz. Room genera una implementación que ejecuta las operaciones correspondientes. Los métodos de inserción, actualización y eliminación pueden devolver información útil: en este ejemplo, identificador insertado o cantidad de filas afectadas.
4.1 Consultas puntuales y observables
Hay dos estilos válidos de consulta, con propósitos diferentes:
- Consulta puntual:
suspend fun obtenerPorId(...)realiza una lectura y entrega su resultado. - Consulta observable:
fun observarPorId(...): Flow<...>entrega un flujo al que el consumidor puede suscribirse y que vuelve a emitir resultados cuando se invalidan las tablas observadas.
No debe memorizarse que «leer siempre devuelve Flow» ni que «leer nunca es suspend». Room admite ambos enfoques.
El Flow que entrega Room normalmente es frío: declarar el flujo no ejecuta por sí solo una consulta sostenida; la recolección activa la observación. Una emisión no implica necesariamente que el contenido haya cambiado de forma relevante: puede ser consecuencia de la invalidación de la tabla.
4.2 Validación del SQL
Room valida las consultas SQL estáticas que declara el DAO durante la compilación. Si se escribe ORDER BY nombres y la columna correcta es nombre, el procesador puede detectar el error antes de ejecutar la aplicación.
Esta validación es una ventaja importante frente al SQL ensamblado manualmente, pero no sustituye pruebas del comportamiento de la aplicación ni garantiza que todas las operaciones tengan la semántica deseada.
4.3 KSP
KSP (Kotlin Symbol Processing) permite que herramientas como el compilador de Room analicen el código y generen implementaciones durante la compilación.
En una configuración Gradle que utilice KSP, room-compiler se declara con ksp(...) y no como una dependencia ordinaria de ejecución.
// Fragmento orientativo de build.gradle.kts
plugins {
alias(libs.plugins.ksp)
}
dependencies {
implementation(libs.androidx.room.runtime)
implementation(libs.androidx.room.ktx)
ksp(libs.androidx.room.compiler)
}
Los alias y artefactos exactos dependen de la configuración del proyecto y de la versión utilizada. Se deben comprobar las versiones compatibles y mantener alineados los componentes de Room. Este fragmento ilustra responsabilidades, no pretende ser un archivo Gradle completo ni una combinación universal de versiones.
5. RoomDatabase y ciclo de vida de la conexión
@Database(
entities = [ProductoEntity::class],
version = 1,
exportSchema = true
)
abstract class CatalogoDatabase : RoomDatabase() {
abstract fun productoDao(): ProductoDao
}
La clase RoomDatabase identifica las entidades incluidas, expone sus DAO y declara una versión del esquema. Room genera la implementación correspondiente.
5.1 Construcción de una instancia
val database = Room.databaseBuilder(
context.applicationContext,
CatalogoDatabase::class.java,
"catalogo.db"
).build()
En aplicaciones habituales se comparte una instancia de base por archivo durante la vida del proceso, en lugar de crearla por pantalla. Se puede gestionar mediante un contenedor o un mecanismo de inyección de dependencias.
applicationContext permite evitar que una dependencia de larga duración retenga inadvertidamente una Activity destruida. La construcción de la base y el acceso a disco deben administrarse de acuerdo con el ciclo de vida y las restricciones de ejecución.
5.2 ¿Por qué una base local puede persistir?
Los registros se guardan en el almacenamiento de la app, no en la memoria exclusiva de una pantalla. Por ello, pueden recuperarse al reiniciar el proceso o el dispositivo. Este comportamiento no significa que la información sea inmune a una desinstalación, borrado de datos o fallo de almacenamiento.
6. Separación entre Entity y dominio
El modelo de almacenamiento suele responder a las necesidades de SQLite y Room, mientras que el modelo de dominio expresa los conceptos de la aplicación.
fun ProductoEntity.toDomain(): Producto = Producto(
id = id,
nombre = nombre,
precio = precio,
cantidad = cantidad
)
fun Producto.toEntity(): ProductoEntity = ProductoEntity(
id = id,
nombre = nombre,
precio = precio,
cantidad = cantidad
)
Estas funciones son mapeadores. Permiten modificar un formato de persistencia sin propagar sus anotaciones a las reglas de dominio.
La separación añade código y no es obligatoria en prototipos pequeños, pero favorece la independencia de la arquitectura cuando cambian fuentes, esquemas o contratos de datos. Un cambio de almacenamiento puede requerir adaptar repositorios o consumidores; la separación reduce el alcance de esos cambios, aunque no garantiza que siempre se modifique una sola clase.
7. Repositorio: encapsular el acceso a datos
class ProductoRepository(private val dao: ProductoDao) {
fun observarTodos(): Flow<List<Producto>> =
dao.observarTodos().map { entidades ->
entidades.map { it.toDomain() }
}
fun observarPorId(id: Int): Flow<Producto?> =
dao.observarPorId(id).map { it?.toDomain() }
suspend fun obtenerPorId(id: Int): Producto? =
dao.obtenerPorId(id)?.toDomain()
suspend fun agregar(producto: Producto): Long =
dao.insertar(producto.toEntity())
suspend fun actualizar(producto: Producto): Int =
dao.actualizar(producto.toEntity())
suspend fun borrar(producto: Producto): Int =
dao.borrar(producto.toEntity())
}
Imports conceptuales: Flow y map pertenecen a kotlinx.coroutines.flow; los mapeadores se importan desde el paquete en que estén declarados.
En observarTodos() aparecen dos funciones llamadas map. La exterior transforma cada emisión del Flow; la interior transforma cada elemento de la lista incluida en esa emisión.
La UI y el ViewModel reciben Producto, sin necesidad de conocer ProductoEntity ni las anotaciones de Room.
8. Flujo reactivo: de SQLite hasta Compose
El objetivo de una consulta observable es evitar que cada pantalla tenga que recordar cuándo llamar a recargar() después de agregar, editar o eliminar un registro.
INSERT / UPDATE / DELETE
│
▼
Tabla SQLite
│ invalidación observada
▼
Consulta Room
│
▼
Flow<List<Entity>>
│ map()
▼
Flow<List<Producto>>
│ stateIn()
▼
StateFlow
│ collectAsStateWithLifecycle()
▼
Compose
Cuando una tabla relevante cambia, Room puede volver a ejecutar la consulta y producir una nueva emisión. El estado observado por Compose se actualiza y los componentes que lo leen pueden recomponerse.
8.1 Flow y StateFlow
Un Flow<T> representa una secuencia de valores que puede producirse a lo largo del tiempo. Un StateFlow<T> es un flujo especializado que siempre mantiene un valor actual consultable mediante value.
El operador stateIn convierte un flujo en un StateFlow compartido dentro de un ámbito:
class ListaViewModel(repository: ProductoRepository) : ViewModel() {
val productos: StateFlow<List<Producto>?> =
repository.observarTodos().stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = null
)
}
| Parámetro | Función |
|---|---|
scope |
Ámbito en el que se mantiene la recolección compartida |
started |
Estrategia que decide cuándo se activa y detiene la recolección upstream |
initialValue |
Estado disponible antes de la primera emisión |
SharingStarted.WhileSubscribed(5_000) conserva temporalmente la recolección upstream durante cinco segundos después de desaparecer el último suscriptor. Esto puede evitar reinicios innecesarios durante recreaciones rápidas de la pantalla. No significa que cierre la base de datos ni que destruya el StateFlow.
Con SharingStarted.Lazily, la recolección comienza al aparecer un primer suscriptor y continúa mientras viva el ámbito. Con Eagerly, empieza sin esperar a un suscriptor.
8.2 Recolección desde Jetpack Compose
@Composable
fun CatalogoRoute(viewModel: ListaViewModel) {
val productos by viewModel.productos.collectAsStateWithLifecycle()
when {
productos == null -> CircularProgressIndicator()
productos!!.isEmpty() -> Text("No hay productos registrados")
else -> ListaProductos(productos = productos!!)
}
}
collectAsStateWithLifecycle() convierte las emisiones en estado observable para Compose y ajusta la recolección al ciclo de vida, evitando trabajo innecesario cuando la pantalla no se encuentra en un estado activo adecuado.
El ejemplo muestra una representación mínima con null inicial; para aplicaciones que necesiten diferenciar carga, vacío, no encontrado y error, conviene definir un tipo de estado explícito.
8.3 Datos vacíos, carga y error
Una lista vacía significa que la consulta entregó un resultado sin registros. No significa por sí misma que todavía se esté cargando.
| Situación | Interpretación | Ejemplo de UI |
|---|---|---|
| Consulta sin primera emisión | Cargando | Indicador de progreso |
Resultado emptyList() |
Consulta exitosa sin registros | Mensaje de estado vacío |
| Resultado con elementos | Datos disponibles | Lista |
| Error de consulta | Operación fallida | Mensaje y posible reintento |
| Registro individual inexistente | No encontrado | Mensaje específico o navegación alternativa |
En particular, un Flow<Producto?> con valor null no permite diferenciar por sí solo entre «todavía no recibí datos» y «el producto ya no existe». Para esa distinción se necesita una representación explícita o un estado que conserve la información de carga.
9. Escrituras asíncronas y consistencia de interfaz
Las funciones DAO de escritura pueden ser suspendidas. El ViewModel puede invocarlas desde viewModelScope y exponer un estado que informe si la operación sigue en curso.
data class FormularioUiState(
val nombre: String = "",
val precio: String = "",
val cantidad: String = "",
val guardando: Boolean = false,
val error: String? = null
)
La validación debe comprobar los campos antes de convertir cadenas a números. Un toDouble() o toInt() sobre una entrada inválida puede lanzar una excepción, por lo que es preferible toDoubleOrNull() y toIntOrNull() o un validador apropiado.
fun guardar(onGuardado: () -> Unit) {
if (uiState.guardando) return
val precio = uiState.precio.toDoubleOrNull() ?: return
val cantidad = uiState.cantidad.toIntOrNull() ?: return
if (uiState.nombre.isBlank() || precio < 0 || cantidad < 0) return
viewModelScope.launch {
uiState = uiState.copy(guardando = true, error = null)
try {
repository.agregar(
Producto(0, uiState.nombre.trim(), precio, cantidad)
)
onGuardado()
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
uiState = uiState.copy(error = "No fue posible guardar el producto")
} finally {
uiState = uiState.copy(guardando = false)
}
}
}
El fragmento supone que uiState y repository son propiedades del ViewModel y que se importan las dependencias correspondientes. Es un ejemplo de gestión de una operación, no un ViewModel completo.
Por qué importa el orden: la navegación o confirmación no debe ejecutarse antes de conocer si la escritura se realizó correctamente. Un estado guardando también ayuda a impedir pulsaciones repetidas. En aplicaciones más amplias, el ViewModel puede exponer un evento o resultado de operación, dejando la decisión de navegación al componente de presentación.
Cancelación: si se usa un catch (e: Exception), es importante relanzar CancellationException para no ocultar la cancelación de la corrutina. El manejo de errores del ejemplo es genérico para ilustrar la estructura; un sistema real puede diferenciar tipos concretos de fallos.
9.1 Obtener un registro para editarlo
val producto = repository.observarPorId(id)
.filterNotNull()
.first()
first() recibe una emisión y termina la recolección. Es útil cuando se necesita una instantánea para inicializar el formulario, evitando reemplazar continuamente lo escrito por el usuario con emisiones posteriores.
Debe considerarse que filterNotNull().first() puede permanecer esperando indefinidamente si el registro no existe y el flujo solo emite null. Una consulta puntual obtenerPorId(id) puede ser más sencilla cuando se necesita distinguir inmediatamente un registro inexistente.
9.2 Actualizar y eliminar
@Update identifica la fila según la clave primaria de la entidad proporcionada. copy() facilita generar una nueva representación con valores actualizados:
val actualizado = producto.copy(cantidad = producto.cantidad - 1)
repository.actualizar(actualizado)
Antes de reducir el inventario, debe verificarse que cantidad > 0. Cuando varias operaciones puedan modificar el mismo dato de forma concurrente, leer y después actualizar puede provocar condiciones de carrera. En esos casos son convenientes transacciones o sentencias de actualización condicional.
Después de un @Delete, una consulta observable por identificador puede emitir null. La pantalla debe manejar el registro inexistente en lugar de suponer que siempre habrá datos mientras permanezca visible.
10. Construcción de dependencias sin Hilt
Cuando un ViewModel necesita un repositorio, y el repositorio necesita un DAO, conviene construir las dependencias desde un punto central.
Application
│
▼
AppContainer
│
├── CatalogoDatabase
│ └── ProductoDao
│
└── ProductoRepository
│
▼
ViewModel
Este enfoque se conoce como inyección manual de dependencias: los objetos reciben las dependencias que necesitan en vez de crearlas internamente en múltiples lugares.
10.1 Contenedor y Application
class AppContainer(context: Context) {
private val database by lazy {
Room.databaseBuilder(
context.applicationContext,
CatalogoDatabase::class.java,
"catalogo.db"
).build()
}
val productoRepository: ProductoRepository by lazy {
ProductoRepository(database.productoDao())
}
}
class CatalogoApplication : Application() {
val container by lazy { AppContainer(this) }
}
Una clase Application personalizada debe declararse en el AndroidManifest.xml:
<application android:name=".CatalogoApplication" ... >
...
</application>
Los puntos suspensivos representan atributos o contenido propios del proyecto; no son XML literal que deba copiarse sin adaptar.
10.2 Factory de ViewModels
Un ViewModel cuyo constructor recibe un repositorio no puede construirse automáticamente en todos los contextos sin indicar cómo obtener esa dependencia. Una ViewModelProvider.Factory puede crear la instancia y proporcionarle el repositorio compartido.
val Factory = viewModelFactory {
initializer {
val app = this[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY]
as CatalogoApplication
ListaViewModel(app.container.productoRepository)
}
}
Y en el destino de Compose:
val viewModel: ListaViewModel = viewModel(factory = Factory)
El ejemplo es intencionalmente pequeño. Si existen varios ViewModels, se definen inicializadores para los tipos necesarios; si requieren argumentos de navegación, la Factory puede obtenerlos mediante las CreationExtras y mecanismos como SavedStateHandle.
Hilt y otras librerías automatizan parte de esta construcción, pero comprender la versión manual ayuda a reconocer sus responsabilidades.
11. Cambios de esquema y migraciones
El esquema define tablas, columnas, tipos, índices y relaciones. Su versión permite identificar la estructura que espera cada versión de la aplicación.
Si se añade una columna a una entidad y se instala una actualización sobre una base que ya contiene datos, modificar solo la clase Kotlin no actualiza automáticamente el archivo existente. Es necesario gestionar el cambio de versión y una estrategia de migración.
11.1 Migración que conserva registros
Supongamos que se añade una categoría a cada producto. El cambio puede definirse con SQL para trasladar la base de la versión 1 a la 2:
val MIGRATION_1_2 = object : Migration(1, 2) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL(
"ALTER TABLE productos ADD COLUMN categoria TEXT NOT NULL DEFAULT 'General'"
)
}
}
Este ejemplo corresponde a la API clásica de migraciones basada en SupportSQLiteDatabase; se deben consultar las firmas pertinentes para la versión concreta de Room del proyecto. Se muestra para ilustrar la necesidad de transformar los registros existentes, no como instrucción de copiar el código en cualquier versión sin adaptarlo.
La migración debe registrarse al construir la base y la anotación @Database debe reflejar version = 2. Conviene exportar los esquemas y probar el paso entre versiones con bases que contengan datos reales de prueba.
11.2 Migración destructiva
fallbackToDestructiveMigration(...) permite recrear estructuras cuando Room no encuentra una ruta de migración adecuada, según las opciones configuradas. Puede eliminar datos existentes. Puede ser aceptable en una base de pruebas desechable; en producción exige una justificación explícita y conocimiento de sus consecuencias.
11.3 Qué significa exportar el esquema
Con exportSchema = true y una ruta de exportación configurada, Room puede conservar representaciones del esquema para controlar cambios y apoyar pruebas de migración. Desactivarlo simplifica un ejercicio corto, pero no es la opción recomendable para una aplicación que evolucionará mediante actualizaciones.
Conexión transversal: alcance y decisiones
Flow observable, estado de pantalla y persistencia
Flow<List<Entidad>> desde un DAO representa una fuente observable de datos de la base; StateFlow<UiState> en el ViewModel representa la situación actual de la pantalla. La pantalla recolecta valores para dibujarse, pero eso no convierte al ViewModel en almacenamiento permanente. stateIn(WhileSubscribed(...)) controla la suscripción al flujo ascendente; no elimina las filas ni cierra por sí mismo la base.
La tabla de Room sobrevive a la muerte del proceso; la instancia de ViewModel no. Para un borrador corto de formulario puede servir SavedStateHandle, pero no sustituye a una base cuando el dato debe persistir permanentemente. Véanse las guías 02 y 10.
12. Diagnóstico de problemas frecuentes
| Síntoma | Posible causa | Qué revisar |
|---|---|---|
| No aparece la implementación generada de la base o DAO | Procesamiento KSP mal configurado | Plugin y dependencia de compilador, compatibilidad de versiones |
Error no such column al compilar |
Consulta SQL usa un nombre inexistente | Anotaciones de la entidad y cadena @Query |
| Los datos desaparecen al reiniciar | Siguen almacenados solo en memoria o se recrea el almacenamiento | Ruta de lectura y escritura, configuración de base |
| La lista no se actualiza después de un cambio | No se está recolectando el flujo o se lee una instantánea | DAO observable, repositorio, StateFlow, colección desde Compose |
| Pantalla vacía durante la carga | Se confunde estado inicial con colección vacía | Representación explícita de carga |
| La pantalla muestra «Cargando» después de borrar | null combina estados diferentes |
Distinguir inexistencia de falta de primera emisión |
| Fallo al crear el ViewModel | No se proporcionan dependencias del constructor | Factory y contenedor |
Error al convertir a Application personalizada |
Falta declaración o instancia esperada | android:name en el manifiesto |
| Error al abrir una base antigua | Esquema incompatible o migración ausente | Versión, migraciones, pruebas |
| Se pierden registros después de actualizar | Migración destructiva | Política de migración y respaldos |
La ausencia de excepciones no garantiza consistencia: una interfaz que muestra datos antiguos también tiene un problema funcional aunque no falle al ejecutar.
13. Vocabulario esencial
| Término | Definición |
|---|---|
| Persistencia | Conservación de información más allá de la memoria temporal del proceso |
| SQLite | Motor de base de datos relacional utilizado localmente en Android |
| Room | Biblioteca que facilita acceso tipado y validado a SQLite |
| Esquema | Definición de la estructura de la base |
| Clave primaria | Identificador único de una fila |
| Entity | Clase que representa una estructura persistida |
| DAO | Interfaz de operaciones sobre la base |
| KSP | Procesamiento de símbolos Kotlin durante la compilación |
| Repositorio | Abstracción que organiza el acceso a fuentes de datos |
| Mapper | Función que convierte una representación de datos en otra |
| Flow | Secuencia asíncrona de emisiones recolectadas por un consumidor |
| StateFlow | Flujo de estado que conserva un valor actual |
stateIn |
Operador que convierte un flujo en StateFlow compartido |
collectAsStateWithLifecycle |
Integración del flujo con estado Compose respetando ciclo de vida |
| Migración | Transformación controlada entre versiones del esquema |
| Inyección de dependencias | Provisión externa de los objetos que necesita un componente |
14. Preguntas de autoevaluación
Comprensión conceptual
- ¿Por qué un ViewModel no sustituye una base de datos aunque sobreviva a la rotación?
- ¿En qué situaciones elegirías DataStore, archivos o Room?
- ¿Para qué sirven
Entity,DAOyRoomDatabase? - ¿Qué diferencia existe entre una consulta
suspendpuntual y una que devuelveFlow? - ¿Por qué conviene definir una clave primaria y especificar
ORDER BY? - ¿Cuál es el propósito de los mapeadores entre Entity y dominio?
- ¿Cómo se relacionan
Flow,stateInycollectAsStateWithLifecycle()? - ¿Qué representan
initialValueyWhileSubscribed(5_000)? - ¿Por qué un
nullinicial no equivale a una lista vacía ni a un registro inexistente? - ¿Qué problema resuelven un contenedor de dependencias y una Factory?
Interpretación de código
- Un DAO declara
@Query("SELECT * FROM productos ORDER BY nombres"), pero la columna se llamanombre. ¿En qué etapa esperarías encontrar el problema? - Un método devuelve
Flow<List<ProductoEntity>>y el repositorio utilizamap { filas -> filas.map { it.toDomain() } }. Explica qué transforma cadamap. - Un estudiante reemplaza
collectAsStateWithLifecycle()por una lectura puntual del repositorio durante la primera composición. ¿Qué comportamiento reactivo puede perder? - Una pantalla usa
filterNotNull().first()para obtener un producto cuyo ID no existe. ¿Por qué podría quedarse esperando? - Un
@Updaterecibe un objeto con una clave primaria incorrecta. ¿Qué consecuencia podría tener?
Aplicación y diagnóstico
- Se agrega un registro y la base contiene la nueva fila, pero la pantalla sigue mostrando los anteriores. ¿Qué componentes revisarías en orden?
- Una actualización añade la columna
categoriaa una aplicación con usuarios que ya tienen registros. ¿Qué cambios necesita el sistema para conservarlos? - Después de eliminar un registro, el detalle muestra indefinidamente un indicador de carga. ¿Cómo mejorarías el modelo de estado?
- Dos operaciones de venta reducen simultáneamente la última unidad disponible. ¿Qué problema puede producirse y cómo lo evitarías?
- Una aplicación necesita conservar registros locales y sincronizarlos después con un servidor. ¿Qué responsabilidad tendría Room y qué parte adicional haría falta?
15. Respuestas posibles
- El ViewModel conserva estado durante determinados cambios de configuración, pero no es almacenamiento duradero: al terminar el proceso se pierde su memoria. Room guarda los registros en almacenamiento local.
- DataStore suele ser adecuado para preferencias pequeñas; archivos para documentos o binarios; Room para colecciones estructuradas con consultas y relaciones.
- Entity declara la representación persistida; DAO define consultas y operaciones; RoomDatabase agrupa entidades, versión y acceso a DAO.
- La consulta puntual devuelve una respuesta para una ejecución; el Flow permite observar nuevas emisiones asociadas a cambios de las tablas relevantes.
- La clave identifica registros para operaciones precisas. Sin
ORDER BY, una consulta no garantiza un orden de presentación. - Evitan acoplar las reglas del dominio a las anotaciones y formas de almacenamiento, facilitando cambios de origen o esquema.
- Room produce un Flow;
stateInproporciona un estado actual compartido; Compose lo observa con una recolección vinculada al ciclo de vida. initialValueaporta el estado antes de la primera emisión.WhileSubscribed(5_000)da un margen de cinco segundos tras perder el último suscriptor antes de detener la recolección upstream.nullpuede representar ausencia de primera emisión o inexistencia de un registro;emptyList()es una consulta exitosa sin filas. Si hay que distinguirlos, debe diseñarse un estado explícito.- El contenedor centraliza la construcción y las dependencias; la Factory explica cómo crear ViewModels con argumentos o repositorios que no pueden inferirse automáticamente.
- Room puede rechazar la consulta estática al compilar, indicando que la columna referida no existe.
- El
mapexterior transforma cada lista emitida por el Flow; el interior transforma cada Entity de esa lista en su modelo de dominio. - La pantalla deja de observar futuras emisiones y puede necesitar recargas manuales para mostrar los cambios posteriores.
filterNotNull()descarta todas las emisiones nulas, yfirst()no termina hasta recibir un valor no nulo. Un ID inexistente puede no producirlo nunca.@Updateutiliza la clave primaria para identificar la fila. Puede no modificar ninguna, o modificar una fila diferente si la clave corresponde a otro registro; conviene revisar el valor de retorno y la identidad.- Comprobaría si el DAO declara una consulta observable, si se recolecta el Flow, si el repositorio transmite las emisiones, si
stateIntiene suscriptores activos y si Compose observa el estado adecuado. - Incrementar la versión del esquema, definir una migración compatible, registrarla, exportar esquemas y probar la actualización sobre una base con registros preexistentes.
- Reemplazaría el uso ambiguo de
Producto?por un estado que diferencieCargando,Encontrado,NoEncontradoyError; navegaría o mostraría un mensaje cuando se confirme que ya no existe. - Es una condición de carrera: ambas operaciones pueden leer la misma cantidad y producir una actualización incorrecta. Puede resolverse mediante una transacción o un
UPDATEatómico con condicióncantidad > 0y comprobación de filas afectadas. - Room sería la fuente local persistente. Para sincronizar hacen falta una capa remota, reglas para resolver conflictos, conectividad y una estrategia que coordine escrituras y cambios entre ambas fuentes.
Cierre
Room organiza el almacenamiento estructurado en Android y permite observar registros de forma reactiva. El punto central es reconocer qué responsabilidades pertenecen a la base, cuáles al repositorio, cuáles al ViewModel y cuáles a la interfaz. Las consultas observables reducen la necesidad de recargas manuales, pero no sustituyen el manejo de errores, la definición de estados ni las estrategias para proteger información cuando evoluciona el esquema.
Para repasar el tema antes del examen, conviene poder responder cinco preguntas sin consultar el código: ¿qué diferencia hay entre estado y persistencia?, ¿cómo se conecta un DAO con un ViewModel?, ¿por qué una consulta observable actualiza Compose?, ¿cómo se distingue la ausencia de datos de una carga pendiente? y ¿qué ocurre con los registros existentes cuando cambia el esquema?