Arquitetura Android com Kotlin: camadas, MVVM e fluxo de dados

Capítulo 8

Tempo estimado de leitura: 10 minutos

+ Exercício

Visão de arquitetura por camadas (UI, Domínio opcional, Dados)

Arquitetura, no contexto de apps Android, é a forma de organizar código para que cada parte tenha uma responsabilidade clara, seja testável e evolua sem “efeito dominó”. A abordagem mais comum é separar o app em camadas, onde as dependências apontam sempre para dentro (camadas externas dependem das internas, nunca o contrário).

Camadas e responsabilidades

  • UI (apresentação): telas (Compose/Views), ViewModel, mapeamento de estado para componentes visuais, tratamento de eventos do usuário. Não conhece detalhes de rede, banco, Retrofit, Room etc.
  • Domínio (opcional): regras de negócio e casos de uso (use cases). É útil quando há lógica reutilizável, validações, orquestração de múltiplas fontes de dados, ou quando você quer manter a UI bem “fina”. Em apps pequenos pode ser omitida e a ViewModel conversa direto com o repositório.
  • Dados: implementação de acesso a dados (API, banco, cache), mapeamento DTO/entidades, repositórios concretos. Expõe interfaces para o domínio/UI consumirem sem saber como os dados são obtidos.

Regra de dependências (o que pode conhecer o quê)

  • UI depende de Domínio (se existir) e/ou de interfaces de repositório.
  • Domínio depende apenas de abstrações (interfaces) e modelos do domínio.
  • Dados implementa as interfaces (repositórios) e depende de bibliotecas de infraestrutura (Retrofit/Room/etc.).

Organização de pastas sugerida (um módulo)

com.seuapp.feature.tasks  (exemplo de feature)  ui/    TasksScreen.kt    TasksViewModel.kt    model/ (UiState, UiEvent)  domain/    model/Task.kt    usecase/GetTasksUseCase.kt    usecase/ToggleTaskUseCase.kt    repository/TasksRepository.kt  data/    remote/TasksApi.kt    local/TasksDao.kt (opcional)    repository/TasksRepositoryImpl.kt    mapper/TaskMappers.kt

Você pode organizar por “feature” (recomendado) para manter tudo relacionado junto. Dentro da feature, separe por camadas.

MVVM na prática: ViewModel, Repository e (quando fizer sentido) Use Cases

No MVVM, a ViewModel é a ponte entre UI e dados/regra de negócio. Ela expõe estado observável para a UI e recebe ações do usuário (intents). O repositório encapsula a origem dos dados. Use cases entram como uma camada de orquestração e regra de negócio, evitando que a ViewModel vire um “Deus-objeto”.

Quando criar Use Cases?

  • Crie quando: há regra de negócio, validação, transformação complexa, reuso entre telas, combinação de múltiplos repositórios, ou quando quer testes mais focados.
  • Pode pular quando: a tela só faz um “fetch simples” e exibe, sem lógica adicional.

Fluxo de dados: estado de tela (UiState) e eventos únicos (UiEvent)

Um erro comum é misturar “estado” com “eventos”. Estado é o que a tela é agora (carregando, dados, erro). Evento é algo que acontece uma vez (mostrar snackbar, navegar, abrir diálogo).

Representando estado com sealed class

Use uma sealed class para modelar estados mutuamente exclusivos. Isso reduz ifs espalhados e torna o código mais seguro.

Continue em nosso aplicativo e ...
  • Ouça o áudio com a tela desligada
  • Ganhe Certificado após a conclusão
  • + de 5000 cursos para você explorar!
ou continue lendo abaixo...
Download App

Baixar o aplicativo

sealed class UiState<out T> {  data object Idle : UiState<Nothing>()  data object Loading : UiState<Nothing>()  data class Success<T>(val data: T) : UiState<T>()  data class Error(val message: String, val cause: Throwable? = null) : UiState<Nothing>()}

Para uma tela específica, você pode ter um estado mais rico (com campos), mas a sealed class é ótima para o “macroestado”.

