Room com Kotlin e Jetpack Compose: criando uma aplicação Android

Ao desenvolver uma aplicação Android, frequentemente precisamos armazenar informações localmente no dispositivo. Embora seja possível utilizar diretamente a API SQLite, o Android oferece o Room como uma camada de abstração que simplifica o acesso ao banco de dados e reduz a quantidade de código necessário.

Neste artigo veremos como utilizar o Room com Kotlin e Jetpack Compose, organizando a aplicação em camadas.

O objetivo não é apenas mostrar como criar uma tabela, mas compreender como as diferentes partes da aplicação se relacionam:

Compose → ViewModel → Repository → DAO → Room → SQLite

Também veremos o papel da classe Application, da MainActivity e da ViewModelFactory.

A interface Jetpack Compose não acessa diretamente o banco de dados. Ela se comunica com o ViewModel, que utiliza o Repository. O Repository acessa o DAO, e o DAO utiliza o Room para realizar as operações sobre o SQLite.

Este tutorial utiliza Room 2.7.2.

O que vamos construir

Para tornar os conceitos mais claros, construiremos uma pequena aplicação capaz de cadastrar, consultar, alterar e excluir usuários.

A aplicação utilizará:

  • Kotlin
  • Jetpack Compose
  • Room
  • ViewModel
  • Repository
  • DAO
  • Flow
  • MVVM

2. Estrutura de arquivos

A UserViewModelFactory faz parte da camada ViewModel e é responsável por fornecer o UserRepository ao UserViewModel quando ele é criado pela MainActivity.

com.example.app

app/
│
├── data/
│   ├── database/
│   │   ├── AppDatabase.kt
│   │   └── UserDao.kt
│   │
│   ├── entity/
│   │   └── User.kt
│   │
│   └── repository/
│       └── UserRepository.kt
│
├── viewmodel/
│   ├── UserViewModel.kt
│   └── UserViewModelFactory.kt
│
├── ui/
│   └── UserScreen.kt
│
├── App.kt
└── MainActivity.kt



 

 

3. Dependências do Room

No app/build.gradle.kts:

plugins {
id(“com.android.application”)
id(“org.jetbrains.kotlin.android”)
id(“com.google.devtools.ksp”)
}

dependencies {
val roomVersion = “2.7.2”

implementation(“androidx.room:room-runtime:$roomVersion”)
implementation(“androidx.room:room-ktx:$roomVersion”)
ksp(“androidx.room:room-compiler:$roomVersion”)
}

 

room-runtime

implementation("androidx.room:room-runtime:$roomVersion")
É a biblioteca principal do Room.

Ela fornece as classes e APIs necessárias para que a aplicação utilize o Room em tempo de execução.

Em outras palavras:

room-runtime é o núcleo do Room que será utilizado pela aplicação.


room-ktx

implementation("androidx.room:room-ktx:$roomVersion")

Fornece a integração do Room com recursos do Kotlin, especialmente Coroutines e Flow.

Isso é particularmente importante neste tutorial porque nosso DAO utiliza:

suspend fun insert(...)

e:

fun getAll(): Flow<List<UserEntity>>

Neste projeto, room-ktx é importante porque utilizaremos as funcionalidades de Kotlin para trabalhar de forma assíncrona e observar alterações no banco através de Flow.

room-compiler

ksp("androidx.room:room-compiler:$roomVersion")
Essa talvez seja a dependência que mais precisa de explicação.

O Room utiliza anotações como:

@Entity
@Dao
@Database
@Query
@Insert
@Update
@Delete

Mas essas anotações, sozinhas, não executam as operações.

O Room Compiler analisa essas classes e anotações durante a compilação e gera o código necessário para implementar o funcionamento do Room.

Por isso usamos:

ksp(...)

em vez de:

implementation(...)

room-compiler não é uma biblioteca utilizada diretamente pelo aplicativo em tempo de execução. Ele é utilizado durante a compilação para gerar o código do Room.


 KSP

id("com.google.devtools.ksp")

O KSP (Kotlin Symbol Processing) é utilizado pelo Room para processar as anotações das classes durante a compilação e gerar automaticamente o código necessário.

@Entity
@Dao
@Query
@Insert
     │
     ▼
    KSP
     │
     ▼
Room Compiler
     │
     ▼
Código gerado

 

4. Entity

Arquivo: data/entity/UserEntity.kt

package com.example.app.data.entity

import androidx.room.Entity
import androidx.room.PrimaryKey

@Entity(tableName = “users”)
data class UserEntity(
@PrimaryKey(autoGenerate = true)
val id: Long = 0,
val name: String,
val email: String
)

Importante destacar:

  1. @Entity informa ao Room que a classe representa uma tabela.
  2. Cada propriedade da classe representa uma coluna.
  3. @PrimaryKey define a chave primária.

Por exemplo, o  código é:

@Entity(tableName = "users")
data class UserEntity(
    @PrimaryKey(autoGenerate = true)
    val id: Long = 0,

    val name: String,
    val email: String
)

Isso é equivalente a :

UserEntity
    │
    │ @Entity
    ▼
Tabela: users
    │
    ├── id       → chave primária
    ├── name     → coluna
    └── email    → coluna

Identificador único id:

@PrimaryKey(autoGenerate = true)
val id: Long = 0

“Se o id começa em zero, como o Room sabe qual número usar?”

Explicação curta:

Com autoGenerate = true, o identificador é gerado automaticamente pelo banco quando um novo registro é inserido. O valor 0 é utilizado no objeto Kotlin para indicar que ainda não existe um ID atribuído àquele registro.

Nome da tabela:

@Entity(tableName = "users")
vale explicar:

