Por que tratar erros e estados de tela é parte do “core” do app
Em apps reais, falhas são inevitáveis: internet instável, servidor fora do ar, respostas inesperadas, validações rejeitadas. O objetivo não é “evitar todo erro”, e sim criar uma estratégia consistente para: (1) identificar o tipo de falha, (2) representar isso no fluxo de dados, (3) refletir na UI com estados claros e (4) orientar o usuário com mensagens acionáveis (o que ele pode fazer agora).
Para isso, vamos padronizar dois pilares:
- Wrapper de resultado para representar sucesso/erro de forma tipada (ex.: Resource/Either).
- UI State para representar Loading/Success/Empty/Error sem inconsistências.
Estratégia consistente de erros (taxonomia prática)
Uma taxonomia simples e útil para apps iniciantes (e escalável) é separar erros por origem e por ação recomendada:
- Rede/Conexão: sem internet, DNS, conexão recusada. Ação: “Verifique sua conexão e tente novamente”.
- Timeout: servidor demorou demais. Ação: “Tentar novamente”.
- Servidor (HTTP 5xx): falha do backend. Ação: “Tentar novamente mais tarde”.
- Cliente/Autorização (HTTP 401/403): sessão expirada/sem permissão. Ação: “Entrar novamente”.
- Validação (HTTP 400/422): dados inválidos (ex.: e-mail). Ação: “Corrigir campos”.
- Parsing/Resposta inválida: JSON inesperado, campos ausentes. Ação: “Atualize o app” ou “Tente novamente”; logar para diagnóstico.
- Desconhecido: qualquer coisa fora do esperado. Ação: “Tentar novamente”.
O ponto-chave: não exponha exceções cruas para a UI. A UI deve receber um erro “do domínio” com mensagem amigável e, quando possível, uma ação.
Wrapper de resultado: Resource + AppError
Vamos criar um wrapper simples e muito comum: Resource para sucesso/erro/carregando e um AppError para categorizar falhas.
- Ouça o áudio com a tela desligada
- Ganhe Certificado após a conclusão
- + de 5000 cursos para você explorar!
Baixar o aplicativo
1) Modelando o erro do app
sealed class AppError(open val cause: Throwable? = null) { data class Network(override val cause: Throwable? = null) : AppError(cause) data class Timeout(override val cause: Throwable? = null) : AppError(cause) data class Server(val code: Int, override val cause: Throwable? = null) : AppError(cause) data class Unauthorized(override val cause: Throwable? = null) : AppError(cause) data class Validation(val fieldErrors: Map<String, String> = emptyMap(), override val cause: Throwable? = null) : AppError(cause) data class Parsing(override val cause: Throwable? = null) : AppError(cause) data class Unknown(override val cause: Throwable? = null) : AppError(cause)}2) Wrapper de resultado
sealed class Resource<out T> { data object Loading : Resource<Nothing>() data class Success<T>(val data: T) : Resource<T>() data class Error(val error: AppError) : Resource<Nothing>()}Esse wrapper é útil para fluxos com Flow ou chamadas suspensas. Você pode emitir Loading, depois Success ou Error.
Mapeando exceções/HTTP para AppError (camada de dados)
O mapeamento deve acontecer o mais próximo possível da origem (normalmente no repositório ou em um helper), para que a UI não precise conhecer Retrofit/HTTP/exceções.
1) Função utilitária para mapear falhas
import java.io.IOExceptionimport java.net.SocketTimeoutExceptionimport retrofit2.HttpExceptionimport com.squareup.moshi.JsonDataExceptionimport com.squareup.moshi.JsonEncodingExceptionfun Throwable.toAppError(): AppError = when (this) { is SocketTimeoutException -> AppError.Timeout(this) is IOException -> AppError.Network(this) is JsonDataException, is JsonEncodingException -> AppError.Parsing(this) is HttpException -> { when (code()) { 401, 403 -> AppError.Unauthorized(this) 400, 422 -> AppError.Validation(cause = this) in 500..599 -> AppError.Server(code(), this) else -> AppError.Unknown(this) } } else -> AppError.Unknown(this)}Observação: o tipo de parsing depende do conversor JSON (Moshi/Gson/Kotlinx). Ajuste as exceções conforme seu projeto.
2) Padronizando execução segura (safeCall)
Para evitar repetir try/catch em todo repositório, crie um helper:
suspend fun <T> safeCall(block: suspend () -> T): Resource<T> { return try { Resource.Success(block()) } catch (t: Throwable) { Resource.Error(t.toAppError()) }}Agora o repositório pode ficar limpo:
class ProductsRepository(private val api: ProductsApi) { suspend fun getProducts(): Resource<List<Product>> = safeCall { api.getProducts() }}Mensagens amigáveis e acionáveis (mapeamento para UI)
O erro do domínio (AppError) não precisa carregar texto pronto. Uma abordagem simples é mapear para um modelo de mensagem com ação sugerida.
1) Modelo de mensagem para UI
data class UiMessage( val title: String, val description: String, val primaryAction: String? = null, val secondaryAction: String? = null)2) Mapper AppError -> UiMessage
fun AppError.toUiMessage(): UiMessage = when (this) { is AppError.Network -> UiMessage( title = "Sem conexão", description = "Verifique sua internet e tente novamente.", primaryAction = "Tentar novamente" ) is AppError.Timeout -> UiMessage( title = "Demorou demais", description = "A resposta está levando mais tempo que o esperado.", primaryAction = "Tentar novamente" ) is AppError.Server -> UiMessage( title = "Serviço indisponível", description = "O servidor apresentou instabilidade (código ${code}).", primaryAction = "Tentar novamente" ) is AppError.Unauthorized -> UiMessage( title = "Sessão expirada", description = "Entre novamente para continuar.", primaryAction = "Entrar" ) is AppError.Validation -> UiMessage( title = "Dados inválidos", description = "Revise os campos e tente novamente." ) is AppError.Parsing -> UiMessage( title = "Resposta inesperada", description = "O app recebeu dados em um formato inesperado.", primaryAction = "Tentar novamente" ) is AppError.Unknown -> UiMessage( title = "Algo deu errado", description = "Tente novamente.", primaryAction = "Tentar novamente" )}Esse mapeamento permite que a UI apresente textos consistentes e ações claras, sem depender de detalhes técnicos.
Padrões de UI State: Loading / Success / Empty / Error
Resource é ótimo para “resultado de uma chamada”. Já a tela precisa de um estado mais específico: lista vazia, conteúdo carregado, erro com retry, etc. Um padrão comum é um UiState dedicado por tela.
1) Definindo o estado da tela
data class ProductsUiState( val isLoading: Boolean = false, val items: List<Product> = emptyList(), val errorMessage: UiMessage? = null) { val isEmpty: Boolean get() = !isLoading && errorMessage == null && items.isEmpty()}Esse modelo evita um sealed class gigante e facilita manter conteúdo durante refresh (importante para evitar flicker).
2) Atualizando o estado no ViewModel (sem flicker)
O erro mais comum é “zerar a lista” ao iniciar loading, causando piscadas. Em vez disso, mantenha o conteúdo e sinalize loading separadamente.
class ProductsViewModel(private val repo: ProductsRepository) : ViewModel() { private val _uiState = MutableStateFlow(ProductsUiState()) val uiState: StateFlow<ProductsUiState> = _uiState fun loadProducts() { viewModelScope.launch { _uiState.update { it.copy(isLoading = true, errorMessage = null) } when (val result = repo.getProducts()) { is Resource.Success -> { _uiState.update { it.copy(isLoading = false, items = result.data, errorMessage = null) } } is Resource.Error -> { _uiState.update { it.copy(isLoading = false, errorMessage = result.error.toUiMessage()) } } Resource.Loading -> Unit } } }}Repare que:
- Ao carregar, limpamos apenas o erro anterior (
errorMessage = null), mas não apagamositems. - Se falhar, mantemos a lista anterior (se existir) e mostramos um erro (pode ser banner/snackbar).
Evitando estados inconsistentes (regras práticas)
- Uma fonte de verdade: a UI deve observar apenas
uiState(não misture flags soltas em vários lugares). - Não derive estado na UI a partir de múltiplas fontes (ex.:
loadingLiveData+itemsFlow), pois pode desincronizar. - Atualizações atômicas: use
update { ... }para alterar campos relacionados juntos. - Loading inicial vs refresh: se já há conteúdo, prefira um indicador discreto (ex.: swipe refresh) em vez de tela cheia.
- Erro de tela cheia vs erro não bloqueante: se não há conteúdo, erro pode ocupar a tela; se há conteúdo, use snackbar/banner e mantenha a lista.
Exemplo de renderização de estados (pseudocódigo de UI)
Independente de View system ou Compose, a lógica é parecida:
if (state.isLoading && state.items.isEmpty()) { showFullScreenLoading()} else if (state.errorMessage != null && state.items.isEmpty()) { showFullScreenError( title = state.errorMessage.title, description = state.errorMessage.description, action = state.errorMessage.primaryAction )} else if (state.isEmpty) { showEmptyState( title = "Nada por aqui", description = "Tente ajustar os filtros ou recarregar." )} else { showContentList(state.items) if (state.isLoading) showInlineLoadingIndicator() if (state.errorMessage != null) showSnackbar(state.errorMessage.description)}Prática: simulação de falhas e testes manuais orientados
Você vai validar se sua estratégia de erros e estados está funcionando sem depender de “dar sorte” de a rede cair. Abaixo estão cenários e o que observar.
Checklist de observação (antes de testar)
- A tela mostra loading apenas quando faz sentido (inicial vs refresh).
- Em falha, a mensagem é amigável e sugere uma ação.
- Não há “piscadas” (flicker) de lista sumindo e voltando.
- Não fica preso em loading infinito.
- O botão “Tentar novamente” realmente chama
loadProducts().
Cenário 1: Modo avião (erro de rede)
Passo a passo:
- Ative o modo avião no dispositivo/emulador.
- Abra a tela que carrega dados remotos e acione o carregamento.
- Desative o modo avião e toque em “Tentar novamente”.
Esperado:
- Erro categorizado como
AppError.Network. - Mensagem “Sem conexão” + ação “Tentar novamente”.
- Se havia conteúdo anterior, ele permanece visível (erro não bloqueante).
Cenário 2: Timeout (rede lenta)
Passo a passo:
- No emulador, use as configurações de rede para simular baixa velocidade/alta latência (quando disponível).
- Acione o carregamento.
Esperado:
- Erro
AppError.Timeout. - Mensagem indicando demora e opção de tentar novamente.
Cenário 3: Resposta HTTP 500 (erro de servidor)
Passo a passo (opções):
- Se você controla um endpoint de teste, aponte para uma rota que retorne 500.
- Ou use um interceptor no OkHttp para forçar 500 em um endpoint específico durante debug.
class Force500Interceptor : Interceptor { override fun intercept(chain: Interceptor.Chain): Response { val request = chain.request() if (request.url.encodedPath.contains("/products")) { return Response.Builder() .request(request) .protocol(Protocol.HTTP_1_1) .code(500) .message("Internal Server Error") .body("{}".toResponseBody("application/json".toMediaType())) .build() } return chain.proceed(request) }}Esperado:
- Erro
AppError.Server(code=500). - Mensagem “Serviço indisponível” e ação “Tentar novamente”.
Cenário 4: JSON inválido (erro de parsing)
Passo a passo:
- Durante debug, force uma resposta com JSON malformado via interceptor.
class InvalidJsonInterceptor : Interceptor { override fun intercept(chain: Interceptor.Chain): Response { val request = chain.request() if (request.url.encodedPath.contains("/products")) { val invalidJson = "{ invalid_json: true " return Response.Builder() .request(request) .protocol(Protocol.HTTP_1_1) .code(200) .message("OK") .body(invalidJson.toResponseBody("application/json".toMediaType())) .build() } return chain.proceed(request) }}Esperado:
- Erro
AppError.Parsing. - Mensagem “Resposta inesperada”.
- O app não crasha; o estado de loading é encerrado.
Cenário 5: Erros de validação (400/422)
Passo a passo:
- Envie um payload inválido (ex.: campo obrigatório vazio) para um endpoint de criação/atualização.
- Simule 422 no interceptor se não houver backend de teste.
Esperado:
- Erro
AppError.Validation. - UI destaca campos inválidos (quando aplicável) e mostra mensagem “Revise os campos”.
Tabela de referência rápida: erro → UI
| Tipo | Quando acontece | Mensagem sugerida | Ação |
|---|---|---|---|
| Network | Sem internet / falha IO | Verifique sua conexão | Tentar novamente |
| Timeout | Demora excessiva | Demorou demais | Tentar novamente |
| Server (5xx) | Backend instável | Serviço indisponível | Tentar novamente |
| Unauthorized | 401/403 | Sessão expirada | Entrar |
| Validation | 400/422 | Dados inválidos | Corrigir campos |
| Parsing | JSON inesperado | Resposta inesperada | Tentar novamente |
| Unknown | Outros | Algo deu errado | Tentar novamente |
Passo a passo: aplicando o padrão em uma tela existente
Passo 1: Crie AppError e Resource
Adicione as classes AppError e Resource em um pacote comum (ex.: core ou common).
Passo 2: Implemente o mapeamento Throwable → AppError
Crie toAppError() e use no safeCall.
Passo 3: Atualize o repositório para retornar Resource
Troque retornos diretos por Resource.Success/Resource.Error.
Passo 4: Crie UiState da tela
Inclua isLoading, dados e errorMessage. Adicione isEmpty como derivado.
Passo 5: Ajuste o ViewModel para evitar flicker
No loading, não limpe dados; apenas marque loading e limpe erro anterior.
Passo 6: Renderize estados com prioridade clara
Defina a ordem: loading inicial → erro sem conteúdo → empty → conteúdo (com loading/erro não bloqueantes).
Passo 7: Rode os testes manuais de falha
Use modo avião, interceptors de debug e valide se a UI permanece consistente.