Eventos únicos (UiEvent)

Eventos não devem ficar “presos” no estado, porque o estado pode ser reemitido em recomposições/rotações e o evento dispararia de novo. Modele eventos como um fluxo separado.

sealed class UiEvent {  data class ShowMessage(val text: String) : UiEvent()  data object NavigateBack : UiEvent()  data class NavigateTo(val route: String) : UiEvent()}

StateFlow para estado e SharedFlow/Channel para eventos

Uma combinação comum:

  • StateFlow para estado (sempre tem valor atual).
  • SharedFlow (ou Channel) para eventos (consumidos uma vez).
class TasksViewModel(  private val getTasks: GetTasksUseCase,  private val toggleTask: ToggleTaskUseCase) : ViewModel() {  private val _uiState = MutableStateFlow<UiState<List<Task>>>(UiState.Idle)  val uiState: StateFlow<UiState<List<Task>>> = _uiState  private val _events = MutableSharedFlow<UiEvent>(extraBufferCapacity = 1)  val events: SharedFlow<UiEvent> = _events  fun load() {    viewModelScope.launch {      _uiState.value = UiState.Loading      runCatching { getTasks() }        .onSuccess { tasks -> _uiState.value = UiState.Success(tasks) }        .onFailure { e -> _uiState.value = UiState.Error(message = "Falha ao carregar", cause = e) }    }  }  fun onToggle(taskId: String) {    viewModelScope.launch {      runCatching { toggleTask(taskId) }        .onFailure { _events.tryEmit(UiEvent.ShowMessage("Não foi possível atualizar")) }    }  }}

Note que a ViewModel não sabe se os dados vêm de rede, banco ou cache. Ela só chama use cases.

Mini-projeto guiado: “Lista de Tarefas” com camadas + MVVM

Objetivo: criar uma feature simples com lista de tarefas, alternar concluída/não concluída, e simular carregamento/erro. O foco é a organização do código e o fluxo de dados.

1) Modelos do domínio

Crie o modelo que representa o que o app precisa, sem detalhes de API.

data class Task(  val id: String,  val title: String,  val done: Boolean)

2) Contrato do repositório (domínio)

Defina uma interface que descreve o que a feature precisa.

interface TasksRepository {  suspend fun getTasks(): List<Task>  suspend fun toggleTask(taskId: String): Task}

3) Implementação de dados (fake para começar)

Para aprender arquitetura, comece com uma implementação em memória. Depois você troca por rede/banco sem mexer na UI.

class InMemoryTasksRepository : TasksRepository {  private val tasks = mutableListOf(    Task("1", "Estudar MVVM", false),    Task("2", "Criar UiState", true),    Task("3", "Testar ViewModel", false)  )  override suspend fun getTasks(): List<Task> {    // Simula latência    kotlinx.coroutines.delay(400)    return tasks.toList()  }  override suspend fun toggleTask(taskId: String): Task {    val index = tasks.indexOfFirst { it.id == taskId }    if (index == -1) error("Task não encontrada")    val updated = tasks[index].copy(done = !tasks[index].done)    tasks[index] = updated    return updated  }}

4) Use cases (domínio)

Use cases encapsulam ações do usuário e regras. Aqui eles são simples, mas já criam um ponto de extensão.

class GetTasksUseCase(private val repository: TasksRepository) {  suspend operator fun invoke(): List<Task> = repository.getTasks()}class ToggleTaskUseCase(private val repository: TasksRepository) {  suspend operator fun invoke(taskId: String): Task = repository.toggleTask(taskId)}

5) Estado e eventos da tela

Você pode usar o genérico UiState<T> mostrado antes, ou criar um estado específico. Aqui vamos manter o genérico e um evento simples.

sealed class TasksEvent {  data class ShowMessage(val text: String) : TasksEvent()}

6) ViewModel (UI)

A ViewModel coordena o carregamento e atualizações, mantendo a UI reativa.