tableName = "users" define explicitamente o nome da tabela no banco. Se o nome da tabela não fosse informado, o Room utilizaria o nome da classe como referência para a tabela.

UserEntity é o nome da classe Kotlin, enquanto users é o nome da tabela no banco de dados.


 

5. DAO

O DAO (Data Access Object) é a interface responsável por definir as operações que a aplicação pode realizar sobre os dados armazenados pelo Room.

Enquanto a Entity define como os dados são representados no banco, o DAO define como esses dados serão consultados, inseridos, alterados ou excluídos.

Para a nossa UserEntity, podemos criar o seguinte DAO:

@Dao
interface UserDao {

    @Query("SELECT * FROM users ORDER BY name")
    fun getAll(): Flow<List<UserEntity>>

    @Insert
    suspend fun insert(user: UserEntity)

    @Update
    suspend fun update(user: UserEntity)

    @Delete
    suspend fun delete(user: UserEntity)
}

@Dao

A anotação:

@Dao

informa ao Room que essa interface contém as operações de acesso ao banco de dados.

O Room analisa essas definições durante a compilação e gera automaticamente a implementação necessária.

Consultando os dados

A consulta:

@Query("SELECT * FROM users ORDER BY name")
fun getAll(): Flow<List<UserEntity>>

utiliza SQL para selecionar todos os registros da tabela users, ordenando-os pelo nome.

O retorno é um:

Flow<List<UserEntity>>

O Flow é importante porque permite observar as alterações realizadas nos dados.

Quando os registros observados pela consulta são alterados, o Room pode emitir uma nova lista através do Flow.

Podemos representar esse fluxo assim:

Banco de dados
      │
      ▼
     Room
      │
      ▼
     DAO
      │
      ▼
Flow<List<UserEntity>>
      │
      ▼
   ViewModel
      │
      ▼
Jetpack Compose

Dessa forma, a interface pode receber automaticamente as alterações dos dados sem precisar consultar o banco novamente a cada mudança.

Inserindo dados

Para inserir um usuário utilizamos:

@Insert
suspend fun insert(user: UserEntity)

A anotação @Insert informa ao Room que o método deve inserir um registro na tabela correspondente à UserEntity.

O modificador suspend permite que a operação seja executada utilizando Kotlin Coroutines, evitando que uma operação de banco de dados seja realizada diretamente na thread principal da interface.

Atualizando dados

Para alterar um registro existente:

@Update
suspend fun update(user: UserEntity)

O Room utiliza a chave primária da entidade para identificar o registro que deve ser atualizado.

Excluindo dados

Para excluir um registro:

@Delete
suspend fun delete(user: UserEntity)

O Room utiliza a entidade recebida para identificar o registro que deverá ser removido.

O papel do DAO na arquitetura

O DAO é a camada que conhece as operações do banco de dados.

A interface da aplicação, por outro lado, não precisa conhecer SQL:

Jetpack Compose
       │
       ▼
   ViewModel
       │
       ▼
  Repository
       │
       ▼
      DAO
       │
       ▼
      Room
       │
       ▼
     SQLite

Assim, cada componente possui uma responsabilidade bem definida:

  • Entity: representa os dados armazenados.
  • DAO: define as operações sobre esses dados.
  • Room: executa essas operações e gerencia o acesso ao SQLite.

Em resumo: a Entity descreve a estrutura dos dados e o DAO descreve as operações que podem ser realizadas sobre eles. O Room utiliza essas informações para gerar o código necessário para acessar o banco de dados.

 

Arquivo: data/dao/UserDao.kt

package com.example.app.data.dao

import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.Query
import androidx.room.Update
import com.example.app.data.entity.UserEntity
import kotlinx.coroutines.flow.Flow

@Dao
interface UserDao {

@Insert
suspend fun insert(user: UserEntity)

@Update
suspend fun update(user: UserEntity)

@Delete
suspend fun delete(user: UserEntity)

@Query(“SELECT * FROM users ORDER BY name”)
fun getAll(): Flow<List<UserEntity>>
}

O DAO possui as operações básicas de inserir, atualizar, deletar e listar. O Flow permite que a interface receba automaticamente as alterações do banco.

6. Database

A classe AppDatabase representa o banco de dados da aplicação no Room.

Ela deve herdar de RoomDatabase e utilizar a anotação @Database, informando quais Entitys fazem parte do banco e qual é a versão atual do banco de dados.

Para o nosso exemplo:

@Database(
    entities = [UserEntity::class],
    version = 1,
    exportSchema = false
)
abstract class AppDatabase : RoomDatabase() {

    abstract fun userDao(): UserDao
}

@Database

A anotação:

@Database(
    entities = [UserEntity::class],
    version = 1,
    exportSchema = false
)

informa ao Room as principais características do banco.

O parâmetro entities indica quais entidades fazem parte do banco de dados:

entities = [UserEntity::class]

Neste caso, a aplicação possui apenas a tabela users, representada pela UserEntity.

Em uma aplicação com várias tabelas, podemos informar várias entidades:

entities = [
    UserEntity::class,
    ProductEntity::class,
    OrderEntity::class
]

Versão do banco

O parâmetro:

version = 1

representa a versão do esquema do banco de dados.

Quando a estrutura do banco for modificada em uma versão futura da aplicação, essa versão deverá ser incrementada e será necessário definir como os dados existentes serão migrados.

Neste exemplo estamos começando com a versão 1.

exportSchema

Utilizamos:

exportSchema = false

para não exportar o esquema do banco durante a compilação deste exemplo.

Em projetos maiores, especialmente quando são utilizadas migrações e controle da evolução do banco, pode ser interessante manter a exportação do schema habilitada.

O DAO fornecido pelo Database

A classe também declara:

abstract fun userDao(): UserDao

