Neste tutorial completo, você vai aprender a implementar a arquitetura MVVM (Model-View-ViewModel) no Android usando Kotlin. Vamos cobrir ViewModel, LiveData, StateFlow, o Repository pattern, injeção de dependência manual e construir um exemplo prático integrando Room e Retrofit. Ao final, você terá uma base sólida para estruturar seus projetos Android de forma escalável e testável.

O que é a Arquitetura MVVM?

O MVVM divide o código do aplicativo em três camadas com responsabilidades bem definidas:

  • Model: camada de dados — inclui o banco de dados local (Room), APIs remotas (Retrofit), e a lógica de negócios
  • View: camada de apresentação — Activities, Fragments e Composables que exibem dados e capturam interações do usuário
  • ViewModel: camada intermediária — prepara e gerencia os dados que a View precisa exibir, sobrevivendo a mudanças de configuração (como rotação de tela)

O princípio fundamental é que a View observa o ViewModel, e o ViewModel não conhece a View. Esse desacoplamento facilita os testes unitários e mantém o código organizado conforme o projeto cresce.

Passo 1: Configurando as Dependências

Adicione as dependências no build.gradle.kts:

dependencies {
    // ViewModel e LiveData
    implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0")
    implementation("androidx.lifecycle:lifecycle-livedata-ktx:2.7.0")
    implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0")

    // Para coletar StateFlow de forma segura
    implementation("androidx.lifecycle:lifecycle-runtime-compose:2.7.0")

    // Activity KTX (para viewModels() delegate)
    implementation("androidx.activity:activity-ktx:1.8.2")
    implementation("androidx.fragment:fragment-ktx:1.6.2")

    // Coroutines
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
}

Passo 2: Criando o Model — Entity e DAO

Vamos construir um app de notas para ilustrar a arquitetura. Começamos pela camada de dados:

import androidx.room.*
import kotlinx.coroutines.flow.Flow

@Entity(tableName = "notas")
data class Nota(
    @PrimaryKey(autoGenerate = true) val id: Long = 0,
    val titulo: String,
    val conteudo: String,
    val dataCriacao: Long = System.currentTimeMillis(),
    val favorita: Boolean = false
)

@Dao
interface NotaDao {
    @Query("SELECT * FROM notas ORDER BY dataCriacao DESC")
    fun observarTodas(): Flow<List<Nota>>

    @Query("SELECT * FROM notas WHERE favorita = 1 ORDER BY dataCriacao DESC")
    fun observarFavoritas(): Flow<List<Nota>>

    @Query("SELECT * FROM notas WHERE id = :id")
    suspend fun buscarPorId(id: Long): Nota?

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun inserir(nota: Nota): Long

    @Update
    suspend fun atualizar(nota: Nota)

    @Delete
    suspend fun deletar(nota: Nota)
}

Passo 3: O Repository Pattern — Separando as Fontes de Dados

O Repository é a camada que abstrai as fontes de dados (local e remota) para o ViewModel. Ele decide de onde buscar os dados e como sincronizá-los:

class NotaRepository(
    private val notaDao: NotaDao,
    private val apiService: ApiService? = null // fonte remota opcional
) {
    // Dados reativos do banco local
    val todasNotas: Flow<List<Nota>> = notaDao.observarTodas()
    val favoritas: Flow<List<Nota>> = notaDao.observarFavoritas()

    suspend fun buscarPorId(id: Long): Nota? {
        return notaDao.buscarPorId(id)
    }

    suspend fun salvar(nota: Nota): Long {
        return notaDao.inserir(nota)
    }

    suspend fun atualizar(nota: Nota) {
        notaDao.atualizar(nota)
    }

    suspend fun deletar(nota: Nota) {
        notaDao.deletar(nota)
    }

    suspend fun alternarFavorita(nota: Nota) {
        notaDao.atualizar(nota.copy(favorita = !nota.favorita))
    }

    // Exemplo de sincronização com API remota
    suspend fun sincronizar(): Result<Unit> {
        return try {
            val notasRemotas = apiService?.buscarNotas() ?: return Result.success(Unit)
            notaDao.inserirTodas(notasRemotas)
            Result.success(Unit)
        } catch (e: Exception) {
            Result.failure(e)
        }
    }
}

O Repository recebe suas dependências pelo construtor, seguindo o princípio de inversão de dependência. Isso facilita a substituição por implementações de teste (mocks).

Passo 4: O ViewModel — Gerenciando o Estado da UI

O ViewModel é o coração da arquitetura MVVM. Ele expõe o estado da UI e processa ações do usuário:

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch

// Estado da UI representado como uma data class imutável
data class NotasUiState(
    val notas: List<Nota> = emptyList(),
    val isLoading: Boolean = false,
    val erro: String? = null,
    val filtro: FiltroNotas = FiltroNotas.TODAS
)