class TasksViewModel(  private val getTasks: GetTasksUseCase,  private val toggleTask: ToggleTaskUseCase) : ViewModel() {  private val _state = MutableStateFlow<UiState<List<Task>>>(UiState.Idle)  val state: StateFlow<UiState<List<Task>>> = _state  private val _events = MutableSharedFlow<TasksEvent>(extraBufferCapacity = 1)  val events: SharedFlow<TasksEvent> = _events  init {    loadTasks()  }  fun loadTasks() {    viewModelScope.launch {      _state.value = UiState.Loading      runCatching { getTasks() }        .onSuccess { _state.value = UiState.Success(it) }        .onFailure { _state.value = UiState.Error("Erro ao carregar", it) }    }  }  fun onTaskClicked(taskId: String) {    viewModelScope.launch {      runCatching { toggleTask(taskId) }        .onSuccess { updated ->          val current = (_state.value as? UiState.Success)?.data.orEmpty()          val newList = current.map { if (it.id == updated.id) updated else it }          _state.value = UiState.Success(newList)        }        .onFailure { _events.tryEmit(TasksEvent.ShowMessage("Falha ao atualizar")) }    }  }}

7) Injeção simples (sem framework) para começar

Para manter o foco, você pode criar os objetos manualmente em um ponto de composição (por exemplo, em uma função de fábrica da feature). Mais tarde, você troca por Hilt/Koin sem alterar as camadas.

object TasksFactory {  fun createViewModel(): TasksViewModel {    val repository: TasksRepository = InMemoryTasksRepository()    val getTasks = GetTasksUseCase(repository)    val toggle = ToggleTaskUseCase(repository)    return TasksViewModel(getTasks, toggle)  }}

8) UI consumindo state e events (exemplo em Compose)

O ponto principal é: a UI renderiza com base no estado e coleta eventos para ações únicas.

@Composablefun TasksScreen(viewModel: TasksViewModel) {  val state by viewModel.state.collectAsState()  val snackbarHostState = remember { SnackbarHostState() }  LaunchedEffect(Unit) {    viewModel.events.collect { event ->      when (event) {        is TasksEvent.ShowMessage -> snackbarHostState.showSnackbar(event.text)      }    }  }  Scaffold(snackbarHost = { SnackbarHost(snackbarHostState) }) { padding ->    when (val s = state) {      UiState.Idle -> Unit      UiState.Loading -> Text("Carregando...", modifier = Modifier.padding(padding))      is UiState.Error -> Text("${s.message}", modifier = Modifier.padding(padding))      is UiState.Success -> {        Column(Modifier.padding(padding)) {          s.data.forEach { task ->            Row(Modifier.fillMaxWidth().clickable { viewModel.onTaskClicked(task.id) }) {              Checkbox(checked = task.done, onCheckedChange = { viewModel.onTaskClicked(task.id) })              Text(task.title)            }          }        }      }    }  }}

Mesmo que você não esteja usando Compose, a ideia é a mesma: observar StateFlow para renderizar e coletar SharedFlow para eventos.

Testes de unidade básicos para ViewModel

O objetivo do teste de ViewModel é validar: (1) transições de estado, (2) chamadas para dependências, (3) emissão de eventos em falhas. Para isso, você normalmente usa um repositório fake e controla corrotinas.

Dependências comuns de teste

// build.gradle (Module) - exemplo de dependências de teste testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3") testImplementation("junit:junit:4.13.2")

Regra para substituir Dispatchers.Main

Como a ViewModel usa viewModelScope (Main), em testes você substitui o Main por um dispatcher de teste.

@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)class MainDispatcherRule(  private val dispatcher: kotlinx.coroutines.test.TestDispatcher = kotlinx.coroutines.test.StandardTestDispatcher()) : org.junit.rules.TestWatcher() {  override fun starting(description: org.junit.runner.Description) {    kotlinx.coroutines.Dispatchers.setMain(dispatcher)  }  override fun finished(description: org.junit.runner.Description) {    kotlinx.coroutines.Dispatchers.resetMain()  }}

Fake repository controlável

