O que é o Room e por que usar
Room é a biblioteca oficial do Android para persistência local baseada em SQLite. Ela adiciona uma camada de abstração que reduz código repetitivo e aumenta a segurança em tempo de compilação, principalmente ao mapear tabelas para classes Kotlin e ao validar consultas SQL. Em um app moderno, o Room normalmente fica na camada de dados, acessado por um Repository, e expõe dados como Flow para a UI observar mudanças automaticamente.
Componentes principais
- Entity: representa uma tabela (colunas = propriedades).
- DAO (Data Access Object): interface com métodos de consulta/insert/update/delete.
- Database: classe abstrata que conecta Entities e DAOs e define versão/migrations.
- Migrations: descrevem como evoluir o schema sem perder dados.
Dependências e configuração do projeto
Adicione as dependências do Room e habilite o KSP (recomendado) para geração de código. Ajuste as versões conforme seu projeto.
// build.gradle (Module: app) - trechos relevantes
plugins {
id("com.google.devtools.ksp")
}
dependencies {
val roomVersion = "2.6.1"
implementation("androidx.room:room-runtime:$roomVersion")
ksp("androidx.room:room-compiler:$roomVersion")
// Kotlin extensions + Coroutines/Flow
implementation("androidx.room:room-ktx:$roomVersion")
// Testes
testImplementation("junit:junit:4.13.2")
androidTestImplementation("androidx.test.ext:junit:1.1.5")
androidTestImplementation("androidx.test:core:1.5.0")
androidTestImplementation("androidx.room:room-testing:$roomVersion")
}Se seu projeto usa kapt em vez de KSP, substitua ksp(...) por kapt(...) e aplique o plugin kotlin-kapt. Em projetos novos, prefira KSP.
Prática: CRUD de Lista de Tarefas (Task)
Vamos criar uma funcionalidade de tarefas com operações de CRUD e observação via Flow. O objetivo é: inserir tarefas, listar/observar, marcar como concluída, editar título e remover.
1) Criando a Entity
Uma Entity define a tabela. Use @PrimaryKey com autoGenerate para IDs. Defina índices quando fizer sentido (por exemplo, para busca por título).
- Ouça o áudio com a tela desligada
- Ganhe Certificado após a conclusão
- + de 5000 cursos para você explorar!
Baixar o aplicativo
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 = 0,
val title: String,
val isDone: Boolean = false,
val createdAt: Long = System.currentTimeMillis()
)2) Criando o DAO (consultas e operações)
O DAO concentra o acesso ao banco. Boas práticas comuns:
- Consultas que alimentam a UI: retornar
Flow<List<...>>para observar mudanças. - Operações de escrita: usar
suspend(Room executa fora da main thread quando usando coroutines). - Atualizações pontuais: usar
@Querypara atualizar apenas um campo pode ser mais eficiente do que@Update.
import androidx.room.Dao
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 createdAt DESC")
fun observeAll(): Flow<List<TaskEntity>>
@Query("SELECT * FROM tasks WHERE id = :id")
suspend fun getById(id: Long): TaskEntity?
@Insert(onConflict = OnConflictStrategy.ABORT)
suspend fun insert(task: TaskEntity): Long
@Update
suspend fun update(task: TaskEntity)
@Query("UPDATE tasks SET isDone = :done WHERE id = :id")
suspend fun setDone(id: Long, done: Boolean)
@Query("UPDATE tasks SET title = :title WHERE id = :id")
suspend fun rename(id: Long, title: String)
@Query("DELETE FROM tasks WHERE id = :id")
suspend fun deleteById(id: Long)
@Query("DELETE FROM tasks")
suspend fun deleteAll()
}3) Criando a classe Database
A classe RoomDatabase define as entidades, a versão do schema e expõe os DAOs. A versão deve ser incrementada sempre que você alterar a estrutura do banco (tabelas/colunas/índices).
import androidx.room.Database
import androidx.room.RoomDatabase
@Database(
entities = [TaskEntity::class],
version = 1,
exportSchema = true
)
abstract class AppDatabase : RoomDatabase() {
abstract fun taskDao(): TaskDao
}exportSchema = true ajuda a versionar o schema (útil para migrations). Em projetos reais, configure o diretório de schemas no Gradle para manter os arquivos gerados.
4) Criando o provider do banco (singleton)
Você deve criar o banco uma única vez. Uma abordagem simples é um objeto que cria a instância usando Room.databaseBuilder.
import android.content.Context
import androidx.room.Room
object DatabaseProvider {
@Volatile private var INSTANCE: AppDatabase? = null
fun get(context: Context): AppDatabase {
return INSTANCE ?: synchronized(this) {
val instance = Room.databaseBuilder(
context.applicationContext,
AppDatabase::class.java,
"app.db"
)
// Em produção, evite allowMainThreadQueries.
.build()
INSTANCE = instance
instance
}
}
}Operações com Coroutines e Flow
Leitura reativa: observando mudanças com Flow
Quando um método do DAO retorna Flow, o Room reexecuta a query automaticamente quando a tabela observada muda (insert/update/delete). Isso é ideal para listas na UI.
// Exemplo de uso (camada de dados ou ViewModel)
val tasksFlow: Flow<List<TaskEntity>> = taskDao.observeAll()Escrita com suspend: inserindo/atualizando/removendo
Operações de escrita devem ser suspend e chamadas dentro de uma coroutine (por exemplo, no escopo do ViewModel). O Room KTX integra com coroutines e executa as queries fora da main thread.
// Exemplo de chamada
suspend fun addTask(title: String) {
taskDao.insert(TaskEntity(title = title))
}
suspend fun toggleDone(id: Long, done: Boolean) {
taskDao.setDone(id, done)
}Transações (quando várias operações precisam ser atômicas)
Se você precisar executar múltiplas operações como uma unidade atômica, use @Transaction em um método do DAO ou withTransaction no RoomDatabase.
import androidx.room.Transaction
@Dao
interface TaskDaoWithTx {
@Transaction
suspend fun replaceAll(tasks: List<TaskEntity>) {
deleteAll()
tasks.forEach { insert(it) }
}
@Insert suspend fun insert(task: TaskEntity): Long
@Query("DELETE FROM tasks") suspend fun deleteAll()
}Relacionamentos básicos no Room
Relacionamentos são modelados com chaves e classes auxiliares. Um exemplo comum é 1:N: uma lista (ou projeto) tem várias tarefas.
Exemplo: Project (1) → Task (N)
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
@Entity(tableName = "projects")
data class ProjectEntity(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val name: String
)
@Entity(
tableName = "tasks",
foreignKeys = [
ForeignKey(
entity = ProjectEntity::class,
parentColumns = ["id"],
childColumns = ["projectId"],
onDelete = ForeignKey.CASCADE
)
],
indices = [Index("projectId")]
)
data class TaskEntity(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val projectId: Long,
val title: String,
val isDone: Boolean = false,
val createdAt: Long = System.currentTimeMillis()
)Para carregar um projeto com suas tarefas, use @Relation em uma classe de resultado e um método @Transaction:
import androidx.room.Embedded
import androidx.room.Relation
data class ProjectWithTasks(
@Embedded val project: ProjectEntity,
@Relation(
parentColumn = "id",
entityColumn = "projectId"
)
val tasks: List<TaskEntity>
)import androidx.room.Dao
import androidx.room.Query
import androidx.room.Transaction
import kotlinx.coroutines.flow.Flow
@Dao
interface ProjectDao {
@Transaction
@Query("SELECT * FROM projects WHERE id = :id")
fun observeProjectWithTasks(id: Long): Flow<ProjectWithTasks?>
}Use @Transaction para garantir consistência ao montar o objeto com relação.
Integração com Repository
O Repository centraliza o acesso ao DAO e oferece uma API mais amigável para a camada de apresentação. Ele também é um bom lugar para mapear Entity em modelos de domínio, se você estiver usando essa separação.
Definindo um modelo simples (opcional)
data class Task(
val id: Long,
val title: String,
val isDone: Boolean,
val createdAt: Long
)
fun TaskEntity.toDomain() = Task(id, title, isDone, createdAt)
fun Task.toEntity() = TaskEntity(id, title, isDone, createdAt)Repository usando Flow e suspend
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
class TaskRepository(
private val dao: TaskDao
) {
fun observeTasks(): Flow<List<Task>> =
dao.observeAll().map { list -> list.map { it.toDomain() } }
suspend fun add(title: String) {
dao.insert(TaskEntity(title = title))
}
suspend fun rename(id: Long, title: String) {
dao.rename(id, title)
}
suspend fun setDone(id: Long, done: Boolean) {
dao.setDone(id, done)
}
suspend fun delete(id: Long) {
dao.deleteById(id)
}
}Esse padrão facilita testes e reduz acoplamento: a UI não precisa conhecer SQL nem detalhes do Room.
Migrations e versionamento do schema
Quando você altera o schema (por exemplo, adiciona uma coluna), você deve:
- Incrementar a versão em
@Database(version = ...). - Criar um objeto
Migrationdescrevendo as alterações SQL. - Registrar a migration no
databaseBuilder.
Exemplo prático: adicionando coluna priority em tasks
Suponha que você quer adicionar uma prioridade inteira (0 a 2) com valor padrão 0.
Passo 1: atualizar a Entity
@Entity(tableName = "tasks")
data class TaskEntity(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val title: String,
val isDone: Boolean = false,
val createdAt: Long = System.currentTimeMillis(),
val priority: Int = 0
)Passo 2: incrementar a versão do Database
@Database(
entities = [TaskEntity::class],
version = 2,
exportSchema = true
)
abstract class AppDatabase : RoomDatabase() {
abstract fun taskDao(): TaskDao
}Passo 3: criar a Migration 1→2
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")
}
}Passo 4: registrar a migration no builder
val instance = Room.databaseBuilder(
context.applicationContext,
AppDatabase::class.java,
"app.db"
)
.addMigrations(MIGRATION_1_2)
.build()Quando usar fallbackToDestructiveMigration (e quando evitar)
fallbackToDestructiveMigration() recria o banco ao invés de migrar, apagando dados. É aceitável em protótipos ou caches, mas não em dados do usuário. Em apps reais, prefira migrations explícitas.
Migrations mais complexas: criando nova tabela e copiando dados
Algumas mudanças não são possíveis com ALTER TABLE simples (por exemplo, remover coluna). A estratégia comum é: criar tabela nova, copiar dados, dropar tabela antiga e renomear.
val MIGRATION_2_3 = object : Migration(2, 3) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL(
"CREATE TABLE IF NOT EXISTS tasks_new (" +
"id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " +
"title TEXT NOT NULL, " +
"isDone INTEGER NOT NULL, " +
"createdAt INTEGER NOT NULL, " +
"priority INTEGER NOT NULL DEFAULT 0" +
")"
)
db.execSQL(
"INSERT INTO tasks_new (id, title, isDone, createdAt, priority) " +
"SELECT id, title, isDone, createdAt, priority FROM tasks"
)
db.execSQL("DROP TABLE tasks")
db.execSQL("ALTER TABLE tasks_new RENAME TO tasks")
}
}Consultas úteis: filtros, busca e paginação simples
Você pode criar queries específicas para a tela, mantendo-as no DAO.
@Query("SELECT * FROM tasks WHERE isDone = 0 ORDER BY createdAt DESC")
fun observePending(): Flow<List<TaskEntity>>
@Query("SELECT * FROM tasks WHERE title LIKE '%' || :query || '%' ORDER BY createdAt DESC")
fun observeSearch(query: String): Flow<List<TaskEntity>>
@Query("SELECT * FROM tasks ORDER BY createdAt DESC LIMIT :limit OFFSET :offset")
suspend fun getPage(limit: Int, offset: Int): List<TaskEntity>Para paginação robusta, normalmente se usa Paging 3, mas uma paginação simples com LIMIT/OFFSET já ajuda a entender o conceito.
Teste básico de DAO com banco em memória
Testar o DAO garante que suas queries e migrations funcionam. A abordagem mais direta é criar um banco em memória com Room.inMemoryDatabaseBuilder e executar operações reais.
1) Preparando o teste
Este exemplo usa teste instrumentado (androidTest), pois o Room depende de APIs Android. Você pode rodar no emulador/dispositivo.
import android.content.Context
import androidx.room.Room
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class TaskDaoTest {
private lateinit var db: AppDatabase
private lateinit var dao: TaskDao
@Before
fun setup() {
val context = ApplicationProvider.getApplicationContext<Context>()
db = Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java)
.allowMainThreadQueries() // apenas em testes
.build()
dao = db.taskDao()
}
@After
fun tearDown() {
db.close()
}
@Test
fun insert_and_getById_returnsTask() = kotlinx.coroutines.runBlocking {
val id = dao.insert(TaskEntity(title = "Estudar Room"))
val loaded = dao.getById(id)
assertNotNull(loaded)
assertEquals("Estudar Room", loaded?.title)
assertEquals(false, loaded?.isDone)
}
@Test
fun setDone_updatesField() = kotlinx.coroutines.runBlocking {
val id = dao.insert(TaskEntity(title = "Fazer CRUD"))
dao.setDone(id, true)
val loaded = dao.getById(id)
assertEquals(true, loaded?.isDone)
}
}2) Dica: testando Flow (noções)
Testar Flow costuma exigir coletar valores e controlar o tempo. Uma forma simples é coletar o primeiro valor com first() após inserir dados. Para cenários mais avançados, use bibliotecas de teste de Flow (ex.: Turbine) e dispatchers de teste.
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Test
@Test
fun observeAll_emitsInsertedItems() = runBlocking {
dao.insert(TaskEntity(title = "T1"))
dao.insert(TaskEntity(title = "T2"))
val list = dao.observeAll().first()
assertEquals(2, list.size)
}Checklist de implementação (para sua prática)
| Item | O que conferir |
|---|---|
| Entity | Chave primária, tipos corretos, defaults, índices quando necessário |
| DAO | Leituras como Flow, escritas como suspend, queries validadas |
| Database | Versão correta, exportSchema, DAOs expostos |
| Repository | API clara, mapeamento Entity↔modelo (se aplicável), sem SQL |
| Migrations | Incremento de versão + Migration registrada + SQL correto |
| Testes | Banco em memória, asserts de insert/update/delete e queries |