Salvar dados em um banco de dados local usando o Room 2.x

Se o app processa quantidades não triviais de dados estruturados, a persistência desses dados localmente pode ser muito útil. O caso de uso mais comum é armazenar em cache partes importantes de dados para que, quando o dispositivo não puder acessar a rede, o usuário ainda consiga ter acesso a esse conteúdo off-line.

A biblioteca de persistência do Room oferece uma camada de abstração sobre o SQLite para permitir acesso fluente ao banco de dados, aproveitando toda a capacidade do SQLite.

Configurar o Room 2.x

Para usar o Room 2.x no app, adicione as dependências abaixo ao arquivo build.gradle do 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")
}

Principais componentes

O Room tem três componentes principais:

  • Classe de banco de dados que contém o banco de dados e serve como o ponto de acesso principal para a conexão com os dados persistidos do app.
  • Entidades de dados que representam tabelas no banco de dados do app.
  • Objetos de acesso a dados (DAOs, na sigla em inglês) que fornecem métodos que o app pode usar para consultar, atualizar, inserir e excluir dados do banco de dados.

A Figura 1 mostra a relação entre os diferentes componentes do Room.

Figura 1. Diagrama da arquitetura da biblioteca do Room.

Exemplo de implementação

// 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()

Definir dados usando entidades

Cada entidade do Room representa uma tabela no banco de dados. Defina cada entidade como uma classe anotada com @Entity.

@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
)
  • Nomes de tabelas e colunas personalizados: por padrão, o Room usa o nome da classe como nome da tabela e os nomes das propriedades como nomes das colunas. Para personalizá-los, use a propriedade tableName em @Entity e a anotação @ColumnInfo(name = "...").
  • Chave primária: para definir uma chave primária, use @PrimaryKey. Para chaves compostas, use a propriedade primaryKeys de @Entity: @Entity(primaryKeys = ["firstName", "lastName"]).
  • Ignorar campos: para impedir que os campos sejam persistidos, use @Ignore.

Conversores de tipo

Às vezes, é necessário armazenar tipos personalizados, como Date, em uma única coluna. Forneça métodos @TypeConverter para converter tipos personalizados em tipos que o Room pode persistir e vice-versa.

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() { ... }

Acessar dados com DAOs

Os DAOs definem métodos para interação com o banco de dados. Adicione a anotação @Dao à interface ou classe abstrata.

Métodos de conveniência

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

    @Update
    fun updateUsers(vararg users: User)

    @Delete
    fun deleteUsers(vararg users: User)
}
  • Inserir: o método de inserção pode retornar um Long que representa o ID da linha inserida ou uma List<Long> que contém os IDs de todas as linhas inseridas.
  • Atualizar ou excluir: o método de atualização ou exclusão pode retornar um Int que representa o número de linhas afetadas.

Métodos de consulta

Adicione a anotação @Query aos métodos para gravar instruções SQL. O Room valida consultas durante o tempo de compilação.

@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 retorno multimapa

No Room 2.4 e versões mais recentes, os métodos de consulta podem retornar um multimapa diretamente usando o tipo Map:

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

Consultas DAO assíncronas

Para evitar o congelamento da interface, as consultas de banco de dados não podem ser executadas na linha de execução principal. Faça consultas assíncronas usando uma das seguintes integrações:

Corrotinas e fluxo do Kotlin

Requer a dependência 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 com RxJava

Requer room-rxjava2 ou 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 com LiveData e Guava

Requer 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>
}

Definir relações no Room 2.x

Para evitar o carregamento lento na linha de execução da interface, não é possível usar referências diretas de objetos entre entidades. Em vez disso, defina relações usando classes de dados intermediárias com @Relation.

Um para um

Cada usuário tem apenas uma 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>

Um para muitos

Cada usuário pode ter muitas 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>
)

Muitos para muitos

As playlists podem ter muitas músicas, e as músicas podem estar em muitas playlists. Requer uma tabela de junção.

@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>
)

Relações aninhadas

Consulte os usuários, as playlists deles e todas as músicas dessas playlists.

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

Gerenciamento de bancos de dados

Esta seção aborda vários aspectos do gerenciamento do banco de dados do Room, incluindo visualizações de banco de dados, pré-preenchimento de dados e migrações de banco de dados.

Visualizações de banco de dados

Encapsule uma consulta complexa em uma classe anotada com @DatabaseView.

@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() { ... }

Pré-preencher o banco de dados

Preencha o banco de dados na inicialização de um arquivo de recurso ou do sistema de arquivos.

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

Migrações

Quando você muda o esquema, incrementa a versão do banco de dados e define um 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()
  • Migrações automatizadas: se você usa o Room 2.4.0 ou mais recente, pode usar @AutoMigration para migrar automaticamente mudanças básicas de esquema. Isso exige que você defina exportSchema como true na configuração do banco de dados: @Database(version = 2, autoMigrations = [AutoMigration(from = 1, to = 2)]).
  • Fallback destrutivo: se a perda de dados for aceitável quando os caminhos de migração estiverem ausentes, chame .fallbackToDestructiveMigration ao criar o banco de dados.

Testar migrações

Para verificar as migrações, use MigrationTestHelper do artefato room-testing. Para oferecer suporte a isso, exporte esquemas na configuração 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
    }
}

Migrar de SQLite para Room

Para migrar seu app do SQLite para o Room, siga estas etapas:

  1. Atualize as dependências para incluir o Room.
  2. Adicione anotações às classes de modelo com @Entity, @PrimaryKey e @ColumnInfo.
  3. Crie DAOs para substituir os métodos de consulta auxiliares.
  4. Crie uma classe RoomDatabase que referencie suas entidades e DAOs. Incremente o número da versão.
  5. Defina um caminho de migração vazio porque o esquema não muda, apenas o framework: kotlin val MIGRATION_1_2 = object : Migration(1, 2) { override fun migrate(database: SupportSQLiteDatabase) {} }
  6. Atualize a instanciação para usar Room.databaseBuilder com o caminho de migração.