Isso permite que outras partes da aplicação obtenham o DAO através do banco:

AppDatabase
     │
     └── userDao()
             │
             ▼
          UserDao

O AppDatabase funciona, portanto, como o ponto central de acesso aos DAOs da aplicação.

Como Entity, DAO e Database se relacionam?

Até aqui temos três componentes fundamentais:

             Room Database
                  │
          ┌───────┴────────┐
          │                │
       Entity             DAO
          │                │
          ▼                ▼
     "O que é?"       "O que fazer?"
          │                │
          ▼                ▼
     Tabela users     SELECT / INSERT
                      UPDATE / DELETE

A Entity descreve a estrutura dos dados, o DAO define as operações que podem ser realizadas e o AppDatabase reúne essas informações para que o Room possa gerenciar o banco de dados.

O AppDatabase não é criado diretamente pela classe

A classe AppDatabase é abstrata:

abstract class AppDatabase : RoomDatabase()

Não criamos uma instância utilizando AppDatabase().

A instância será criada pelo próprio Room utilizando um databaseBuilder, como veremos no próximo passo, quando configurarmos o banco na classe App.

 

Arquivo: data/database/AppDatabase.kt

package com.example.app.data.database

import androidx.room.Database
import androidx.room.RoomDatabase
import com.example.app.data.dao.UserDao
import com.example.app.data.entity.UserEntity

@Database(
entities = [UserEntity::class],
version = 1,
exportSchema = false
)
abstract class AppDatabase : RoomDatabase() {

abstract fun userDao(): UserDao
}

7. App — a Application da aplicação

Agora que criamos o AppDatabase, precisamos criar uma instância desse banco para que as demais partes da aplicação possam utilizá-lo.

Para isso, vamos criar uma classe chamada App, que herda de Application.

class App : Application() {

    val database: AppDatabase by lazy {
        Room.databaseBuilder(
            applicationContext,
            AppDatabase::class.java,
            "app_database"
        ).build()
    }
}

O que é a classe Application?

Application é uma classe fornecida pelo Android que representa o estado global da aplicação enquanto o processo da aplicação estiver em execução.

Ao criar:

class App : Application()

estamos criando uma classe personalizada de Application para o nosso aplicativo.

Neste exemplo, vamos utilizá-la para disponibilizar a instância do AppDatabase.

Criando o banco de dados

A instância do banco é criada através de:

Room.databaseBuilder(
    applicationContext,
    AppDatabase::class.java,
    "app_database"
).build()

O databaseBuilder() recebe:

  • applicationContext — o contexto da aplicação;
  • AppDatabase::class.java — a classe que representa nosso banco;
  • "app_database" — o nome do arquivo do banco de dados.

O resultado é uma instância de AppDatabase.

Podemos visualizar essa relação:

             App
              │
              ▼
        AppDatabase
              │
              ▼
           UserDao

Por que utilizar by lazy?

Utilizamos:

val database: AppDatabase by lazy {
    ...
}

para que o banco seja criado somente quando a propriedade database for utilizada pela primeira vez.

Depois de criada, a mesma instância é reutilizada enquanto o objeto App permanecer em memória.

Isso evita a criação desnecessária de várias instâncias do RoomDatabase.

Por que o banco fica na App?

A MainActivity não deve ser responsável por criar o banco de dados.

Em vez disso, a App disponibiliza a infraestrutura necessária para que a Activity e as demais partes da aplicação possam utilizá-la.

Assim, na MainActivity, podemos obter o banco através de:

val app = application as App

e então acessar o DAO:

val userDao = app.database.userDao()

A partir daí podemos criar o Repository e o ViewModel.

O fluxo fica:

App
 │
 └── AppDatabase
       │
       └── UserDao
             │
             └── UserRepository
                   │
                   └── UserViewModel
                         │
                         └── Jetpack Compose

App não é a MainActivity

É importante não confundir essas duas classes.

A App herda de:

Application

e representa a aplicação no nível global.

A MainActivity, por outro lado, é uma Activity e representa uma tela/ponto de entrada da interface da aplicação.

Portanto:

Application
    │
    └── App
         │
         └── AppDatabase


Activity
    │
    └── MainActivity
         │
         └── Jetpack Compose

Em resumo: neste projeto, a classe App é utilizada como ponto central para criar e disponibilizar a instância do AppDatabase. A MainActivity utiliza essa infraestrutura para montar o Repository, o ViewModel e a interface Jetpack Compose.

Arquivo: App.kt

package com.example.app

import android.app.Application
import androidx.room.Room
import com.example.app.data.database.AppDatabase

class App : Application() {

val database: AppDatabase by lazy {

Room.databaseBuilder(
applicationContext,
AppDatabase::class.java,
“app_database”
).build()
}
}

O by lazy faz com que o AppDatabase seja criado somente no primeiro acesso a database. Depois disso, a mesma instância é reutilizada.

8. AndroidManifest.xml

O AndroidManifest.xml informa ao Android os principais componentes da aplicação. Neste projeto, precisamos declarar a nossa classe App e a MainActivity.

<application
    android:name=".App"
    ... >

    <activity
        android:name=".MainActivity"
        android:exported="true">

        <intent-filter>
            <action android:name="android.intent.action.MAIN" />
            <category android:name="android.intent.category.LAUNCHER" />
        </intent-filter>

    </activity>

</application>

A declaração:

android:name=".App"

informa ao Android que a classe App será a implementação de Application utilizada pela aplicação.

Já:

android:name=".MainActivity"

declara a MainActivity como uma Activity da aplicação.

O intent-filter com MAIN e LAUNCHER indica que essa Activity é o ponto de entrada da interface quando o usuário inicia o aplicativo.

