Cómo guardar contenido en una base de datos local con Room 2.x

Si tu app controla grandes cantidades de datos estructurados, puedes beneficiarte con la posibilidad de conservar esos datos localmente. El caso de uso más común consiste en almacenar en caché datos relevantes para que el dispositivo no pueda acceder a la red, de modo que el usuario pueda explorar ese contenido mientras está sin conexión.

La biblioteca de persistencias Room brinda una capa de abstracción para SQLite que te permite acceder a la base de datos sin problemas y, al mismo tiempo, aprovechar toda la tecnología de SQLite.

Cómo configurar Room 2.x

Para usar Room 2.x en tu app, agrega las siguientes dependencias al archivo build.gradle de la app:

dependencies {
    val room_version = "2.6.1"

    implementation("androidx.room:room-runtime:$room_version")
    annotationProcessor("androidx.room:room-compiler:$room_version")

    // To use Kotlin Symbol Processing (KSP)
    // ksp("androidx.room:room-compiler:$room_version")

    // optional - Kotlin Extensions and Coroutines support for Room
    implementation("androidx.room:room-ktx:$room_version")

    // optional - RxJava2 support for Room
    implementation("androidx.room:room-rxjava2:$room_version")

    // optional - Guava support for Room, including Optional and ListenableFuture
    implementation("androidx.room:room-guava:$room_version")

    // optional - Test helpers
    testImplementation("androidx.room:room-testing:$room_version")
}

Componentes principales

Room tiene tres componentes principales:

  • Clase de base de datos que contiene la base de datos y sirve como punto de acceso principal para la conexión subyacente a los datos persistentes de la app
  • Entidades de datos que representan tablas de la base de datos de tu app
  • Objetos de acceso a datos (DAO) que proporcionan métodos que tu app puede usar para consultar, actualizar, insertar y borrar datos en la base de datos

En la figura 1, se muestran las relaciones entre los diferentes componentes de Room.

Figura 1. Diagrama de la arquitectura de la biblioteca de Room

Ejemplo de implementación

// Entity
@Entity
data class User(
    @PrimaryKey val uid: Int,
    @ColumnInfo(name = "first_name") val firstName: String?,
    @ColumnInfo(name = "last_name") val lastName: String?
)

// DAO
@Dao
interface UserDao {
    @Query("SELECT * FROM user")
    fun getAll(): List<User>

    @Insert
    fun insertAll(vararg users: User)

    @Delete
    fun delete(user: User)
}

// Database
@Database(entities = [User::class], version = 1)
abstract class AppDatabase : RoomDatabase() {
    abstract fun userDao(): UserDao
}

// Usage
val db = Room.databaseBuilder(
            applicationContext,
            AppDatabase::class.java, "database-name"
        ).build()

val userDao = db.userDao()
val users: List<User> = userDao.getAll()

Cómo definir datos mediante entidades

Cada entidad de Room representa una tabla en la base de datos. Define cada entidad como una clase con @Entity como anotación.

@Entity(tableName = "users")
data class User (
    @PrimaryKey val id: Int,
    @ColumnInfo(name = "first_name") val firstName: String?,
    @ColumnInfo(name = "last_name") val lastName: String?,
    @Ignore val picture: Bitmap? = null
)
  • Nombres de tabla y columna personalizados: De forma predeterminada, Room usa el nombre de la clase como el nombre de la tabla y los nombres de propiedad como nombres de columna. Para personalizarlos, usa la tableName propiedad en @Entity y la @ColumnInfo(name = "...") anotación.
  • Clave primaria: Para definir una clave primaria, usa @PrimaryKey. Para las claves compuestas, usa la propiedad primaryKeys de @Entity: @Entity(primaryKeys = ["firstName", "lastName"]).
  • Ignorar campos: Para evitar que los campos persistan, usa @Ignore.

Convertidores de tipos

A veces, necesitas almacenar tipos personalizados, como Date, en una sola columna. Proporciona @TypeConverter métodos para convertir tipos personalizados en tipos que Room puede conservar y viceversa.

class Converters {
  @TypeConverter
  fun fromTimestamp(value: Long?): Date? = value?.let { Date(it) }

  @TypeConverter
  fun dateToTimestamp(date: Date?): Long? = date?.time
}

// Register in your Database class
@Database(entities = [User::class], version = 1)
@TypeConverters(Converters::class)
abstract class AppDatabase : RoomDatabase() { ... }

Cómo acceder a datos con DAO

