A arquitetura MVVM (Model-View-ViewModel) se consolidou como o padrao mais adotado no desenvolvimento Android moderno, especialmente quando combinada com Kotlin e os componentes do Jetpack. Diferente de abordagens mais antigas como MVC, o MVVM promove uma separacao clara de responsabilidades que resulta em codigo mais testavel, manutenivel e resiliente a mudancas de configuracao. Neste guia, vamos dissecar cada camada do MVVM, implementar exemplos praticos e explorar as melhores estrategias para projetos do mundo real.

O Que e a Arquitetura MVVM

O MVVM divide a aplicacao em tres camadas distintas. A camada Model representa os dados e a logica de negocios, incluindo repositorios, fontes de dados e entidades. A camada View e responsavel pela interface grafica – Activities, Fragments ou composables no Jetpack Compose. A camada ViewModel atua como intermediaria, expondo dados da Model para a View de forma reativa e gerenciando o estado da interface.

O fluxo de dados e unidirecional: a View observa o ViewModel, que por sua vez consulta a Model. A View nunca acessa a Model diretamente, e a Model nao conhece nem a View nem o ViewModel. Essa separacao facilita testes unitarios, pois cada camada pode ser testada de forma isolada.

Estrutura de Pastas Recomendada

Uma organizacao clara de pastas facilita a navegacao e manutencao do projeto:

// Estrutura de pacotes recomendada
// com.exemplo.app/
//   data/
//     model/         -> Entidades e data classes
//     repository/    -> Repositorios
//     remote/        -> API services (Retrofit)
//     local/         -> DAOs e banco de dados (Room)
//   ui/
//     lista/         -> ListaFragment + ListaViewModel
//     detalhe/       -> DetalheFragment + DetalheViewModel
//   di/              -> Modulos de injecao de dependencia
//   util/            -> Classes utilitarias

Essa organizacao por feature facilita o crescimento do projeto e torna claro onde cada componente se encontra.

A Camada Model

A Model encapsula os dados e as regras de negocio. Vamos criar um exemplo completo com uma entidade, um servico de API e um repositorio:

// Entidade de dominio
data class Tarefa(
    val id: Long,
    val titulo: String,
    val descricao: String,
    val concluida: Boolean = false,
    val criadaEm: LocalDateTime = LocalDateTime.now()
)

// Interface do servico de API
interface TarefaApiService {
    @GET("tarefas")
    suspend fun listarTarefas(): List<TarefaDto>

    @POST("tarefas")
    suspend fun criarTarefa(@Body tarefa: TarefaDto): TarefaDto

    @PUT("tarefas/{id}")
    suspend fun atualizarTarefa(
        @Path("id") id: Long,
        @Body tarefa: TarefaDto
    ): TarefaDto
}

// Repository que unifica fontes de dados
class TarefaRepository(
    private val apiService: TarefaApiService,
    private val tarefaDao: TarefaDao
) {
    fun observarTarefas(): Flow<List<Tarefa>> {
        return tarefaDao.listarTodas().map { entidades ->
            entidades.map { it.toDomain() }
        }
    }

    suspend fun sincronizar() {
        val tarefasRemotas = apiService.listarTarefas()
        tarefaDao.inserirTodas(tarefasRemotas.map { it.toEntity() })
    }

    suspend fun criarTarefa(tarefa: Tarefa) {
        val dto = tarefa.toDto()
        val resposta = apiService.criarTarefa(dto)
        tarefaDao.inserir(resposta.toEntity())
    }
}

O Repository e o ponto central que decide de onde buscar os dados – cache local, rede ou ambos. Essa abstracao permite trocar a implementacao sem afetar o restante da aplicacao.

A Camada ViewModel

O ViewModel gerencia o estado da UI e sobrevive a mudancas de configuracao. Com Kotlin, podemos usar tanto LiveData quanto StateFlow:

class TarefaViewModel(
    private val repository: TarefaRepository
) : ViewModel() {

    // Estado da UI usando StateFlow
    private val _uiState = MutableStateFlow<TarefaUiState>(TarefaUiState.Loading)
    val uiState: StateFlow<TarefaUiState> = _uiState.asStateFlow()

    // Evento unico (navegacao, snackbar, etc.)
    private val _evento = Channel<TarefaEvento>()
    val evento = _evento.receiveAsFlow()

    init {
        carregarTarefas()
    }

    fun carregarTarefas() {
        viewModelScope.launch {
            _uiState.value = TarefaUiState.Loading
            try {
                repository.sincronizar()
                repository.observarTarefas().collect { tarefas ->
                    _uiState.value = TarefaUiState.Success(tarefas)
                }
            } catch (e: Exception) {
                _uiState.value = TarefaUiState.Error(
                    e.message ?: "Erro desconhecido"
                )
            }
        }
    }

    fun criarTarefa(titulo: String, descricao: String) {
        viewModelScope.launch {
            try {
                val novaTarefa = Tarefa(
                    id = 0,
                    titulo = titulo,
                    descricao = descricao
                )
                repository.criarTarefa(novaTarefa)
                _evento.send(TarefaEvento.TarefaCriada)
            } catch (e: Exception) {
                _evento.send(
                    TarefaEvento.Erro("Falha ao criar tarefa")
                )
            }
        }
    }

    fun alternarConclusao(tarefa: Tarefa) {
        viewModelScope.launch {
            val atualizada = tarefa.copy(concluida = !tarefa.concluida)
            repository.criarTarefa(atualizada)
        }
    }
}

// Sealed class para estados da UI
sealed class TarefaUiState {
    object Loading : TarefaUiState()
    data class Success(val tarefas: List<Tarefa>) : TarefaUiState()
    data class Error(val mensagem: String) : TarefaUiState()
}