Assim, podemos visualizar:

Android
   │
   ├── cria App
   │      └── disponibiliza AppDatabase
   │
   └── inicia MainActivity
          └── inicia Jetpack Compose

Importante: App e MainActivity não têm a mesma função. App é a Application da aplicação, enquanto MainActivity é a Activity que inicia a interface.

 

9. App x MainActivity — quem é a principal?

É comum surgir a dúvida: entre App e MainActivity, qual é a classe principal da aplicação?

A resposta é: as duas possuem papéis diferentes.

App é a classe que representa a Application da aplicação e possui um ciclo de vida associado ao processo do aplicativo. Neste projeto, ela é utilizada para disponibilizar a instância do AppDatabase.

MainActivity é uma Activity e funciona como o ponto de entrada da interface da aplicação. É nela que iniciamos a composição da nossa UI com Jetpack Compose.

Podemos resumir assim:

Application
    │
    └── App
         └── AppDatabase
              └── UserDao


Activity
    │
    └── MainActivity
         └── Jetpack Compose

Portanto, não devemos pensar que uma delas “substitui” a outra:

App cuida da infraestrutura global da aplicação, enquanto MainActivity inicia e apresenta a interface ao usuário.

Neste projeto, a MainActivity obtém a instância do banco disponibilizada pela App e, a partir dela, monta as dependências necessárias para o ViewModel.

App
 │
 └── AppDatabase
       │
       └── UserDao
             │
             ▼
       UserRepository
             │
             ▼
       UserViewModel
             │
             ▼
       MainActivity
             │
             ▼
       Jetpack Compose

Em outras palavras: App controla a infraestrutura da aplicação; MainActivity inicia e apresenta a interface.

10. MainActivity completa

Arquivo: MainActivity.kt

package com.example.app

import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import com.example.app.data.repository.UserRepository
import com.example.app.ui.UserScreen
import com.example.app.viewmodel.UserViewModel
import com.example.app.viewmodel.UserViewModelFactory

class MainActivity : ComponentActivity() {

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)

val app = application as App

val repository = UserRepository(
app.database.userDao()
)

val factory = UserViewModelFactory(repository)

val viewModel: UserViewModel by viewModels {
factory
}

setContent {
UserScreen(viewModel)
}
}
}

Observe que a MainActivity não cria o banco. Ela obtém a instância existente através de application as App e usa app.database. A Activity apenas conecta a infraestrutura à interface.

11. A instância única do Room

O RoomDatabase deve ser tratado como uma instância compartilhada pela aplicação.

No nosso projeto, essa instância é criada na classe App:

class App : Application() {

    val database: AppDatabase by lazy {
        Room.databaseBuilder(
            applicationContext,
            AppDatabase::class.java,
            "app_database"
        ).build()
    }
}

O uso de by lazy garante que o banco seja criado somente quando for necessário. Depois de criado, a mesma instância de AppDatabase é reutilizada enquanto o processo da aplicação estiver em execução.

Por que não criar o banco várias vezes?

Imagine que cada parte da aplicação criasse seu próprio banco:

MainActivity
     │
     └── AppDatabase #1

OutraActivity
     │
     └── AppDatabase #2

Outro componente
     │
     └── AppDatabase #3

Além de desnecessário, isso pode consumir recursos e dificultar o gerenciamento do acesso aos dados.

Em nosso projeto, temos uma única instância compartilhada:

                  App
                   │
                   ▼
             AppDatabase
                   │
        ┌──────────┼──────────┐
        ▼          ▼          ▼
    UserDao     TripDao    CountryDao

Todas as partes da aplicação que precisam acessar o banco utilizam essa mesma instância.

Por que colocar o banco na App?

A classe App possui um ciclo de vida associado ao processo da aplicação, sendo um local adequado para manter objetos que precisam ser compartilhados por diferentes componentes.

Assim, a MainActivity não precisa criar o banco. Ela simplesmente obtém a instância já disponibilizada pela App:

val app = application as App
val database = app.database

A partir dela, podemos obter o DAO:

val userDao = database.userDao()

E continuar a construção das camadas da aplicação:

App
 │
 └── AppDatabase
       │
       └── UserDao
             │
             └── UserRepository
                   │
                   └── UserViewModel

Em resumo: criamos uma única instância do AppDatabase e a compartilhamos entre os componentes da aplicação. Isso evita a criação desnecessária de múltiplas instâncias do banco e mantém o acesso aos dados centralizado.

12. Repository

O Repository é a camada que fica entre o ViewModel e as fontes de dados da aplicação.

No nosso exemplo, o UserRepository será responsável por intermediar o acesso ao UserDao.

A ideia é evitar que o ViewModel precise conhecer os detalhes de implementação do Room.

ViewModel
    │
    ▼
Repository
    │
    ▼
UserDao
    │
    ▼
Room
    │
    ▼
SQLite

Criando o UserRepository

class UserRepository(
    private val userDao: UserDao
) {

    val users: Flow<List<UserEntity>> =
        userDao.getAll()

    suspend fun insert(user: UserEntity) {
        userDao.insert(user)
    }

    suspend fun update(user: UserEntity) {
        userDao.update(user)
    }

    suspend fun delete(user: UserEntity) {
        userDao.delete(user)
    }
}

O Repository recebe o UserDao pelo construtor e utiliza esse DAO para realizar as operações necessárias.

Por que não usar o DAO diretamente no ViewModel?

Poderíamos fazer:

ViewModel
    │
    ▼
UserDao
    │
    ▼
Room

e uma aplicação pequena poderia até funcionar dessa maneira.

Entretanto, dessa forma o ViewModel passa a conhecer diretamente a camada de persistência.

Utilizando o Repository:

