Persistência local com Room em Kotlin: entidades, DAO, migrations e testes

Capítulo 11

Tempo estimado de leitura: 11 minutos

+ Exercício

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

Continue em nosso aplicativo e ...
  • Ouça o áudio com a tela desligada
  • Ganhe Certificado após a conclusão
  • + de 5000 cursos para você explorar!
ou continue lendo abaixo...
Download App

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 @Query para 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 Migration descrevendo 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)

ItemO que conferir
EntityChave primária, tipos corretos, defaults, índices quando necessário
DAOLeituras como Flow, escritas como suspend, queries validadas
DatabaseVersão correta, exportSchema, DAOs expostos
RepositoryAPI clara, mapeamento Entity↔modelo (se aplicável), sem SQL
MigrationsIncremento de versão + Migration registrada + SQL correto
TestesBanco em memória, asserts de insert/update/delete e queries

Agora responda o exercício sobre o conteúdo:

Ao adicionar uma nova coluna em uma tabela do Room sem perder os dados existentes, qual conjunto de passos é o mais adequado?

Você acertou! Parabéns, agora siga para a próxima página

Você errou! Tente novamente.

Para evoluir o schema sem apagar dados, é preciso refletir a mudança na Entity, aumentar a versão do banco e fornecer uma Migration com o SQL correspondente, registrando-a no builder.

Próximo capitúlo

Consumo de API com Retrofit em Kotlin: modelos, chamadas, interceptors e parsing

Arrow Right Icon
Capa do Ebook gratuito Android para Iniciantes com Kotlin: construindo seu primeiro app moderno
73%

Android para Iniciantes com Kotlin: construindo seu primeiro app moderno

Novo curso

15 páginas

Baixe o app para ganhar Certificação grátis e ouvir os cursos em background, mesmo com a tela desligada.