Persistencia local en Android con Room y Kotlin

Capítulo 7

Tiempo estimado de lectura: 9 minutos

+ Ejercicio

¿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.

Continúa en nuestra aplicación.
  • Escuche el audio con la pantalla apagada.
  • Obtenga un certificado al finalizar.
  • ¡Más de 5000 cursos para que explores!
O continúa leyendo más abajo...
Download App

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 Long autogenerado como clave primaria para simplificar relaciones y actualizaciones.
  • Evita listas/objetos anidados directamente en columnas; usa tablas relacionadas o TypeConverter si es inevitable.
  • Guarda fechas como Long (epoch) o usa convertidores si prefieres Instant/Date.
  • Agrega índices en columnas usadas en WHERE, ORDER BY o 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 Flow para listas observables; evita “refrescar manualmente” tras cada operación.
  • Para actualizaciones simples, un @Query de UPDATE puede 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 suspend en DAO para escrituras/lecturas puntuales y llámalas desde corrutinas (por ejemplo, en viewModelScope).
  • Evita consultas sin suspend que 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.

Ahora responde el ejercicio sobre el contenido:

¿Cuál es el beneficio principal de exponer una consulta de Room como Flow al mostrar una lista de tareas en la UI?

¡Tienes razón! Felicitaciones, ahora pasa a la página siguiente.

¡Tú error! Inténtalo de nuevo.

Al devolver un Flow, Room puede emitir nuevas listas cuando la tabla cambia (insertar/actualizar/eliminar). La UI se suscribe al flujo y se actualiza automáticamente, evitando refrescos manuales.

Siguiente capítulo

Consumo de APIs REST en Android con Retrofit y serialización en Kotlin

Arrow Right Icon
Portada de libro electrónico gratuitaAndroid desde Cero con Kotlin
50%

Android desde Cero con Kotlin

Nuevo curso

14 páginas

Descarga la aplicación para obtener una certificación gratuita y escuchar cursos en segundo plano, incluso con la pantalla apagada.