ViewModel
    │
    ▼
UserRepository
    │
    ▼
UserDao

o ViewModel passa a depender do Repository, e não dos detalhes de como os dados são armazenados.

Isso cria uma separação mais clara entre as responsabilidades.

O Repository não substitui o DAO

É importante entender que Repository e DAO possuem funções diferentes.

DAO

Define as operações diretamente relacionadas ao banco:

@Query("SELECT * FROM users")
fun getAll(): Flow<List<UserEntity>>

Repository

Utiliza o DAO e fornece uma interface de acesso aos dados para as camadas superiores:

val users: Flow<List<UserEntity>> =
    userDao.getAll()

Podemos resumir:

DAO
 │
 └── Conhece o banco e as consultas
     
Repository
 │
 └── Organiza o acesso aos dados

ViewModel
 │
 └── Utiliza o Repository

Uma vantagem para o futuro

Hoje nossa fonte de dados é apenas o Room:

UserRepository
      │
      ▼
   UserDao
      │
      ▼
     Room

Mas uma aplicação pode futuramente utilizar outras fontes de dados.

Por exemplo:

                 UserRepository
                  /          \
                 ▼            ▼
              Room         API REST

O ViewModel não precisa conhecer esses detalhes. Ele continua trabalhando com o UserRepository.

Em resumo: o DAO define como acessar o banco, enquanto o Repository organiza e intermedeia esse acesso para o restante da aplicação. O ViewModel utiliza o Repository sem precisar conhecer os detalhes do Room.

13. ViewModel

O ViewModel é a camada responsável por disponibilizar os dados e as ações que a interface Jetpack Compose precisa.

Ele recebe o UserRepository e utiliza o Repository para consultar ou modificar os dados.

Dessa forma, a interface não precisa conhecer o Room, o DAO ou o banco de dados.

A arquitetura fica:

Jetpack Compose
       │
       ▼
   UserViewModel
       │
       ▼
  UserRepository
       │
       ▼
     UserDao
       │
       ▼
      Room
       │
       ▼
     SQLite

Criando o UserViewModel

class UserViewModel(
    private val repository: UserRepository
) : ViewModel() {

    val users: StateFlow<List<UserEntity>> =
        repository.users
            .stateIn(
                viewModelScope,
                SharingStarted.WhileSubscribed(5_000),
                emptyList()
            )

    fun insert(user: UserEntity) {
        viewModelScope.launch {
            repository.insert(user)
        }
    }

    fun update(user: UserEntity) {
        viewModelScope.launch {
            repository.update(user)
        }
    }

    fun delete(user: UserEntity) {
        viewModelScope.launch {
            repository.delete(user)
        }
    }
}

Por que o ViewModel recebe o Repository?

O ViewModel não deve precisar conhecer os detalhes do banco de dados.

Ele não precisa saber que os dados estão armazenados em uma tabela SQLite, nem precisa executar SQL.

Ele simplesmente solicita ao Repository:

repository.insert(user)

ou observa os usuários:

repository.users

Assim, cada camada possui uma responsabilidade:

Repository
    │
    └── Acesso aos dados

ViewModel
    │
    └── Estado e ações da tela

Compose
    │
    └── Interface do usuário

viewModelScope

As operações de gravação utilizam:

viewModelScope.launch {
    repository.insert(user)
}

viewModelScope é um escopo de coroutines associado ao ciclo de vida do ViewModel.

Isso permite executar operações assíncronas sem bloquear a interface.

Quando o ViewModel deixa de existir, as coroutines associadas ao seu viewModelScope também são canceladas.

Por que transformar Flow em StateFlow?

O Repository fornece:

Flow<List<UserEntity>>

No ViewModel, transformamos esse Flow em um StateFlow:

val users: StateFlow<List<UserEntity>> =
    repository.users
        .stateIn(
            viewModelScope,
            SharingStarted.WhileSubscribed(5_000),
            emptyList()
        )

O StateFlow representa o estado atual da tela.

Inicialmente temos:

emptyList()

Quando o Room detectar alterações nos dados observados pelo DAO, um novo valor será emitido pelo Flow, chegará ao ViewModel e atualizará o StateFlow.

O Compose observa o estado

Na interface Jetpack Compose, podemos observar esse estado:

val users by viewModel.users.collectAsStateWithLifecycle()

Quando users mudar, o Compose será recomposto e a interface poderá apresentar os novos dados.

O fluxo completo fica:

       Room
         │
         ▼
        DAO
         │
         ▼
       Flow
         │
         ▼
    Repository
         │
         ▼
     ViewModel
         │
      StateFlow
         │
         ▼
      Compose
         │
         ▼
      Tela

O ViewModel também recebe ações da interface

O caminho inverso acontece quando o usuário realiza alguma ação.

Por exemplo, ao pressionar o botão Salvar:

Usuário
   │
   ▼
Compose
   │
   ▼
ViewModel
   │
   ▼
Repository
   │
   ▼
DAO
   │
   ▼
Room

A interface apenas informa ao ViewModel o que o usuário deseja fazer:

viewModel.insert(user)

O ViewModel então utiliza o Repository para realizar a operação.

Em resumo: o ViewModel funciona como intermediário entre a interface e os dados. Ele mantém o estado utilizado pelo Compose, recebe as ações do usuário e utiliza o Repository para acessar os dados, mantendo a interface desacoplada do Room.

14. Factory do ViewModel

No item anterior criamos o UserViewModel com o seguinte construtor:

class UserViewModel(
    private val repository: UserRepository
) : ViewModel() {
    // ...
}

Observe que o UserViewModel precisa receber um UserRepository para funcionar.