// Sealed class para eventos unicos
sealed class TarefaEvento {
    object TarefaCriada : TarefaEvento()
    data class Erro(val mensagem: String) : TarefaEvento()
}

A distincao entre estado (StateFlow) e evento (Channel) e fundamental. O estado pode ser re-observado a qualquer momento, enquanto eventos como navegacao ou mensagens de snackbar devem ser consumidos uma unica vez.

A Camada View

A View observa o ViewModel e reage as mudancas de estado:

class TarefaFragment : Fragment(R.layout.fragment_tarefa) {

    private val viewModel: TarefaViewModel by viewModels()
    private var _binding: FragmentTarefaBinding? = null
    private val binding get() = _binding!!
    private lateinit var adapter: TarefaAdapter

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        _binding = FragmentTarefaBinding.bind(view)

        configurarRecyclerView()
        observarEstado()
        observarEventos()

        binding.fabNovaTarefa.setOnClickListener {
            mostrarDialogNovaTarefa()
        }
    }

    private fun configurarRecyclerView() {
        adapter = TarefaAdapter { tarefa ->
            viewModel.alternarConclusao(tarefa)
        }
        binding.recyclerTarefas.adapter = adapter
        binding.recyclerTarefas.layoutManager = LinearLayoutManager(requireContext())
    }

    private fun observarEstado() {
        viewLifecycleOwner.lifecycleScope.launch {
            viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
                viewModel.uiState.collect { state ->
                    when (state) {
                        is TarefaUiState.Loading -> {
                            binding.progressBar.isVisible = true
                            binding.recyclerTarefas.isVisible = false
                        }
                        is TarefaUiState.Success -> {
                            binding.progressBar.isVisible = false
                            binding.recyclerTarefas.isVisible = true
                            adapter.submitList(state.tarefas)
                        }
                        is TarefaUiState.Error -> {
                            binding.progressBar.isVisible = false
                            Snackbar.make(
                                binding.root, state.mensagem,
                                Snackbar.LENGTH_LONG
                            ).show()
                        }
                    }
                }
            }
        }
    }

    private fun observarEventos() {
        viewLifecycleOwner.lifecycleScope.launch {
            viewModel.evento.collect { evento ->
                when (evento) {
                    is TarefaEvento.TarefaCriada -> {
                        Snackbar.make(
                            binding.root, "Tarefa criada!",
                            Snackbar.LENGTH_SHORT
                        ).show()
                    }
                    is TarefaEvento.Erro -> {
                        Snackbar.make(
                            binding.root, evento.mensagem,
                            Snackbar.LENGTH_LONG
                        ).show()
                    }
                }
            }
        }
    }

    override fun onDestroyView() {
        super.onDestroyView()
        _binding = null
    }
}

O uso de repeatOnLifecycle garante que a coleta do Flow respeite o ciclo de vida do Fragment, evitando processamento desnecessario quando a tela nao esta visivel.

StateFlow vs LiveData

Ambos servem para expor estado reativo, mas possuem diferencas importantes. O LiveData e lifecycle-aware por padrao e foi projetado especificamente para Android. O StateFlow faz parte das Coroutines e e mais flexivel, podendo ser usado em camadas que nao dependem do Android SDK.

A recomendacao atual do Google e usar StateFlow em ViewModels modernos, especialmente em projetos que utilizam Jetpack Compose, onde a integracao com Flow e nativa.

Boas Praticas para MVVM com Kotlin

  • Mantenha o ViewModel livre de referencias ao Android: nao passe Context, View ou Activity para o ViewModel. Use AndroidViewModel apenas quando estritamente necessario.
  • Use sealed classes para estados: elas garantem que todos os estados possiveis sejam tratados no when.
  • Separe estado de eventos: use StateFlow para estado e Channel ou SharedFlow para eventos unicos.
  • Injete dependencias: nunca instancie repositorios diretamente no ViewModel. Use Hilt, Koin ou outra solucao de injecao.
  • Evite logica de negocio na View: a View deve apenas observar e renderizar. Toda logica pertence ao ViewModel ou a camada de dominio.
  • Teste o ViewModel isoladamente: com dependencias injetadas, basta criar mocks dos repositorios para testar toda a logica.

Erros Comuns e Armadilhas

  • Coletar Flow sem respeitar o lifecycle: usar lifecycleScope.launch diretamente sem repeatOnLifecycle pode causar coleta em background desnecessaria e crashes.
  • ViewModel fazendo chamadas diretas a API: o ViewModel deve delegar ao Repository, nunca chamar servicos de rede diretamente.
  • Estado mutavel exposto: expor MutableStateFlow ou MutableLiveData publicamente permite que a View altere o estado diretamente, quebrando o fluxo unidirecional. Sempre exponha a versao imutavel.
  • Excesso de ViewModels: nao crie um ViewModel para cada componente pequeno. Um ViewModel por tela geralmente e suficiente.
  • Ignorar tratamento de erros: sempre encapsule chamadas assincronas em try-catch e comunique erros atraves do estado da UI.

Conclusao e Proximos Passos

A arquitetura MVVM com Kotlin proporciona uma base solida para aplicacoes Android de qualquer escala. A separacao entre Model, View e ViewModel resulta em codigo mais organizado, testavel e resistente a mudancas. Com o uso de StateFlow, sealed classes e coroutines, o Kotlin torna a implementacao do MVVM elegante e eficiente.

Para evoluir ainda mais, explore Clean Architecture para adicionar uma camada de dominio ao MVVM, estude Jetpack Compose para interfaces declarativas e implemente testes unitarios completos para seus ViewModels. Esses topicos possuem guias dedicados aqui no Kotlin Brasil e vao complementar o conhecimento adquirido neste artigo.