enum class FiltroNotas { TODAS, FAVORITAS }

class NotaViewModel(
    private val repository: NotaRepository
) : ViewModel() {

    private val _uiState = MutableStateFlow(NotasUiState(isLoading = true))
    val uiState: StateFlow<NotasUiState> = _uiState.asStateFlow()

    private val _filtroAtual = MutableStateFlow(FiltroNotas.TODAS)

    init {
        observarNotas()
    }

    private fun observarNotas() {
        viewModelScope.launch {
            _filtroAtual.flatMapLatest { filtro ->
                when (filtro) {
                    FiltroNotas.TODAS -> repository.todasNotas
                    FiltroNotas.FAVORITAS -> repository.favoritas
                }
            }.collect { notas ->
                _uiState.update { state ->
                    state.copy(
                        notas = notas,
                        isLoading = false,
                        erro = null
                    )
                }
            }
        }
    }

    fun alterarFiltro(filtro: FiltroNotas) {
        _filtroAtual.value = filtro
        _uiState.update { it.copy(filtro = filtro) }
    }

    fun adicionarNota(titulo: String, conteudo: String) {
        viewModelScope.launch {
            try {
                repository.salvar(Nota(titulo = titulo, conteudo = conteudo))
            } catch (e: Exception) {
                _uiState.update { it.copy(erro = "Erro ao salvar nota: ${e.message}") }
            }
        }
    }

    fun deletarNota(nota: Nota) {
        viewModelScope.launch {
            try {
                repository.deletar(nota)
            } catch (e: Exception) {
                _uiState.update { it.copy(erro = "Erro ao deletar nota") }
            }
        }
    }

    fun alternarFavorita(nota: Nota) {
        viewModelScope.launch {
            repository.alternarFavorita(nota)
        }
    }

    fun limparErro() {
        _uiState.update { it.copy(erro = null) }
    }
}

O ViewModel usa viewModelScope para lançar coroutines que são automaticamente canceladas quando o ViewModel é destruído. O estado é exposto como StateFlow imutável para a View.

Passo 5: LiveData vs StateFlow no ViewModel

Tanto LiveData quanto StateFlow podem ser usados para expor estado. Aqui está a comparação:

// Abordagem com LiveData
class NotaViewModelComLiveData(
    private val repository: NotaRepository
) : ViewModel() {

    // LiveData é lifecycle-aware nativamente
    val notas: LiveData<List<Nota>> = repository.todasNotas
        .asLiveData() // converte Flow para LiveData

    private val _erro = MutableLiveData<String?>()
    val erro: LiveData<String?> = _erro

    fun adicionarNota(titulo: String, conteudo: String) {
        viewModelScope.launch {
            try {
                repository.salvar(Nota(titulo = titulo, conteudo = conteudo))
            } catch (e: Exception) {
                _erro.value = e.message
            }
        }
    }
}

// Abordagem com StateFlow (recomendada para projetos novos)
class NotaViewModelComStateFlow(
    private val repository: NotaRepository
) : ViewModel() {

    val notas: StateFlow<List<Nota>> = repository.todasNotas
        .stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5000),
            initialValue = emptyList()
        )
}

O SharingStarted.WhileSubscribed(5000) mantém o Flow ativo por 5 segundos após o último coletor se desconectar. Isso evita reiniciar a coleta durante rotações de tela, onde a Activity é destruída e recriada rapidamente.

Passo 6: A View — Conectando Tudo

Na Activity ou Fragment, observamos o ViewModel e atualizamos a UI:

import android.os.Bundle
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import kotlinx.coroutines.launch

class NotasActivity : AppCompatActivity() {

    private val viewModel: NotaViewModel by viewModels {
        NotaViewModelFactory(
            (application as App).notaRepository
        )
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_notas)

        // Coleta segura do StateFlow — respeita o lifecycle
        lifecycleScope.launch {
            repeatOnLifecycle(Lifecycle.State.STARTED) {
                viewModel.uiState.collect { state ->
                    atualizarUI(state)
                }
            }
        }
    }

    private fun atualizarUI(state: NotasUiState) {
        if (state.isLoading) {
            // mostrar indicador de carregamento
        } else {
            // atualizar a lista no adapter
            adapter.submitList(state.notas)
        }

        state.erro?.let { mensagem ->
            // exibir Snackbar com o erro
            Snackbar.make(rootView, mensagem, Snackbar.LENGTH_LONG).show()
            viewModel.limparErro()
        }
    }
}

O repeatOnLifecycle garante que a coleta é pausada quando a Activity vai para o background e retomada quando volta. Isso evita atualizações desnecessárias e possíveis crashes.

Passo 7: Injeção de Dependência Manual

Sem bibliotecas como Hilt ou Koin, podemos fazer injeção de dependência manualmente usando a classe Application e ViewModelFactory:

class App : Application() {