Esse detalhe é importante porque, normalmente, o Android consegue criar um ViewModel automaticamente quando ele possui um construtor sem parâmetros. Porém, neste caso, nosso UserViewModel possui uma dependência:

UserViewModel
      │
      └── precisa de → UserRepository

Precisamos então informar ao Android como criar esse ViewModel e qual UserRepository deve ser fornecido.

É justamente essa a função da UserViewModelFactory.

Criando a UserViewModelFactory

class UserViewModelFactory(
    private val repository: UserRepository
) : ViewModelProvider.Factory {

    override fun <T : ViewModel> create(
        modelClass: Class<T>
    ): T {
        if (modelClass.isAssignableFrom(UserViewModel::class.java)) {
            @Suppress("UNCHECKED_CAST")
            return UserViewModel(repository) as T
        }

        throw IllegalArgumentException(
            "Unknown ViewModel class"
        )
    }
}

A Factory recebe o UserRepository no seu próprio construtor.

Depois, quando o Android precisar criar um UserViewModel, a Factory faz a ligação:

UserRepository
      │
      ▼
UserViewModelFactory
      │
      │ cria
      ▼
UserViewModel

Qual é a relação entre Factory e ViewModel?

É importante não confundir as responsabilidades das duas classes.

O UserViewModel contém a lógica relacionada ao estado e às ações da tela:

UserViewModel
    │
    ├── observa usuários
    ├── insere usuário
    ├── atualiza usuário
    └── exclui usuário

A UserViewModelFactory não executa essas operações.

Sua função é simplesmente criar o UserViewModel fornecendo as dependências que ele precisa.

Podemos pensar na Factory como uma “fábrica” do ViewModel:

                UserRepository
                       │
                       ▼
             UserViewModelFactory
                       │
                       │ create()
                       ▼
                UserViewModel

Por que não criar o ViewModel diretamente?

Poderíamos escrever:

val viewModel = UserViewModel(repository)

Mas um ViewModel deve ser obtido através do mecanismo de gerenciamento de ViewModels do Android, para que ele seja associado corretamente ao ciclo de vida do componente que o utiliza.

Por isso utilizamos a Factory juntamente com:

viewModel(...)

ou com o mecanismo equivalente de criação de ViewModel.

A Factory informa ao sistema:

“Quando você precisar de um UserViewModel, crie-o utilizando este UserRepository.”

De onde vem o Repository?

A cadeia completa que construímos até agora é:

App
 │
 └── AppDatabase
       │
       └── UserDao
             │
             ▼
       UserRepository
             │
             ▼
    UserViewModelFactory
             │
             ▼
       UserViewModel
             │
             ▼
       Jetpack Compose

Assim, cada classe possui uma responsabilidade específica:

ClasseResponsabilidade
AppDatabaseRepresenta o banco Room
UserDaoDefine as operações no banco
UserRepositoryIntermedeia o acesso aos dados
UserViewModelMantém o estado e as ações da tela
UserViewModelFactoryCria o UserViewModel fornecendo o UserRepository

Factory não é o ViewModel

Essa distinção é importante:

UserViewModelFactory
        │
        │ cria
        ▼
   UserViewModel
        │
        │ utiliza
        ▼
 UserRepository

A UserViewModelFactory não substitui o UserViewModel e também não contém a lógica da tela.

Ela existe para resolver o problema de criação de um ViewModel que possui dependências no construtor.

Em resumo: UserViewModel é responsável pelo estado e pelas ações da tela. UserViewModelFactory é responsável por criar o UserViewModel e fornecer a ele o UserRepository de que precisa.

15. Tela Compose: listar, editar e deletar

Agora chegamos à interface da aplicação.

A tela Jetpack Compose não acessa diretamente o Room, o DAO ou o banco de dados. Ela trabalha com o UserViewModel.

O fluxo da aplicação fica:

Room
 │
 ▼
DAO
 │
 ▼
Repository
 │
 ▼
ViewModel
 │
 ▼
Compose

E, quando o usuário realiza uma ação, o fluxo ocorre no sentido contrário:

Compose
 │
 ▼
ViewModel
 │
 ▼
Repository
 │
 ▼
DAO
 │
 ▼
Room

Obtendo o estado no Compose

Na tela, observamos o StateFlow disponibilizado pelo UserViewModel:

@Composable
fun UserScreen(
    viewModel: UserViewModel
) {
    val users by viewModel.users.collectAsStateWithLifecycle()

    // interface
}

O collectAsStateWithLifecycle() transforma o StateFlow em um estado que pode ser observado pelo Compose.

Quando os dados mudarem, o Compose será recomposto e a lista apresentada na tela será atualizada.

Listando os usuários

Podemos utilizar LazyColumn para apresentar os usuários:

LazyColumn {
    items(users) { user ->
        UserItem(
            user = user,
            onEdit = {
                // editar
            },
            onDelete = {
                viewModel.delete(user)
            }
        )
    }
}

A tela não precisa saber como o usuário será excluído do banco.

Ela simplesmente informa ao ViewModel:

viewModel.delete(user)

O ViewModel utiliza o Repository, que utiliza o DAO:

UserScreen
    │
    │ delete(user)
    ▼
UserViewModel
    │
    ▼
UserRepository
    │
    ▼
UserDao
    │
    ▼
Room

Editando um usuário

O mesmo princípio vale para a edição.

A interface pode informar ao ViewModel que o usuário foi alterado:

viewModel.update(user)

O caminho será:

Compose
   │
   ▼
ViewModel
   │
   ▼
Repository
   │
   ▼
DAO
   │
   ▼
Room

Depois que o banco for atualizado, o Flow observado pelo DAO emitirá os novos dados.

Esses dados percorrem novamente as camadas:

Room
  ↓
DAO
  ↓
Flow
  ↓
