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

  1. 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 use allowMainThreadQueries() em produção.

  2. Esquecer @Transaction em queries com relacionamentos: Consultas que retornam objetos com @Relation devem ser anotadas com @Transaction para garantir consistência dos dados.

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

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

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