¿Qué es Room y por qué usarlo?
Room es la capa de persistencia recomendada sobre SQLite en Android. Te permite definir tablas como clases Kotlin (entidades), declarar operaciones de lectura/escritura (DAO) y obtener una base de datos tipada y segura. Room valida tus consultas en tiempo de compilación, reduce código repetitivo y se integra muy bien con corrutinas y flujos reactivos para observar cambios.
Dependencias y configuración mínima
En tu módulo app, agrega Room con soporte para corrutinas y (opcionalmente) KSP para generar código más rápido. Las versiones cambian con frecuencia, así que usa las recomendadas por tu proyecto.
// build.gradle(.kts) - ejemplo orientativo
dependencies {
implementation("androidx.room:room-runtime:2.6.1")
implementation("androidx.room:room-ktx:2.6.1")
ksp("androidx.room:room-compiler:2.6.1")
}
Si usas KAPT en lugar de KSP, reemplaza ksp por kapt y habilita el plugin correspondiente.
Ejemplo incremental: una lista de tareas (crear, listar, editar, eliminar)
Construiremos un almacenamiento local para una lista de tareas. Empezaremos guardando y listando elementos, y luego añadiremos editar/eliminar. Finalmente, observaremos cambios para reflejarlos en la UI.
1) Entidad: modelar la tabla
Una entidad representa una tabla. Define un identificador, campos y (si aplica) índices para acelerar búsquedas. En este ejemplo, indexaremos title para búsquedas por texto y ordenaciones frecuentes.
- Escuche el audio con la pantalla apagada.
- Obtenga un certificado al finalizar.
- ¡Más de 5000 cursos para que explores!
Descargar la aplicación
import androidx.room.Entity
import androidx.room.Index
import androidx.room.PrimaryKey
@Entity(
tableName = "tasks",
indices = [Index(value = ["title"]) ]
)
data class TaskEntity(
@PrimaryKey(autoGenerate = true) val id: Long = 0L,
val title: String,
val done: Boolean = false,
val updatedAt: Long = System.currentTimeMillis()
)
Recomendaciones de modelado
- Usa
Longautogenerado como clave primaria para simplificar relaciones y actualizaciones. - Evita listas/objetos anidados directamente en columnas; usa tablas relacionadas o
TypeConvertersi es inevitable. - Guarda fechas como
Long(epoch) o usa convertidores si prefieresInstant/Date. - Agrega índices en columnas usadas en
WHERE,ORDER BYo claves foráneas; no indexes “por si acaso” porque encarece escrituras.
2) DAO: operaciones de base de datos
El DAO define consultas y operaciones. Para observar cambios, Room puede devolver Flow: cada vez que cambie la tabla, el flujo emitirá una nueva lista.
import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Update
import kotlinx.coroutines.flow.Flow
@Dao
interface TaskDao {
@Query("SELECT * FROM tasks ORDER BY updatedAt DESC")
fun observeAll(): Flow<List<TaskEntity>>
@Query("SELECT * FROM tasks WHERE id = :id LIMIT 1")
suspend fun getById(id: Long): TaskEntity?
@Insert(onConflict = OnConflictStrategy.ABORT)
suspend fun insert(task: TaskEntity): Long
@Update
suspend fun update(task: TaskEntity)
@Delete
suspend fun delete(task: TaskEntity)
@Query("DELETE FROM tasks")
suspend fun deleteAll()
@Query("UPDATE tasks SET done = :done, updatedAt = :updatedAt WHERE id = :id")
suspend fun setDone(id: Long, done: Boolean, updatedAt: Long = System.currentTimeMillis())
}
Notas prácticas
- Prefiere
Flowpara listas observables; evita “refrescar manualmente” tras cada operación. - Para actualizaciones simples, un
@QuerydeUPDATEpuede ser más eficiente que cargar y luego@Update. - Define estrategias de conflicto conscientemente:
ABORT(falla),REPLACE(sobrescribe),IGNORE(no inserta). En tareas, suele ser mejor fallar o controlar el conflicto explícitamente.
3) Base de datos: el contenedor RoomDatabase
La clase de base de datos lista las entidades y expone los DAOs. También define la versión, clave para migraciones.
import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
@Database(
entities = [TaskEntity::class],
version = 1,
exportSchema = true
)
abstract class AppDatabase : RoomDatabase() {
abstract fun taskDao(): TaskDao
companion object {
@Volatile private var INSTANCE: AppDatabase? = null
fun getInstance(context: Context): AppDatabase {
return INSTANCE ?: synchronized(this) {
INSTANCE ?: Room.databaseBuilder(
context.applicationContext,
AppDatabase::class.java,
"app.db"
)
.build()
.also { INSTANCE = it }
}
}
}
}
exportSchema ayuda a mantener un historial de esquemas (útil para migraciones). En proyectos reales, configura la carpeta de esquemas en Gradle para versionarlos.
Paso a paso: guardar y listar elementos
4) Repositorio: una capa para aislar Room
El repositorio centraliza el acceso a datos y te permite cambiar la fuente (Room, red, caché) sin tocar la UI. Además, es un buen lugar para mapear entre entidades y modelos de dominio si lo necesitas.
import kotlinx.coroutines.flow.Flow
class TaskRepository(private val dao: TaskDao) {
fun observeTasks(): Flow<List<TaskEntity>> = dao.observeAll()
suspend fun addTask(title: String) {
val entity = TaskEntity(title = title.trim())
dao.insert(entity)
}
}
5) Observar cambios y reflejarlos en la UI
La idea clave: la UI no “pide” la lista cada vez; se suscribe a un Flow que emite automáticamente cuando hay inserciones/actualizaciones/borrados en la tabla observada.
Ejemplo de recolección en Compose (solo la parte relevante de observación):
// En tu ViewModel expones un Flow/StateFlow derivado del repositorio
val tasksFlow: Flow<List<TaskEntity>> = repository.observeTasks()
// En Compose (ejemplo conceptual)
val tasks by tasksFlow.collectAsState(initial = emptyList())
Cuando llames a addTask, Room invalidará la consulta y el Flow emitirá la lista actualizada; Compose recompondrá mostrando el nuevo elemento.
Ampliación: editar y eliminar
6) Editar: actualizar campos
Para editar, normalmente necesitas el id. Puedes cargar la entidad, modificarla y usar @Update, o ejecutar un UPDATE directo si solo cambias algunos campos.
class TaskRepository(private val dao: TaskDao) {
fun observeTasks() = dao.observeAll()
suspend fun addTask(title: String) {
dao.insert(TaskEntity(title = title.trim()))
}
suspend fun renameTask(id: Long, newTitle: String) {
val current = dao.getById(id) ?: return
dao.update(
current.copy(
title = newTitle.trim(),
updatedAt = System.currentTimeMillis()
)
)
}
suspend fun toggleDone(id: Long, done: Boolean) {
dao.setDone(id = id, done = done)
}
suspend fun deleteTask(id: Long) {
val current = dao.getById(id) ?: return
dao.delete(current)
}
}
Con esto, cualquier edición o cambio de estado se reflejará automáticamente en la UI si estás observando observeAll().
7) Eliminar: por entidad o por id
Eliminar por entidad es cómodo si ya la tienes. Si solo tienes el id, puedes crear un @Query específico para evitar una lectura previa.
@Dao
interface TaskDao {
// ...
@Query("DELETE FROM tasks WHERE id = :id")
suspend fun deleteById(id: Long)
}
Consultas útiles: búsqueda, filtros y paginado simple
Room soporta SQL en @Query. Algunas consultas típicas:
@Query("SELECT * FROM tasks WHERE done = 0 ORDER BY updatedAt DESC")
fun observePending(): Flow<List<TaskEntity>>
@Query("SELECT * FROM tasks WHERE title LIKE '%' || :q || '%' ORDER BY updatedAt DESC")
fun observeSearch(q: String): Flow<List<TaskEntity>>
@Query("SELECT * FROM tasks ORDER BY updatedAt DESC LIMIT :limit OFFSET :offset")
suspend fun listPage(limit: Int, offset: Int): List<TaskEntity>
Índices y rendimiento: si vas a buscar por title con frecuencia, el índice ayuda, aunque las búsquedas con LIKE '%texto%' no siempre aprovechan índices plenamente. Para búsquedas avanzadas, considera FTS (Full-Text Search) más adelante.
Manejo de hilos: evitar bloqueos y ANR
Las operaciones de base de datos pueden ser costosas. Reglas prácticas:
- Usa
suspenden DAO para escrituras/lecturas puntuales y llámalas desde corrutinas (por ejemplo, enviewModelScope). - Evita consultas sin
suspendque devuelvan listas directamente si podrían ejecutarse en el hilo principal. - Para observación, usa
Flow; Room ejecuta la consulta fuera del main thread y emite resultados de forma segura. - Si haces trabajo adicional pesado (mapeos grandes, ordenaciones complejas), aplica
flowOn(Dispatchers.Default)o mueve el procesamiento fuera del hilo principal.
// Ejemplo conceptual en repositorio
val tasksUiFlow = dao.observeAll()
.map { list -> list.map { it /* map a UI model si aplica */ } }
.flowOn(kotlinx.coroutines.Dispatchers.Default)
Migraciones simples: evolucionar el esquema sin perder datos
Cuando cambias entidades (agregas columnas, renombrados, nuevas tablas), debes incrementar la versión de la base de datos y proporcionar una migración. Una migración es un bloque SQL que transforma el esquema de una versión a otra.
8) Caso típico: agregar una columna nueva
Supongamos que en la versión 2 quieres agregar priority (entero) con valor por defecto 0.
1) Actualiza la entidad:
@Entity(tableName = "tasks", indices = [Index(value = ["title"])])
data class TaskEntity(
@PrimaryKey(autoGenerate = true) val id: Long = 0L,
val title: String,
val done: Boolean = false,
val updatedAt: Long = System.currentTimeMillis(),
val priority: Int = 0
)
2) Sube la versión de la base de datos y define la migración:
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
val MIGRATION_1_2 = object : Migration(1, 2) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE tasks ADD COLUMN priority INTEGER NOT NULL DEFAULT 0")
}
}
3) Registra la migración en el builder:
@Database(entities = [TaskEntity::class], version = 2, exportSchema = true)
abstract class AppDatabase : RoomDatabase() {
abstract fun taskDao(): TaskDao
companion object {
fun getInstance(context: Context): AppDatabase {
return Room.databaseBuilder(context, AppDatabase::class.java, "app.db")
.addMigrations(MIGRATION_1_2)
.build()
}
}
}
9) ¿Y si no te importa perder datos en desarrollo?
Durante prototipado, puedes usar fallbackToDestructiveMigration() para recrear la base de datos al cambiar la versión. Evítalo en producción porque borra los datos del usuario.
Room.databaseBuilder(context, AppDatabase::class.java, "app.db")
.fallbackToDestructiveMigration()
.build()
Buenas prácticas adicionales
Validación y consistencia
- Normaliza texto de entrada:
trim(), evita títulos vacíos, y considera reglas de unicidad si aplica. - Si necesitas unicidad (por ejemplo,
titleúnico), puedes definir un índice único:Index(value=["title"], unique=true). Maneja el error de inserción para mostrar feedback.
Transacciones
Si una operación requiere múltiples pasos que deben ser atómicos, usa @Transaction en el DAO.
@Dao
interface TaskDao {
@Transaction
suspend fun replaceAll(tasks: List<TaskEntity>) {
deleteAll()
tasks.forEach { insert(it) }
}
@Query("DELETE FROM tasks")
suspend fun deleteAll()
@Insert
suspend fun insert(task: TaskEntity): Long
}
Relaciones (vista rápida)
Si más adelante agregas subtareas o categorías, lo habitual es modelar tablas separadas y usar relaciones con @Relation y claves foráneas. Mantén las entidades enfocadas en persistencia y crea modelos de UI/dominio cuando la app crezca.