Repository
  ↓
ViewModel
  ↓
StateFlow
  ↓
Compose

O resultado é que a tela pode ser atualizada automaticamente.

Edição e exclusão não pertencem ao Compose

É importante observar que o Compose não executa operações de banco de dados.

Ele apenas apresenta os dados e envia as ações do usuário para o ViewModel.

Assim:

Compose
 │
 ├── apresenta dados
 ├── recebe interação
 │
 └── chama ViewModel
             │
             ├── insert()
             ├── update()
             └── delete()

Essa separação mantém a interface independente da tecnologia utilizada para persistir os dados.

Hoje utilizamos Room, mas a interface não precisa conhecer essa implementação.

O ciclo completo

Neste ponto, temos praticamente toda a arquitetura da aplicação:

                    ┌──────────────┐
                    │    Compose   │
                    │     Tela     │
                    └──────┬───────┘
                           │
                    ações / estado
                           │
                           ▼
                    ┌──────────────┐
                    │  ViewModel   │
                    └──────┬───────┘
                           │
                           ▼
                    ┌──────────────┐
                    │  Repository  │
                    └──────┬───────┘
                           │
                           ▼
                    ┌──────────────┐
                    │     DAO      │
                    └──────┬───────┘
                           │
                           ▼
                    ┌──────────────┐
                    │     Room     │
                    └──────┬───────┘
                           │
                           ▼
                       SQLite

Para leitura dos dados, o caminho retorna através do Flow, permitindo que o estado da interface seja atualizado conforme os dados do banco mudam.

Em resumo: a tela Compose apresenta os dados e recebe as ações do usuário. O ViewModel coordena essas ações, o Repository intermedeia o acesso aos dados, o DAO executa as operações e o Room gerencia a persistência no SQLite.

 

Arquivo: ui/UserScreen.kt

package com.example.app.ui

import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.example.app.data.entity.UserEntity
import com.example.app.viewmodel.UserViewModel

@Composable
fun UserScreen(
viewModel: UserViewModel
) {
val users by viewModel.users.collectAsState()

var editingUser by remember {
mutableStateOf<UserEntity?>(null)
}

var name by remember {
mutableStateOf(“”)
}

var email by remember {
mutableStateOf(“”)
}

Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp)
) {

OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text(“Nome”) },
modifier = Modifier.fillMaxWidth()
)

OutlinedTextField(
value = email,
onValueChange = { email = it },
label = { Text(“E-mail”) },
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp)
)

Button(
onClick = {
if (editingUser == null) {
viewModel.insert(name, email)
} else {
viewModel.update(
editingUser!!.copy(
name = name,
email = email
)
)
}

name = “”
email = “”
editingUser = null
},
modifier = Modifier.padding(top = 8.dp)
) {
Text(
if (editingUser == null)
“Adicionar”
else
“Salvar”
)
}

LazyColumn(
modifier = Modifier
.fillMaxWidth()
.padding(top = 16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(
items = users,
key = { it.id }
) { user ->

UserCard(
user = user,
onEdit = {
editingUser = user
name = user.name
email = user.email
},
onDelete = {
viewModel.delete(user)
}
)
}
}
}
}

@Composable
private fun UserCard(
user: UserEntity,
onEdit: () -> Unit,
onDelete: () -> Unit
) {
Card(
modifier = Modifier.fillMaxWidth()
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(12.dp),
horizontalArrangement = Arrangement.SpaceBetween
) {
Column(
modifier = Modifier.weight(1f)
) {
Text(user.name)
Text(user.email)
}

Row(
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
Button(onClick = onEdit) {
Text(“Editar”)
}

Button(onClick = onDelete) {
Text(“Deletar”)
}
}
}
}
}

A tela utiliza LazyColumn para a lista. Cada usuário aparece em um Card com os botões Editar e Deletar. Ao editar, os dados são carregados no formulário; ao salvar, o registro existente é atualizado pelo id.

16. Entity x Model

Em projetos pequenos, a Entity pode ser usada diretamente pela UI. Em projetos maiores, é recomendável separar a Entity do Model da aplicação.

data class User(
val id: Long,
val name: String,
val email: String
)

17. Fluxo completo

Android

├── App
│    └── AppDatabase
│         └── DAO
│              └── Repository

└── MainActivity
└── ViewModel
└── Compose / UserScreen

No acesso aos dados, o fluxo é:

UserScreen → ViewModel → Repository → DAO → Room → SQLite

 

18. Construindo a tela com Jetpack Compose

A interface do nosso exemplo foi construída utilizando Jetpack Compose. Nesta seção vamos identificar os principais componentes utilizados no arquivo UserScreen.kt e entender a função de cada um.

O objetivo não é apresentar todos os recursos do Compose, mas explicar os componentes necessários para compreender a tela criada neste tutorial.

@Composable

Uma função que constrói uma parte da interface é marcada com:

@Composable
fun UserScreen(
    viewModel: UserViewModel
) {
    ...
}

@Composable informa ao Compose que a função pode ser utilizada para construir a interface de usuário.

Também criamos um componente específico para apresentar cada usuário:

@Composable
private fun UserCard(
    user: UserEntity,
    onEdit: () -> Unit,
    onDelete: () -> Unit
) {
    ...
}

Assim, podemos dividir uma tela complexa em componentes menores.


Column

O Column organiza seus componentes verticalmente, um abaixo do outro.

Na nossa tela:

Column(
    modifier = Modifier
        .fillMaxSize()
        .padding(16.dp)
) {
    ...
}

Ele é utilizado como estrutura principal da tela:

Column
  │
  ├── Campo Nome
  ├── Campo E-mail
  ├── Botão
  └── LazyColumn

Row