class FakeTasksRepository : TasksRepository {  var shouldFailGet = false  private val tasks = mutableListOf(Task("1", "A", false))  override suspend fun getTasks(): List<Task> {    if (shouldFailGet) error("boom")    return tasks.toList()  }  override suspend fun toggleTask(taskId: String): Task {    val t = tasks.first { it.id == taskId }    val updated = t.copy(done = !t.done)    tasks[0] = updated    return updated  }}

Teste: loadTasks emite Loading e depois Success

@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)class TasksViewModelTest {  @get:org.junit.Rule  val mainDispatcherRule = MainDispatcherRule()  @org.junit.Test  fun loadTasks_emiteLoadingDepoisSuccess() = kotlinx.coroutines.test.runTest {    val repo = FakeTasksRepository()    val vm = TasksViewModel(      getTasks = GetTasksUseCase(repo),      toggleTask = ToggleTaskUseCase(repo)    )    // init chama loadTasks; avance o dispatcher para executar corrotinas pendentes    kotlinx.coroutines.test.advanceUntilIdle()    val state = vm.state.value    assert(state is UiState.Success<*>)    val data = (state as UiState.Success).data    assert(data.size == 1)  }}

Teste: falha no carregamento resulta em Error

@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)@org.junit.Testfun loadTasks_quandoFalha_emiteError() = kotlinx.coroutines.test.runTest {  val repo = FakeTasksRepository().apply { shouldFailGet = true }  val vm = TasksViewModel(GetTasksUseCase(repo), ToggleTaskUseCase(repo))  kotlinx.coroutines.test.advanceUntilIdle()  assert(vm.state.value is UiState.Error)}

Teste: falha no toggle emite evento

Para testar eventos, você coleta do flow em uma corrotina de teste.

@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)@org.junit.Testfun onTaskClicked_quandoFalha_emiteShowMessage() = kotlinx.coroutines.test.runTest {  val repo = object : TasksRepository {    override suspend fun getTasks() = listOf(Task("1","A",false))    override suspend fun toggleTask(taskId: String): Task { error("fail") }  }  val vm = TasksViewModel(GetTasksUseCase(repo), ToggleTaskUseCase(repo))  kotlinx.coroutines.test.advanceUntilIdle()  val events = mutableListOf<TasksEvent>()  val job = launch {    vm.events.collect { events.add(it) }  }  vm.onTaskClicked("1")  kotlinx.coroutines.test.advanceUntilIdle()  assert(events.any { it is TasksEvent.ShowMessage })  job.cancel()}

Checklist rápido: arquitetura saudável

ItemO que verificar
UI finaSem chamadas diretas a Retrofit/Room; sem regra de negócio complexa.
ViewModel previsívelEstado em StateFlow, eventos em SharedFlow/Channel, funções públicas representam ações do usuário.
Repositório com contratoInterface no domínio/feature, implementação em data.
Use cases quando necessárioOrquestram regras e facilitam testes.
TestesTesta transições de estado e emissão de eventos com fakes.

Agora responda o exercício sobre o conteúdo:

Em uma arquitetura Android por camadas usando MVVM, qual abordagem melhor evita que eventos únicos (como mostrar uma mensagem ou navegar) sejam disparados novamente após recomposições ou rotações?

Você acertou! Parabéns, agora siga para a próxima página

Você errou! Tente novamente.

Eventos únicos não devem ficar no estado, pois o estado pode ser reemitido e o evento dispararia novamente. A prática recomendada é usar StateFlow para estado e um fluxo separado como SharedFlow/Channel para eventos consumidos uma vez.

Próximo capitúlo

Boas práticas Kotlin no Android: null safety, extensions, coroutines e legibilidade

Arrow Right Icon
Capa do Ebook gratuito Android para Iniciantes com Kotlin: construindo seu primeiro app moderno
53%

Android para Iniciantes com Kotlin: construindo seu primeiro app moderno

Novo curso

15 páginas

Baixe o app para ganhar Certificação grátis e ouvir os cursos em background, mesmo com a tela desligada.