Como funciona a arquitetura MVVM no Android?

Resposta rápida: no Android moderno, MVVM separa a aplicação em Model, View e ViewModel. A tela em Jetpack Compose envia ações do usuário ao ViewModel, observa um StateFlow<UiState> com collectAsStateWithLifecycle() e renderiza esse estado. O ViewModel coordena a lógica de apresentação e conversa com um Repository, que decide como acessar API, Room ou cache. O fluxo recomendado é unidirecional: evento da UI → ViewModel → novo estado → UI.

Compose / View
    | eventos do usuário
    v
ViewModel -----> UiState (StateFlow)
    |
    v
Repository -----> API / Room / DataStore

Essa estrutura evita que uma Activity, um Fragment ou uma função @Composable concentre chamadas HTTP, regras de negócio, controle de carregamento e tratamento de erro. O resultado é uma tela mais fácil de testar, alterar e recompor sem perder estado.

Se você chegou aqui pesquisando “MVVM Android”, a implementação mais útil para projetos novos combina:

  • Jetpack Compose para a View;
  • ViewModel do AndroidX para manter o estado da tela;
  • StateFlow para expor estado observável;
  • coroutines para trabalho assíncrono;
  • Repository para esconder detalhes de rede e persistência;
  • injeção de dependência com Hilt ou Koin;
  • testes unitários do ViewModel e do Repository.

O que são Model, View e ViewModel?

Model: dados e regras da aplicação

A Model não é apenas uma data class. Ela representa os dados, as regras de negócio e os componentes que acessam fontes externas. Em um app real, essa parte pode incluir:

  • entidades de domínio;
  • repositories;
  • clientes HTTP com Retrofit ou Ktor Client;
  • banco local com Room;
  • preferências com DataStore;
  • casos de uso quando a regra merece uma classe própria.

O ponto importante é que a interface não deve saber se uma lista veio da internet, do banco local ou de um cache. Essa decisão pertence à camada de dados.

View: interface que renderiza estado

A View é a tela: uma função Compose, uma Activity ou um Fragment. Sua responsabilidade é pequena e objetiva:

  1. receber um estado;
  2. desenhar a interface;
  3. transformar interações em eventos;
  4. encaminhar esses eventos ao ViewModel.

A View não deveria calcular desconto, decidir estratégia de sincronização nem montar manualmente uma requisição de API. Em Compose, uma tela previsível recebe dados e callbacks, o que também facilita previews e testes.

ViewModel: estado e lógica de apresentação

O ViewModel prepara os dados para a tela e sobrevive a mudanças de configuração, como rotação. Ele deve:

  • expor um estado imutável;
  • processar ações da interface;
  • iniciar operações assíncronas em viewModelScope;
  • converter respostas e falhas em estados compreensíveis pela UI;
  • depender de abstrações, não de componentes visuais.

Evite guardar Activity, Fragment, View, NavController ou Context no ViewModel. Quando um recurso Android for realmente necessário, prefira abstraí-lo atrás de uma interface ou usar APIs próprias para aquele problema.

Exemplo completo de MVVM com Kotlin, StateFlow e Compose

Vamos montar uma tela de tarefas. O objetivo não é criar uma arquitetura enorme, mas mostrar o caminho completo entre dados, ViewModel e UI.

1. Modele o domínio e o Repository

data class Tarefa(
    val id: Long,
    val titulo: String,
    val concluida: Boolean,
)

interface TarefaRepository {
    fun observarTarefas(): Flow<List<Tarefa>>
    suspend fun atualizarConclusao(id: Long, concluida: Boolean)
    suspend fun sincronizar()
}

A interface do Repository não menciona Retrofit, Room ou DTO. Isso permite usar uma implementação de produção e outra falsa nos testes.

Uma implementação offline-first poderia observar o banco local e sincronizar a API em segundo plano:

class TarefaRepositoryImpl(
    private val dao: TarefaDao,
    private val api: TarefaApi,
) : TarefaRepository {

    override fun observarTarefas(): Flow<List<Tarefa>> =
        dao.observarTodas().map { entidades ->
            entidades.map(TarefaEntity::toDomain)
        }

    override suspend fun atualizarConclusao(
        id: Long,
        concluida: Boolean,
    ) {
        dao.atualizarConclusao(id, concluida)
    }

    override suspend fun sincronizar() {
        val remotas = api.listarTarefas()
        dao.substituirTodas(remotas.map(TarefaDto::toEntity))
    }
}

Para aprofundar esse desenho, leia o guia de Android offline-first com Kotlin.

2. Crie um UiState único

Uma única data class costuma ser mais prática que vários StateFlow independentes. Ela produz um retrato consistente da tela:

data class TarefasUiState(
    val carregando: Boolean = true,
    val tarefas: List<Tarefa> = emptyList(),
    val mensagemErro: String? = null,
)

Com esse modelo, a UI não corre o risco de observar carregando = false em um fluxo e uma lista antiga em outro. Mudanças relacionadas são publicadas juntas.

3. Implemente o ViewModel

class TarefasViewModel(
    private val repository: TarefaRepository,
) : ViewModel() {

    val uiState: StateFlow<TarefasUiState> =
        repository.observarTarefas()
            .map<List<Tarefa>, TarefasUiState> { tarefas ->
                TarefasUiState(
                    carregando = false,
                    tarefas = tarefas,
                )
            }
            .catch { erro ->
                emit(
                    TarefasUiState(
                        carregando = false,
                        mensagemErro = erro.message
                            ?: "Não foi possível carregar as tarefas",
                    )
                )
            }
            .stateIn(
                scope = viewModelScope,
                started = SharingStarted.WhileSubscribed(5_000),
                initialValue = TarefasUiState(),
            )

    init {
        sincronizar()
    }

    fun onConcluidaChange(tarefa: Tarefa, concluida: Boolean) {
        viewModelScope.launch {
            repository.atualizarConclusao(tarefa.id, concluida)
        }
    }

    fun tentarNovamente() {
        sincronizar()
    }

    private fun sincronizar() {
        viewModelScope.launch {
            runCatching { repository.sincronizar() }
                .onFailure { erro ->
                    // Registre a falha em observabilidade.
                    // O banco local ainda pode manter a tela utilizável.
                    println("Falha na sincronização: ${erro.message}")
                }
        }
    }
}

stateIn transforma o Flow do Repository em StateFlow, mantém um valor atual e controla quando o fluxo de origem permanece ativo. SharingStarted.WhileSubscribed(5_000) tolera pequenas interrupções de observação, como uma mudança de configuração, sem reiniciar o pipeline imediatamente.

Em uma aplicação com criação, busca e filtros, o ViewModel pode manter um MutableStateFlow privado para entradas da tela e combiná-lo com os dados do Repository. A regra permanece: exponha StateFlow, não MutableStateFlow.

4. Observe o estado no Compose

Separe a função que conversa com o ViewModel da função visual. A primeira coleta estado; a segunda recebe somente valores e callbacks.

@Composable
fun TarefasRoute(
    viewModel: TarefasViewModel,
) {
    val uiState by viewModel.uiState.collectAsStateWithLifecycle()

    TarefasScreen(
        uiState = uiState,
        onConcluidaChange = viewModel::onConcluidaChange,
        onTentarNovamente = viewModel::tentarNovamente,
    )
}

@Composable
fun TarefasScreen(
    uiState: TarefasUiState,
    onConcluidaChange: (Tarefa, Boolean) -> Unit,
    onTentarNovamente: () -> Unit,
) {
    when {
        uiState.carregando -> CircularProgressIndicator()

        uiState.mensagemErro != null -> ErrorContent(
            mensagem = uiState.mensagemErro,
            onTentarNovamente = onTentarNovamente,
        )

        uiState.tarefas.isEmpty() -> Text("Nenhuma tarefa cadastrada")

        else -> LazyColumn {
            items(
                items = uiState.tarefas,
                key = Tarefa::id,
            ) { tarefa ->
                Row {
                    Checkbox(
                        checked = tarefa.concluida,
                        onCheckedChange = { concluida ->
                            onConcluidaChange(tarefa, concluida)
                        },
                    )
                    Text(tarefa.titulo)
                }
            }
        }
    }
}