O Row organiza os componentes horizontalmente.

No UserCard, utilizamos um Row para colocar as informações do usuário de um lado e os botões do outro:

Row(
    modifier = Modifier
        .fillMaxWidth()
        .padding(12.dp),
    horizontalArrangement = Arrangement.SpaceBetween
) {
    ...
}

Visualmente:

Nome / E-mail          Editar  Deletar

Também utilizamos outro Row para organizar os dois botões.


LazyColumn

O LazyColumn é utilizado para criar uma lista vertical.

No nosso exemplo:

LazyColumn {
    items(
        items = users,
        key = { it.id }
    ) { user ->

        UserCard(
            user = user,
            onEdit = { ... },
            onDelete = { ... }
        )
    }
}

A lista recebe os usuários fornecidos pelo ViewModel e cria um UserCard para cada usuário.

A palavra Lazy indica que o Compose pode criar e manter em composição apenas os itens necessários para a exibição, tornando esse componente adequado para listas maiores.


Card

O Card cria uma área visual para agrupar informações relacionadas.

No nosso exemplo, cada usuário é apresentado dentro de um Card:

Card(
    modifier = Modifier.fillMaxWidth()
) {
    ...
}

O resultado é aproximadamente:

┌────────────────────────────────────┐
│ João Silva        [Editar] [Deletar]│
│ joao@email.com                     │
└────────────────────────────────────┘

Text

Text apresenta texto na interface.

Por exemplo:

Text(user.name)
Text(user.email)

Também utilizamos Text nos campos e botões:

Text("Nome")

e:

Text("Deletar")

OutlinedTextField

OutlinedTextField cria um campo de entrada de texto.

No nosso formulário:

OutlinedTextField(
    value = name,
    onValueChange = { name = it },
    label = { Text("Nome") },
    modifier = Modifier.fillMaxWidth()
)

Existem duas informações importantes:

value indica o valor atual do campo:

value = name

e onValueChange recebe o novo valor digitado:

onValueChange = { name = it }

Assim, o campo e a variável name permanecem sincronizados.

O mesmo mecanismo é utilizado para o campo email.


Button

Button cria um botão interativo.

No nosso exemplo:

Button(
    onClick = {
        ...
    }
) {
    Text("Adicionar")
}

O código dentro de onClick é executado quando o usuário pressiona o botão.

Na nossa aplicação, essa ação pode resultar em uma inserção ou atualização no banco, mas o Compose não realiza essa operação diretamente.

Ele chama o ViewModel:

viewModel.insert(name, email)

ou:

viewModel.update(...)

Modifier

Modifier é utilizado para configurar características e comportamento dos componentes.

No nosso código encontramos, por exemplo:

Modifier
    .fillMaxWidth()
    .padding(16.dp)

Nesse caso:

  • fillMaxWidth() faz o componente ocupar a largura disponível;
  • padding(16.dp) adiciona espaço ao redor do componente.

O Modifier aparece em praticamente toda a construção da nossa interface.


remember e mutableStateOf

O Compose precisa saber quais valores representam o estado da interface.

No formulário utilizamos:

var name by remember {
    mutableStateOf("")
}

mutableStateOf cria um estado observável pelo Compose.

remember mantém esse estado durante as recomposições da função.

Quando fazemos:

name = it

o estado muda e o Compose pode recompor os componentes que dependem desse valor.

Na nossa tela também utilizamos estado para controlar a edição:

var editingUser by remember {
    mutableStateOf<UserEntity?>(null)
}

Esse estado determina se o formulário está no modo Adicionar ou Salvar.


collectAsState

Os usuários vêm do ViewModel através de um Flow.

Na tela fazemos:

val users by viewModel.users.collectAsState()

O collectAsState() permite que o Compose observe os valores emitidos pelo Flow como estado da interface.

Assim, quando a lista de usuários for alterada, a interface poderá ser atualizada.

Room
  ↓
DAO
  ↓
Flow
  ↓
ViewModel
  ↓
collectAsState()
  ↓
Compose
  ↓
LazyColumn

Como os componentes se combinam

A tela completa pode ser entendida como uma árvore de componentes:

UserScreen
    │
    └── Column
         │
         ├── OutlinedTextField
         │      └── Text
         │
         ├── OutlinedTextField
         │      └── Text
         │
         ├── Button
         │      └── Text
         │
         └── LazyColumn
                │
                └── UserCard
                     │
                     └── Card
                          │
                          └── Row
                               │
                               ├── Column
                               │    ├── Text
                               │    └── Text
                               │
                               └── Row
                                    ├── Button
                                    │    └── Text
                                    │
                                    └── Button
                                         └── Text

Essa é uma das características fundamentais do Jetpack Compose: a interface é construída pela composição de funções e componentes.

Em resumo: Column e Row organizam os elementos, LazyColumn apresenta a lista, Card agrupa informações, Text apresenta textos, OutlinedTextField recebe dados, Button recebe ações e Modifier controla layout e aparência. remember, mutableStateOf e collectAsState permitem que a interface reaja às mudanças de estado.

19. Conclusão

O exemplo agora possui todas as peças necessárias para demonstrar uma aplicação Android simples com Room e Jetpack Compose: App, MainActivity, Database, Entity, DAO, Repository, ViewModel, Factory e uma tela Compose com operações de inserir, listar, editar e deletar.

O ponto principal é separar responsabilidades. A App mantém a infraestrutura global, especialmente a instância do RoomDatabase. A MainActivity inicia a interface. O ViewModel coordena o estado, o Repository organiza o acesso aos dados, o DAO executa as operações do Room e o Compose apresenta os dados.

Deixe um comentário

O seu endereço de e-mail não será publicado. Campos obrigatórios são marcados com *