¿Qué es Room?
Room es la librería oficial de Jetpack para acceso a bases de datos SQLite en Android. Provee una capa de abstracción sobre SQLite que elimina el boilerplate típico (abrir conexiones, manejar cursores, serializar objetos) y agrega verificación en tiempo de compilación de las queries SQL.
Room tiene tres componentes principales: Entity (una tabla), DAO (las operaciones sobre esa tabla), y Database (el punto de acceso central).
Configuración
// build.gradle (app)
plugins {
id("com.google.devtools.ksp") // KSP es más rápido que kapt
}
dependencies {
val room_version = "2.6.1"
implementation("androidx.room:room-runtime:$room_version")
implementation("androidx.room:room-ktx:$room_version") // soporte para coroutines y Flow
ksp("androidx.room:room-compiler:$room_version")
}
Entity — definir una tabla
Una Entity es una data class anotada. Cada instancia representa una fila en la tabla:
@Entity(tableName = "productos")
data class ProductoEntity(
@PrimaryKey(autoGenerate = true)
val id: Int = 0,
@ColumnInfo(name = "nombre")
val nombre: String,
@ColumnInfo(name = "precio")
val precio: Double,
@ColumnInfo(name = "categoria")
val categoria: String,
@ColumnInfo(name = "fecha_creacion")
val fechaCreacion: Long = System.currentTimeMillis()
)
Entity vs modelo de dominioEs buena práctica tener clases separadas para la base de datos (Entity con sufijo Entity) y el modelo de dominio que usa el resto de la app (Producto). Un mapper convierte entre ambos. Así la base de datos no contamina la lógica de negocio.
DAO — las operaciones
El Data Access Object es una interfaz con las queries. Room genera la implementación en tiempo de compilación y valida el SQL:
@Dao
interface ProductoDao {
// INSERT
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertar(producto: ProductoEntity)
@Insert
suspend fun insertarTodos(productos: List<ProductoEntity>)
// UPDATE
@Update
suspend fun actualizar(producto: ProductoEntity)
// DELETE
@Delete
suspend fun eliminar(producto: ProductoEntity)
@Query("DELETE FROM productos WHERE id = :id")
suspend fun eliminarPorId(id: Int)
// SELECT — con Flow para actualizaciones reactivas
@Query("SELECT * FROM productos ORDER BY nombre ASC")
fun obtenerTodos(): Flow<List<ProductoEntity>>
@Query("SELECT * FROM productos WHERE id = :id")
suspend fun obtenerPorId(id: Int): ProductoEntity?
@Query("SELECT * FROM productos WHERE categoria = :categoria")
fun obtenerPorCategoria(categoria: String): Flow<List<ProductoEntity>>
@Query("SELECT COUNT(*) FROM productos")
suspend fun contarProductos(): Int
}
suspend vs FlowUsá suspend para operaciones únicas (insert, update, delete, select puntual). Usá Flow para queries que deben actualizarse automáticamente cuando la tabla cambia. Room emite un nuevo valor al Flow cada vez que los datos relevantes cambian.
Database — el punto de entrada
@Database(
entities = [ProductoEntity::class],
version = 1,
exportSchema = true // guarda el schema en un JSON para migraciones
)
abstract class AppDatabase : RoomDatabase() {
abstract fun productoDao(): ProductoDao
companion object {
@Volatile
private var INSTANCE: AppDatabase? = null
fun getInstance(context: Context): AppDatabase {
return INSTANCE ?: synchronized(this) {
Room.databaseBuilder(
context.applicationContext,
AppDatabase::class.java,
"app_database"
).build().also { INSTANCE = it }
}
}
}
}
El patrón Singleton es importante: Room mantiene una conexión a la base de datos y crear múltiples instancias genera problemas de concurrencia y memoria.
Patrón Repository
El ViewModel no debería acceder al DAO directamente. Un Repository actúa como fuente única de verdad y puede combinar datos de múltiples fuentes (Room + API remota):
class ProductoRepository(private val dao: ProductoDao) {
// Expone un Flow del DAO directamente
val productos: Flow<List<Producto>> = dao.obtenerTodos()
.map { entities -> entities.map { it.toDomainModel() } }
suspend fun guardar(producto: Producto) {
dao.insertar(producto.toEntity())
}
suspend fun eliminar(producto: Producto) {
dao.eliminar(producto.toEntity())
}
}
// ViewModel limpio gracias al Repository:
class ProductosViewModel(private val repo: ProductoRepository) : ViewModel() {
val productos: StateFlow<List<Producto>> = repo.productos
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = emptyList()
)
fun eliminar(producto: Producto) {
viewModelScope.launch { repo.eliminar(producto) }
}
}
El flujo reactivo completo
Con Room + Flow + StateFlow + ListAdapter tenés un flujo completamente reactivo:
// Usuario toca "eliminar" en un item
// → Fragment llama viewModel.eliminar(producto)
// → ViewModel llama repository.eliminar()
// → Repository llama dao.eliminar()
// → Room actualiza la base de datos
// → El Flow del DAO emite la nueva lista automáticamente
// → StateFlow en el ViewModel se actualiza
// → collect en el Fragment recibe la lista
// → adapter.submitList() con DiffUtil anima la eliminación
No hay polling, no hay callbacks manuales. Solo datos fluyendo de abajo hacia arriba.
Migraciones de schema
Cuando modificás una Entity (agregás una columna, renombrás una tabla), debés incrementar la versión en @Database y proveer una Migration:
val MIGRATION_1_2 = object : Migration(1, 2) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("ALTER TABLE productos ADD COLUMN stock INTEGER NOT NULL DEFAULT 0")
}
}
// En el builder:
Room.databaseBuilder(context, AppDatabase::class.java, "app_database")
.addMigrations(MIGRATION_1_2)
.build()
No uses fallbackToDestructiveMigration() en producciónEsta opción elimina y recrea la base de datos si no encuentra una migration, borrando todos los datos del usuario. Solo usala durante desarrollo.