Use collectAsStateWithLifecycle() no Android em vez de collectAsState() quando o fluxo não precisa continuar sendo coletado com a tela fora do estado ativo. Isso integra a coleta ao lifecycle e reduz trabalho desnecessário.

Fluxo unidirecional de dados no MVVM

MVVM funciona melhor quando combinado com Unidirectional Data Flow (UDF):

  1. a UI mostra UiState;
  2. o usuário toca, digita ou atualiza a tela;
  3. a UI envia uma ação ao ViewModel;
  4. o ViewModel chama o domínio ou Repository;
  5. a fonte de dados muda;
  6. um novo UiState chega à UI.

Esse ciclo reduz estados impossíveis e facilita depuração. Em vez de a tela alterar diretamente uma lista e depois tentar sincronizá-la, a ação percorre um caminho conhecido.

Você pode representar ações com funções explícitas, como onConcluidaChange, ou com uma sealed interface quando a tela tem muitos eventos:

sealed interface TarefasAction {
    data class AlterarConclusao(
        val tarefa: Tarefa,
        val concluida: Boolean,
    ) : TarefasAction

    data object TentarNovamente : TarefasAction
}

Não crie uma classe de evento por hábito. Funções nomeadas são mais simples em telas pequenas; uma interface de ações fica útil quando o volume de interações cresce ou quando você quer registrar e testar a redução de eventos.

StateFlow ou LiveData no Android?

Para projetos novos com Kotlin e Compose, prefira StateFlow. Ele faz parte de coroutines, funciona fora do Android e combina com operadores como map, combine, debounce e stateIn.

CritérioStateFlowLiveData
Integração com coroutinesNativaParcial
Valor inicial obrigatórioSimNão
Uso fora do AndroidSimNão
Jetpack ComposecollectAsStateWithLifecycle()observeAsState()
Projeto legado com XMLFuncionaMuito comum

LiveData não está “errado”. Se um app baseado em Views já o usa de forma consistente, uma migração sem benefício claro pode apenas gerar trabalho. Em código novo, StateFlow normalmente oferece uma base mais flexível. Veja também o guia completo de Kotlin Flow.

Eventos únicos: navegação, Snackbar e mensagens

Um erro frequente em MVVM é tratar tudo como evento descartável. Mensagens importantes para o usuário podem se perder se forem emitidas enquanto a tela está em segundo plano.

Prefira transformar o resultado da operação em estado quando ele afetar a interface. Por exemplo, uma falha ao salvar pode aparecer em uiState.mensagemErro. Depois que a UI confirmar a exibição, envie uma ação para limpar a mensagem.

Para navegação, mantenha o NavController na camada de UI. O ViewModel pode expor que uma operação terminou; a rota decide para onde navegar. O guia de Navigation Compose com NavHost e NavController mostra essa separação na prática.

Como organizar pastas sem exagerar nas camadas

Uma estrutura por feature costuma escalar melhor que separar o projeto inteiro por tipo técnico:

feature/tarefas/
  data/
    TarefaRepositoryImpl.kt
    TarefaApi.kt
    TarefaDao.kt
  domain/
    Tarefa.kt
    TarefaRepository.kt
  presentation/
    TarefasViewModel.kt
    TarefasUiState.kt
    TarefasScreen.kt

Em um app pequeno, domain/ pode ser desnecessário. Não crie UseCase, Mapper, Coordinator e interfaces duplicadas para uma operação trivial. Adicione uma camada quando ela isola uma regra, permite reutilização ou reduz acoplamento real.

MVVM também não substitui Clean Architecture. MVVM organiza a apresentação; Clean Architecture orienta a direção das dependências. É possível usar ambos, mas a arquitetura deve continuar proporcional ao produto.

Testando o ViewModel

Como o ViewModel depende de uma interface, o teste não precisa inicializar Retrofit, Room nem Android UI.