Los DAO definen métodos para la interacción con la base de datos. Anota la interfaz o la clase abstracta con @Dao.

Métodos de conveniencia

@Dao
interface UserDao {
    @Insert(onConflict = OnConflictStrategy.REPLACE)
    fun insertUsers(vararg users: User)

    @Update
    fun updateUsers(vararg users: User)

    @Delete
    fun deleteUsers(vararg users: User)
}
  • Insertar: El método de inserción puede mostrar un Long que representa el ID de la fila insertada o un List<Long> que contiene los IDs de todas las filas insertadas.
  • Actualizar o borrar: El método de actualización o eliminación puede mostrar un Int que representa la cantidad de filas afectadas.

Métodos de búsqueda

Anota métodos con @Query para escribir instrucciones de SQL. Room valida las consultas en el tiempo de compilación.

@Dao
interface UserDao {
    // Simple query
    @Query("SELECT * FROM user")
    fun loadAllUsers(): Array<User>

    // Return a subset of columns using a POJO or tuple
    @Query("SELECT first_name, last_name FROM user")
    fun loadFullName(): List<NameTuple>

    // Pass parameters
    @Query("SELECT * FROM user WHERE age > :minAge")
    fun loadAllUsersOlderThan(minAge: Int): Array<User>

    // Collection of parameters
    @Query("SELECT * FROM user WHERE region IN (:regions)")
    fun loadUsersFromRegions(regions: List<String>): List<User>

    // Join tables
    @Query("SELECT * FROM book INNER JOIN user ON user.id = book.user_id WHERE user.name = :userName")
    fun findBooksBorrowedByName(userName: String): List<Book>
}

Tipos de datos que se muestran de multimapa

En Room 2.4 y versiones posteriores, los métodos de búsqueda pueden mostrar un multimapa directamente con el tipo Map:

@Query("SELECT * FROM user JOIN book ON user.id = book.user_id")
fun loadUserAndBookNames(): Map<User, List<Book>>

Consultas DAO asíncronas

Para evitar que la IU se bloquee, las consultas de la base de datos no pueden ejecutarse en el subproceso principal. Haz que tus consultas sean asíncronas con una de las siguientes integraciones:

Corrutinas y Flow de Kotlin

Requiere la dependencia room-ktx.

@Dao
interface UserDao {
    // One-shot async query
    @Insert
    suspend fun insertUsers(vararg users: User)

    // Observable query using Flow
    @Query("SELECT * FROM user WHERE id = :id")
    fun loadUserById(id: Int): Flow<User>
}

Java con RxJava

Requiere room-rxjava2 o room-rxjava3.

@Dao
interface UserDao {
    @Insert
    fun insertUsers(users: List<User>): Completable

    @Query("SELECT * FROM user WHERE id = :id")
    fun loadUserById(id: Int): Flowable<User>
}

Java con LiveData y Guava

Requiere room-guava para ListenableFuture.

@Dao
interface UserDao {
    // LiveData for observable queries
    @Query("SELECT * FROM user WHERE id = :id")
    fun loadUserById(id: Int): LiveData<User>

    // Guava ListenableFuture for one-shot queries
    @Insert
    fun insertUsers(users: List<User>): ListenableFuture<Integer>
}

Cómo definir relaciones en Room 2.x

Para evitar la carga diferida en el subproceso de IU, no puedes usar referencias directas de objetos entre entidades. En su lugar, define relaciones con clases de datos intermedias con @Relation.

Uno a uno

Cada usuario tiene una sola biblioteca.

@Entity
data class User(@PrimaryKey val userId: Long, val name: String)

@Entity
data class Library(@PrimaryKey val libraryId: Long, val userOwnerId: Long)

// Intermediate class
data class UserAndLibrary(
    @Embedded val user: User,
    @Relation(
         parentColumn = "userId",
         entityColumn = "userOwnerId"
    )
    val library: Library
)

// DAO Query
@Transaction
@Query("SELECT * FROM User")
fun getUsersAndLibraries(): List<UserAndLibrary>

Uno a varios

Cada usuario puede tener muchas playlists.

@Entity
data class Playlist(@PrimaryKey val playlistId: Long, val userCreatorId: Long)

data class UserWithPlaylists(
    @Embedded val user: User,
    @Relation(
          parentColumn = "userId",
          entityColumn = "userCreatorId"
    )
    val playlists: List<Playlist>
)

Varios a varios

Las playlists pueden tener muchas canciones, y las canciones pueden estar en muchas playlists. Requiere una tabla de unión.

