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.ktVocê 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.
- Ouça o áudio com a tela desligada
- Ganhe Certificado após a conclusão
- + de 5000 cursos para você explorar!
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:
StateFlowpara estado (sempre tem valor atual).SharedFlow(ouChannel) 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
| Item | O que verificar |
|---|---|
| UI fina | Sem chamadas diretas a Retrofit/Room; sem regra de negócio complexa. |
| ViewModel previsível | Estado em StateFlow, eventos em SharedFlow/Channel, funções públicas representam ações do usuário. |
| Repositório com contrato | Interface no domínio/feature, implementação em data. |
| Use cases quando necessário | Orquestram regras e facilitam testes. |
| Testes | Testa transições de estado e emissão de eventos com fakes. |