Todo tutorial de ViewModel que li me entregava o arquivo final: @HiltViewModel, um MutableStateFlow privado, um StateFlow público, viewModelScope.launch, uma classe selada. Compilou. Copiei. Entendi nada.
O problema de começar com código finalizado é que parece arbitrário. Por que duas propriedades ao invés de uma? Por que init? Por que começar em Loading?
Então fiz no sentido contrário. Joguei o código fora e perguntei: quais problemas este item está realmente resolvendo? Existem 8. Empilhe as 8 respostas e o arquivo escreve-se sozinho — nada nele é uma escolha de estilo.
Aqui está a versão que eu gostaria de ter tido.
A Versão para Crianças de 6 Anos
Imagine um quadro branco na parede de uma sala. Qualquer pessoa na sala pode ler o quadro, mas apenas você segura a caneta. Quando você muda o que está escrito, todos olhando para ele veem imediatamente a nova coisa — ninguém precisa perguntar "já mudou?".
O quadro branco é StateFlow. A regra de que apenas você segura a caneta é por que há duas propriedades ao invés de uma. E o quadro pendura na parede (o ViewModel), não em uma pessoa — então quando alguém sai da sala, a escrita sobrevive.
Agora a versão real.
Primeiro: o que uma tela realmente pode estar fazendo?
Antes do ViewModel, um observação: uma tela de lista está sempre em exatamente uma das três situações. Está carregando, foi bem-sucedida ou falhou. É isso.
sealed interface PokemonListUiState {
object Loading : PokemonListUiState
data class Success(val pokemon: List<PokemonResult>) : PokemonListUiState
data class Error(val message: String) : PokemonListUiState
}
sealed significa conjunto fechado — "estas são as únicas 3, nada mais pode se juntar." O compilador sabe a lista completa, então quando a tela faz when (state), ele força você a lidar com todas três. Você não esquece o caso de erro.
Por que não um enum? Porque cada caso carrega carga diferente: Success guarda uma lista, Error guarda uma mensagem, Loading guarda nada. Enums não conseguem fazer isso limpo. E observe que Loading é um object, não um data class — ele não carrega dados, então uma instância compartilhada única é suficiente.
Agora, os 8 problemas.
Problema 1: O telêmetro é frágil
Rode o telefone e o Android destruirá e reconstruirá sua tela. Se a tela mantinha os dados, eles desapareceram — você refazeria a solicitação da rede em cada rotação. Você precisa de algo que durar mais do que a tela.
Solução: um ViewModel. Isso é literalmente seu trabalho.
class PokemonListViewModel : ViewModel()
Problema 2: O ViewModel precisa de um repositório — mas não deveria construir um
Construir um significa construir Retrofit, o que significa OkHttp, o que significa uma pool de conexões... tudo para pedir uma lista de Pokémon. Nós apenas queremos fazer a pergunta, não montar isso.
Solução: deixe o Hilt entregar.
@HiltViewModel
class PokemonListViewModel @Inject constructor(
private val repository: PokemonRepository
) : ViewModel()
Note que ele pede PokemonRepository — a interface, não a implementação. O ViewModel não deve saber ou se importar de onde vieram os dados.
Problema 3: Os dados chegam MAIS TARDE — como a tela sabe?
A tela já se desenhou enquanto a rede ainda estava trabalhando. Duas opções: a tela continua verificando (
MutableStateFlow<PokemonListUiState>(...)
Defina .value e todos que estão observando são notificados imediatamente. Push, não poll. Isso é o quadro branco.
Problema 4: Mas se a tela pode observar, ela também pode alterá-lo?
Isto seria um desastre. Qualquer tela poderia escrever lixo: viewModel.uiState.value = Success(fakeList). O estado mudaria de qualquer lugar do aplicativo e quando um bug aparecesse você teria que caçar cada arquivo que pudesse tê-lo escrito.
Solução: expor a leitura, esconder a escrita.
private val _uiState = MutableStateFlow<PokemonListUiState>(PokemonListUiState.Loading)
val uiState: StateFlow<PokemonListUiState> = _uiState.asStateFlow()
MutableStateFlow tem um .value configurável. asStateFlow() retorna uma visão de leitura única dos mesmos dados — sem setter exposto. Agora há exatamente um lugar no código onde o estado pode mudar: dentro deste ViewModel. Essa única fonte da verdade é a real recompensa — é um recurso de depuração.
Isto é a resposta para 'por que duas propriedades em vez de uma.'
Problema 5: Quando o fetch deve começar?
No momento em que o usuário abre a tela. Não ao pressionar um botão, não mais tarde.
Solução: execute-o no instante em que o ViewModel é criado.
init { ... }
Problema 6: A função do repositório é suspend — você não pode apenas chamá-la
suspend funções só são executadas dentro de uma coroutine. Mas em qual coroutine? Escolha um escopo longo (como GlobalScope) e a coroutine continua rodando mesmo após o usuário ter saído da tela — mantendo referências, atualizando estado para algo que não existe mais. Isso é um vazamento de memória.
Solução: use um escopo cuja vida está ligada à do ViewModel.
viewModelScope.launch { ... }
Dois termos para separar: escopo = onde a coroutine vive (viewModelScope, que o Android cancela automaticamente quando o ViewModel morre). builder = o que inicia (launch). Você não cria o escopo; a biblioteca lhe dá um pré-conectado ao ciclo de vida correto.
Problema 7: A rede pode falhar
Sem wifi. Servidor fora do ar. Tempo limite.
Solução: tente, e se explodir, capture a exceção e mostre o erro em vez de fazer crash.
Problema 8: O que o usuário olha antes da resposta chegar?
Tela em branco? Lista vazia?
Solução: comece com o estado Loading, antes de qualquer coisa acontecer.
MutableStateFlow<PokemonListUiState>(PokemonListUiState.Loading)
O usuário vê um spinner imediatamente, e é a verdade: "Estou trabalhando nisso."
Empilhe o 8 e o arquivo se escreve sozinho
@HiltViewModel
class PokemonListViewModel @Inject constructor(
private val repository: PokemonRepository
) : ViewModel() {
private val _uiState = MutableStateFlow<PokemonListUiState>(PokemonListUiState.Loading)
val uiState: StateFlow<PokemonListUiState> = _uiState.asStateFlow()
init {
viewModelScope.launch {
_uiState.value = try {
val response = repository.getPokemonList(limit = 20, offset = 0)
PokemonListUiState.Success(response.results)
} catch (e: Exception) {
PokemonListUiState.Error(e.message ?: "Algo deu errado")
}
}
}
}
Leia como uma frase: sobreviva à tela → peça pelo repositório → mantenha o estado de forma observável → esconda a escrita → comece em Carregando → faça a busca ao nascer → em um escopo que morre com você → lidar com falhas. Nenhuma linha é arbitrária.
Problemas que encontrei
- "MutableStateFlow faz com que sobreviva à rotação." Não — eu combinei dois trabalhos separados. O ViewModel sobrevive (Problema 1). StateFlow notifica (Problema 3). Eles apenas acontecem de viver no mesmo arquivo.
-
LoadingvsSuccess(emptyList()). Eu pensei queLoadingexistia para evitar um crash. Não existe — nada irá cair. É sobre não mentir ao usuário.Loadingsignifica "Estou trabalhando."Success(emptyList())significa "Terminei e encontrei zero Pokémon." Essas são afirmações completamente diferentes, e uma delas é falsa antes que a rede responda. -
e.messageé nuloável.Error(e.message)não compila contra umString. Você precisa de um fallback:e.message ?: "Algo deu errado". - Expor o fluxo mutável "apenas por agora." No momento em que é público, o estado pode mudar de qualquer lugar e sua única fonte da verdade está perdida. Nunca permanece "apenas por agora".
Conclusões prontas para entrevistas
P: Por que duas propriedades — uma privada _uiState e uma pública uiState?
Para que a tela possa ler o estado, mas nunca escrever nele. asStateFlow() expõe uma visão de leitura única do mesmo dado, mantendo o ViewModel como o único lugar onde o estado pode mudar. Com um MutableStateFlow público, qualquer arquivo poderia mutá-lo e os bugs tornam-se inlocalizáveis.
P: Por que usar viewModelScope.launch em vez de GlobalScope.launch?
viewModelScope é automaticamente cancelado quando o ViewModel é destruído. GlobalScope sobrevive à tela — a coroutine continua rodando e mantendo referências para uma tela que o usuário já deixou. Isso é um vazamento de memória.
P: Por que começar em Loading em vez de Success(emptyList())?
Porque você ainda não conseguiu — você nem mesmo perguntou. Success(emptyList()) informa ao usuário "pronto, encontrei nada", o que é uma mentira. Loading diz "Estou trabalhando", o que é verdade e lhes dá um spinner em vez de uma tela vazia falsa.
P: Por que usar uma interface sealed para UiState ao invés de um enum ou booleanos?
Sealed = conjunto fechado, então when é exaustivo e você não pode esquecer um estado. Diferente de um enum, cada caso carrega seus próprios dados (Success → uma lista, Error → uma mensagem). Diferente de isLoading/isError booleanos, você não pode representar combinações impossíveis como loading e error ao mesmo tempo.
TL;DR
A ViewModel não é uma pilha de cerimônias — são 8 respostas empilhadas. Sobreviver à rotação (ViewModel), receber dependências entregues a você (Hilt), fazer o estado observável (StateFlow), manter o marcador para si mesmo (privado mutável + público somente leitura), começar honesto (Loading), buscar ao nascer (init), permanecer em um escopo que morre com você (viewModelScope) e esperar falhas (try/catch).
Memorize o porquê de cada uma. Procure a sintaxe — ninguém se lembra como escrever asStateFlow().

