Neste tutorial completo, você vai aprender a usar o Room Database com Kotlin para persistência de dados no Android. Vamos cobrir desde a configuração inicial com Entity, DAO e Database até tópicos avançados como TypeConverters, migrations, integração com Flow, relacionamentos entre tabelas e testes de DAOs. O Room é a biblioteca oficial do Google para abstração do SQLite e é parte fundamental do Android Jetpack.
O que é o Room Database?
O Room é uma camada de abstração sobre o SQLite que facilita o acesso ao banco de dados local no Android. Ele oferece verificação de queries em tempo de compilação, integração nativa com Coroutines e Flow, e elimina grande parte do código boilerplate necessário para trabalhar com SQLite puro.
A arquitetura do Room se baseia em três componentes principais:
- Entity: representa uma tabela no banco de dados
- DAO (Data Access Object): contém os métodos para acessar o banco
- Database: classe abstrata que serve como ponto de entrada principal
Passo 1: Configurando as Dependências
Adicione as dependências no build.gradle.kts do módulo app:
plugins {
id("com.google.devtools.ksp") version "1.9.22-1.0.17"
}
dependencies {
val roomVersion = "2.6.1"
implementation("androidx.room:room-runtime:$roomVersion")
implementation("androidx.room:room-ktx:$roomVersion") // suporte a coroutines
ksp("androidx.room:room-compiler:$roomVersion")
// Para testes
testImplementation("androidx.room:room-testing:$roomVersion")
}
Note que usamos KSP (Kotlin Symbol Processing) em vez de KAPT para o processamento de anotações, pois o KSP é significativamente mais rápido.
Passo 2: Criando a Entity
A Entity é uma data class anotada que representa uma tabela no banco:
import androidx.room.Entity
import androidx.room.PrimaryKey
import androidx.room.ColumnInfo
@Entity(tableName = "tarefas")
data class Tarefa(
@PrimaryKey(autoGenerate = true)
val id: Long = 0,
@ColumnInfo(name = "titulo")
val titulo: String,
@ColumnInfo(name = "descricao")
val descricao: String = "",
@ColumnInfo(name = "concluida")
val concluida: Boolean = false,
@ColumnInfo(name = "data_criacao")
val dataCriacao: Long = System.currentTimeMillis(),
@ColumnInfo(name = "prioridade")
val prioridade: Int = 0
)
A anotação @Entity define a tabela, @PrimaryKey marca a chave primária e @ColumnInfo permite customizar o nome da coluna. Se o nome da propriedade e da coluna forem iguais, @ColumnInfo é opcional.
Passo 3: Criando o DAO
O DAO define os métodos de acesso ao banco. O Room gera automaticamente a implementação em tempo de compilação:
import androidx.room.*
import kotlinx.coroutines.flow.Flow
@Dao
interface TarefaDao {
// Retorna Flow para observar mudanças em tempo real
@Query("SELECT * FROM tarefas ORDER BY prioridade DESC, data_criacao DESC")
fun observarTodas(): Flow<List<Tarefa>>
// Query com parâmetro
@Query("SELECT * FROM tarefas WHERE id = :id")
suspend fun buscarPorId(id: Long): Tarefa?
// Filtro por status
@Query("SELECT * FROM tarefas WHERE concluida = :concluida")
fun observarPorStatus(concluida: Boolean): Flow<List<Tarefa>>
// Busca por texto
@Query("SELECT * FROM tarefas WHERE titulo LIKE '%' || :termo || '%'")
suspend fun buscarPorTitulo(termo: String): List<Tarefa>
// Inserção — retorna o ID gerado
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun inserir(tarefa: Tarefa): Long
// Inserção em lote
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun inserirTodas(tarefas: List<Tarefa>)
// Atualização
@Update
suspend fun atualizar(tarefa: Tarefa)
// Deleção
@Delete
suspend fun deletar(tarefa: Tarefa)
// Deleção por query
@Query("DELETE FROM tarefas WHERE concluida = 1")
suspend fun deletarConcluidas(): Int // retorna quantidade removida
// Contagem
@Query("SELECT COUNT(*) FROM tarefas WHERE concluida = 0")
fun contarPendentes(): Flow<Int>
}
Note que métodos que retornam Flow não precisam ser suspend — o Flow já é assíncrono por natureza. Métodos que fazem operações únicas (inserir, atualizar, deletar) devem ser marcados como suspend.
Passo 4: Criando a Classe Database
A classe Database é o ponto de entrada para o Room:
import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
@Database(
entities = [Tarefa::class],
version = 1,
exportSchema = true
)
abstract class AppDatabase : RoomDatabase() {
abstract fun tarefaDao(): TarefaDao
companion object {
@Volatile
private var INSTANCE: AppDatabase? = null
fun getInstance(context: Context): AppDatabase {
return INSTANCE ?: synchronized(this) {
val instance = Room.databaseBuilder(
context.applicationContext,
AppDatabase::class.java,
"app_database"
).build()
INSTANCE = instance
instance
}
}
}
}
O padrão Singleton garante que apenas uma instância do banco exista durante o ciclo de vida do app. O @Volatile assegura que a variável seja visível em todas as threads.
Passo 5: TypeConverters para Tipos Complexos
O Room só suporta tipos primitivos e String nativamente. Para tipos complexos como Date, List ou enums, usamos TypeConverters:
import androidx.room.TypeConverter
import java.util.Date
class Converters {
@TypeConverter
fun fromTimestamp(value: Long?): Date? {
return value?.let { Date(it) }
}
@TypeConverter
fun dateToTimestamp(date: Date?): Long? {
return date?.time
}
@TypeConverter
fun fromStringList(value: String?): List<String> {
return value?.split(",")?.map { it.trim() } ?: emptyList()
}
@TypeConverter
fun stringListToString(list: List<String>): String {
return list.joinToString(",")
}
}
Registre os converters na classe Database:
@Database(entities = [Tarefa::class], version = 1)
@TypeConverters(Converters::class)
abstract class AppDatabase : RoomDatabase() {
abstract fun tarefaDao(): TarefaDao
// ...
}
Passo 6: Migrations — Evoluindo o Esquema do Banco
Quando você precisa alterar a estrutura do banco (adicionar coluna, criar tabela), é necessário criar uma migration para não perder os dados dos usuários:
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 tarefas ADD COLUMN categoria TEXT NOT NULL DEFAULT ''")
}
}
val MIGRATION_2_3 = object : Migration(2, 3) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("""
CREATE TABLE IF NOT EXISTS categorias (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
nome TEXT NOT NULL,
cor TEXT NOT NULL DEFAULT '#000000'
)
""".trimIndent())
}
}
// Aplique as migrations ao criar o banco:
Room.databaseBuilder(context, AppDatabase::class.java, "app_database")
.addMigrations(MIGRATION_1_2, MIGRATION_2_3)
.build()
Passo 7: Relacionamentos entre Tabelas
O Room suporta relacionamentos 1:1, 1:N e N:N através de anotações como @Embedded e @Relation:
Relacionamento 1:N (um usuário tem muitas tarefas):
@Entity(tableName = "usuarios")
data class Usuario(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val nome: String
)
@Entity(
tableName = "tarefas",
foreignKeys = [ForeignKey(
entity = Usuario::class,
parentColumns = ["id"],
childColumns = ["usuario_id"],
onDelete = ForeignKey.CASCADE
)]
)
data class Tarefa(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val titulo: String,
@ColumnInfo(name = "usuario_id") val usuarioId: Long
)
// Classe intermediária para o resultado da consulta
data class UsuarioComTarefas(
@Embedded val usuario: Usuario,
@Relation(
parentColumn = "id",
entityColumn = "usuario_id"
)
val tarefas: List<Tarefa>
)
// No DAO:
@Transaction
@Query("SELECT * FROM usuarios WHERE id = :id")
suspend fun buscarUsuarioComTarefas(id: Long): UsuarioComTarefas?
Relacionamento N:N (tarefas com múltiplas tags):
@Entity(tableName = "tags")
data class Tag(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val nome: String
)
@Entity(
tableName = "tarefa_tag",
primaryKeys = ["tarefaId", "tagId"]
)
data class TarefaTagCrossRef(
val tarefaId: Long,
val tagId: Long
)
data class TarefaComTags(
@Embedded val tarefa: Tarefa,
@Relation(
parentColumn = "id",
entityColumn = "id",
associateBy = Junction(
value = TarefaTagCrossRef::class,
parentColumn = "tarefaId",
entityColumn = "tagId"
)
)
val tags: List<Tag>
)
Passo 8: Integração com Flow para Dados Reativos
A integração com Flow permite que a UI seja atualizada automaticamente quando os dados mudam:
class TarefaRepository(private val dao: TarefaDao) {
val todasTarefas: Flow<List<Tarefa>> = dao.observarTodas()
val pendentes: Flow<Int> = dao.contarPendentes()
suspend fun adicionar(tarefa: Tarefa) = dao.inserir(tarefa)
suspend fun concluir(tarefa: Tarefa) = dao.atualizar(tarefa.copy(concluida = true))
suspend fun remover(tarefa: Tarefa) = dao.deletar(tarefa)
}
// No ViewModel:
class TarefaViewModel(private val repository: TarefaRepository) : ViewModel() {
val tarefas: StateFlow<List<Tarefa>> = repository.todasTarefas
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
fun adicionarTarefa(titulo: String) {
viewModelScope.launch {
repository.adicionar(Tarefa(titulo = titulo))
}
}
}
Passo 9: Testando os DAOs
O Room fornece ferramentas para testes usando banco de dados em memória:
import androidx.room.Room
import androidx.test.core.app.ApplicationProvider
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Before
import org.junit.Test
import org.junit.Assert.*
class TarefaDaoTest {
private lateinit var database: AppDatabase
private lateinit var dao: TarefaDao
@Before
fun setup() {
database = Room.inMemoryDatabaseBuilder(
ApplicationProvider.getApplicationContext(),
AppDatabase::class.java
).allowMainThreadQueries().build()
dao = database.tarefaDao()
}
@After
fun tearDown() {
database.close()
}
@Test
fun inserirEBuscarTarefa() = runTest {
val tarefa = Tarefa(titulo = "Estudar Room", descricao = "Tutorial completo")
val id = dao.inserir(tarefa)
val resultado = dao.buscarPorId(id)
assertNotNull(resultado)
assertEquals("Estudar Room", resultado?.titulo)
}
@Test
fun observarTarefasRetornaFlowAtualizado() = runTest {
dao.inserir(Tarefa(titulo = "Tarefa 1"))
dao.inserir(Tarefa(titulo = "Tarefa 2"))
val tarefas = dao.observarTodas().first()
assertEquals(2, tarefas.size)
}
@Test
fun deletarConcluidas() = runTest {
dao.inserir(Tarefa(titulo = "Pendente", concluida = false))
dao.inserir(Tarefa(titulo = "Feita", concluida = true))
val removidas = dao.deletarConcluidas()
assertEquals(1, removidas)
val restantes = dao.observarTodas().first()
assertEquals(1, restantes.size)
assertEquals("Pendente", restantes[0].titulo)
}
}
Erros Comuns
Acessar o banco na main thread: O Room bloqueia operações na thread principal por padrão. Use coroutines (funções
suspend) ou Flow para acesso assíncrono. Nunca useallowMainThreadQueries()em produção.Esquecer
@Transactionem queries com relacionamentos: Consultas que retornam objetos com@Relationdevem ser anotadas com@Transactionpara garantir consistência dos dados.Não criar migrations ao alterar o esquema: Se você mudar a versão do banco sem fornecer uma migration, o app vai crashar. Use
fallbackToDestructiveMigration()apenas durante o desenvolvimento.Passar a mesma instância de lista ao Flow: O Room já cuida da reatividade. Não tente contornar o sistema criando seus próprios mecanismos de notificação.
Ignorar
exportSchema = true: Habilitar a exportação do schema permite que o Room gere arquivos JSON que são essenciais para validar migrations automaticamente nos testes.
Conclusão e Próximos Passos
Neste tutorial, você aprendeu a utilizar o Room Database com Kotlin de forma completa: desde a criação de Entities, DAOs e a classe Database, passando por TypeConverters, migrations para evolução do schema, relacionamentos 1:1, 1:N e N:N, integração reativa com Flow, até testes automatizados dos DAOs.
Como próximos passos, recomendamos:
- Integrar o Room com a arquitetura MVVM para uma separação de responsabilidades clara
- Combinar Room com Retrofit para sincronizar dados locais e remotos
- Explorar o Kotlin Flow para técnicas avançadas de observação de dados
- Consultar o glossário de coroutine e data class para reforçar os conceitos utilizados
O Room é uma peça essencial em qualquer app Android moderno e dominá-lo vai melhorar significativamente a qualidade dos seus projetos.