@Entity
data class Song(@PrimaryKey val songId: Long, val songName: String)

@Entity(primaryKeys = ["playlistId", "songId"])
data class PlaylistSongCrossRef(val playlistId: Long, val songId: Long)

data class PlaylistWithSongs(
    @Embedded val playlist: Playlist,
    @Relation(
         parentColumn = "playlistId",
         entityColumn = "songId",
         associateBy = Junction(PlaylistSongCrossRef::class)
    )
    val songs: List<Song>
)

Relaciones anidadas

Consulta los usuarios, sus playlists y todas las canciones de esas playlists.

data class UserWithPlaylistsAndSongs(
      @Embedded val user: User,
      @Relation(
          entity = Playlist::class,
          parentColumn = "userId",
          entityColumn = "userCreatorId"
      )
      val playlists: List<PlaylistWithSongs> // Nesting PlaylistWithSongs
  )

Administración de bases de datos

En esta sección, se abarcan varios aspectos de la administración de tu base de datos de Room, incluidas las vistas de la base de datos, la propagación previa de datos y las migraciones de bases de datos.

Vistas de la base de datos

Encapsula una consulta compleja en una clase con @DatabaseView como anotación.

@DatabaseView("SELECT user.id, user.name, department.name AS departmentName FROM user INNER JOIN department ON user.departmentId = department.id")
data class UserDetail(val id: Long, val name: String, val departmentName: String)

// Register in Database class
@Database(entities = [User::class], views = [UserDetail::class], version = 1)
abstract class AppDatabase : RoomDatabase() { ... }

Propaga la base de datos

Propaga la base de datos en la inicialización desde un archivo de recursos o el sistema de archivos.

Room.databaseBuilder(appContext, AppDatabase::class.java, "Sample.db")
    .createFromAsset("database/myapp.db")
    .build()

Migraciones

Cuando cambies el esquema, aumenta la versión de la base de datos y define un Migration objeto.

val MIGRATION_1_2 = object : Migration(1, 2) {
  override fun migrate(database: SupportSQLiteDatabase) {
    database.execSQL("ALTER TABLE User ADD COLUMN age INTEGER NOT NULL DEFAULT 0")
  }
}

Room.databaseBuilder(applicationContext, AppDatabase::class.java, "database-name")
  .addMigrations(MIGRATION_1_2)
  .build()
  • Migraciones automáticas: Si usas Room 2.4.0 o versiones posteriores, puedes usar @AutoMigration para migrar automáticamente los cambios básicos del esquema. Para ello, debes establecer exportSchema en true en la configuración de la base de datos: @Database(version = 2, autoMigrations = [AutoMigration(from = 1, to = 2)]).
  • Respaldo destructivo: Si resulta aceptable perder datos cuando faltan rutas de migración, llama a .fallbackToDestructiveMigration cuando compiles la base de datos.

Cómo probar migraciones

Para verificar las migraciones, usa MigrationTestHelper del artefacto room-testing. Para admitir esto, asegúrate de exportar esquemas en la configuración de build.gradle.

@RunWith(AndroidJUnit4::class)
class MigrationTest {
    @get:Rule
    val helper: MigrationTestHelper = MigrationTestHelper(
            InstrumentationRegistry.getInstrumentation(),
            AppDatabase::class.java.canonicalName,
            FrameworkSQLiteOpenHelperFactory()
    )

    @Test
    fun migrate1To2() {
        var db = helper.createDatabase("test-db", 1).apply {
            execSQL("INSERT INTO User VALUES (1, 'John')")
            close()
        }
        db = helper.runMigrationsAndValidate("test-db", 2, true, MIGRATION_1_2)
        // Verify data was migrated correctly
    }
}

Cómo migrar de SQLite a Room

Para migrar tu app de SQLite a Room, completa los siguientes pasos:

  1. Actualiza las dependencias para incluir Room.
  2. Anota las clases de modelo con @Entity, @PrimaryKey y @ColumnInfo.
  3. Crea DAO para reemplazar tus métodos de búsqueda auxiliares.
  4. Crea una clase RoomDatabase que haga referencia a tus entidades y DAO. Aumenta el número de versión.
  5. Define una ruta de migración vacía porque el esquema no cambia, solo el framework: kotlin val MIGRATION_1_2 = object : Migration(1, 2) { override fun migrate(database: SupportSQLiteDatabase) {} }
  6. Actualiza la instancia para usar Room.databaseBuilder con la ruta de migración.