    // Lazy: instanciado apenas quando necessário
    private val database by lazy { AppDatabase.getInstance(this) }
    val notaRepository by lazy { NotaRepository(database.notaDao()) }
}

class NotaViewModelFactory(
    private val repository: NotaRepository
) : ViewModelProvider.Factory {

    override fun <T : ViewModel> create(modelClass: Class<T>): T {
        if (modelClass.isAssignableFrom(NotaViewModel::class.java)) {
            @Suppress("UNCHECKED_CAST")
            return NotaViewModel(repository) as T
        }
        throw IllegalArgumentException("ViewModel desconhecido: ${modelClass.name}")
    }
}

Esse padrão funciona bem para projetos pequenos e médios. Para projetos maiores, considere usar Hilt (recomendado pelo Google) ou Koin para gerenciar dependências automaticamente.

Passo 8: Exemplo Prático — Integrando Room e Retrofit

Vamos unir tudo em um Repository que busca dados da API e salva localmente:

class NotaRepository(
    private val notaDao: NotaDao,
    private val apiService: ApiService
) {
    val notas: Flow<List<Nota>> = notaDao.observarTodas()

    // Estratégia: mostrar dados locais e atualizar em background
    suspend fun atualizarNotas(): Result<Unit> {
        return try {
            val notasRemotas = apiService.buscarNotas()
            notaDao.inserirTodas(notasRemotas)
            Result.success(Unit)
        } catch (e: Exception) {
            // Dados locais continuam disponíveis
            Result.failure(e)
        }
    }

    suspend fun criarNota(titulo: String, conteudo: String): Result<Nota> {
        return try {
            val nota = Nota(titulo = titulo, conteudo = conteudo)
            // Salva localmente primeiro (offline-first)
            val id = notaDao.inserir(nota)
            val notaSalva = nota.copy(id = id)

            // Tenta sincronizar com o servidor
            try {
                apiService.criarNota(notaSalva)
            } catch (e: Exception) {
                // Marca para sincronização futura
            }

            Result.success(notaSalva)
        } catch (e: Exception) {
            Result.failure(e)
        }
    }
}

No ViewModel, integramos tudo:

class NotaViewModel(private val repository: NotaRepository) : ViewModel() {

    private val _uiState = MutableStateFlow(NotasUiState(isLoading = true))
    val uiState: StateFlow<NotasUiState> = _uiState.asStateFlow()

    init {
        // Observa dados locais
        viewModelScope.launch {
            repository.notas.collect { lista ->
                _uiState.update { it.copy(notas = lista, isLoading = false) }
            }
        }
        // Atualiza do servidor em background
        sincronizar()
    }

    fun sincronizar() {
        viewModelScope.launch {
            _uiState.update { it.copy(isLoading = true) }
            repository.atualizarNotas().onFailure { e ->
                _uiState.update { it.copy(erro = "Falha ao sincronizar: ${e.message}") }
            }
            _uiState.update { it.copy(isLoading = false) }
        }
    }
}

Erros Comuns

  1. Colocar lógica de negócios na View: Activities e Fragments devem apenas observar o estado e delegar ações ao ViewModel. Qualquer lógica de processamento pertence ao ViewModel ou ao Repository.

  2. ViewModel referenciando a View diretamente: O ViewModel nunca deve ter referência a Activities, Fragments ou Views. Isso causa memory leaks e quebra a testabilidade. Use StateFlow ou LiveData para comunicação.

  3. Não usar repeatOnLifecycle: Coletar StateFlow com lifecycleScope.launch sem repeatOnLifecycle mantém a coleta ativa mesmo com o app em background, desperdiçando recursos.

  4. Estado mutável exposto publicamente: Sempre exponha StateFlow (imutável) e mantenha MutableStateFlow privado. O mesmo vale para LiveData vs MutableLiveData.

  5. Criar o ViewModel manualmente: Nunca instancie ViewModels com NotaViewModel(). Use viewModels() ou ViewModelProvider para que o Android gerencie o ciclo de vida corretamente.

Conclusão e Próximos Passos

Neste tutorial, você aprendeu a implementar a arquitetura MVVM com Kotlin de forma completa: separação de responsabilidades entre Model, View e ViewModel, uso de StateFlow e LiveData, o Repository pattern para abstração de dados, injeção de dependência manual, e um exemplo prático integrando Room e Retrofit.

Como próximos passos, recomendamos:

  • Estudar Hilt para injeção de dependência automatizada e escalável
  • Explorar Jetpack Compose para uma camada de View declarativa que se integra perfeitamente com o MVVM
  • Aprender sobre Clean Architecture para projetos de grande escala
  • Praticar testes unitários do ViewModel com kotlinx-coroutines-test
  • Consultar o glossário de Flow e coroutine para reforçar conceitos

A arquitetura MVVM é o padrão recomendado pelo Google para apps Android e dominar sua implementação é essencial para escrever código limpo, escalável e testável.