class FakeTarefaRepository : TarefaRepository {
    private val tarefas = MutableStateFlow<List<Tarefa>>(emptyList())

    override fun observarTarefas(): Flow<List<Tarefa>> = tarefas

    override suspend fun atualizarConclusao(
        id: Long,
        concluida: Boolean,
    ) {
        tarefas.update { lista ->
            lista.map { tarefa ->
                if (tarefa.id == id) tarefa.copy(concluida = concluida)
                else tarefa
            }
        }
    }

    override suspend fun sincronizar() = Unit

    fun emitir(vararg itens: Tarefa) {
        tarefas.value = itens.toList()
    }
}
@Test
fun `marcar tarefa atualiza o estado da tela`() = runTest {
    val repository = FakeTarefaRepository()
    val viewModel = TarefasViewModel(repository)

    val tarefa = Tarefa(1, "Estudar StateFlow", false)
    repository.emitir(tarefa)

    viewModel.onConcluidaChange(tarefa, true)
    advanceUntilIdle()

    assertTrue(viewModel.uiState.value.tarefas.single().concluida)
}

Em projetos reais, configure o dispatcher principal de testes e use ferramentas como Turbine quando precisar validar sequências emitidas por Flow. O guia de testes de Flow e StateFlow com Turbine aprofunda esse cenário.

Erros comuns em arquitetura MVVM

Colocar regra de negócio no Composable

Um composable deve renderizar e encaminhar ações. Cálculos de domínio, chamadas HTTP e decisões de persistência dificultam testes e podem ser repetidos durante recomposições.

Expor MutableStateFlow

Isto permite que qualquer consumidor altere o estado:

val uiState = MutableStateFlow(TarefasUiState()) // evite expor assim

Mantenha mutabilidade privada ou derive o estado de fluxos do Repository.

Passar Context para o ViewModel

Guardar Activity ou Context pode causar vazamento e acoplamento. Para textos, a UI pode resolver resources; para serviços do sistema, use uma abstração injetada quando necessário.

Chamar Retrofit diretamente em cada tela

O ViewModel passa a conhecer DTO, política de cache e detalhes HTTP. Um Repository concentra essas decisões e permite evoluir para Room ou offline-first sem reescrever a tela.

Criar um estado booleano para cada detalhe

isLoading, hasError, isEmpty, isRefreshing e listas em fluxos separados podem gerar combinações contraditórias. Modele um UiState coerente e publique alterações relacionadas juntas.

Usar arquitetura como cerimônia

MVVM não exige dezenas de arquivos por tela. Uma tela simples pode ter Screen, ViewModel, UiState e um Repository compartilhado. Comece pequeno e extraia responsabilidades quando a complexidade aparecer.

Checklist de MVVM Android moderno

Antes de considerar a tela pronta, confirme:

  • a View apenas renderiza estado e envia ações;
  • o ViewModel não guarda referências a componentes visuais;
  • o estado público é imutável;
  • Compose coleta Flow com lifecycle;
  • rede, banco e cache ficam atrás de um Repository;
  • carregamento, conteúdo vazio e erro têm representação explícita;
  • operações assíncronas usam viewModelScope;
  • regras importantes têm testes unitários;
  • navegação permanece na camada de UI;
  • as camadas existem por necessidade, não por copiar um template.

Próximos passos

A melhor forma de aprender arquitetura MVVM com Kotlin é implementar uma feature completa: listar dados de uma API, armazenar em Room, expor StateFlow, renderizar no Compose e testar o ViewModel com um Repository falso.

Depois deste guia, avance nesta ordem:

  1. Jetpack Compose do básico ao avançado;
  2. Kotlin Flow na prática;
  3. Hilt no Android com módulos, scopes e testes;
  4. Clean Architecture com Kotlin;
  5. Android offline-first.

MVVM não é um objetivo por si só. Ele é uma forma de manter o fluxo da tela previsível, proteger regras e fontes de dados das mudanças de interface e tornar o app mais fácil de testar. Com ViewModel, StateFlow, Compose e um Repository bem definido, você já tem uma base sólida para a maioria das aplicações Android modernas.