Tratamento de erros e estados de tela no Android: resiliência e experiência do usuário

Capítulo 13

Tempo estimado de leitura: 10 minutos

+ Exercício

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.

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

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 apagamos items.
  • 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

TipoQuando aconteceMensagem sugeridaAção
NetworkSem internet / falha IOVerifique sua conexãoTentar novamente
TimeoutDemora excessivaDemorou demaisTentar novamente
Server (5xx)Backend instávelServiço indisponívelTentar novamente
Unauthorized401/403Sessão expiradaEntrar
Validation400/422Dados inválidosCorrigir campos
ParsingJSON inesperadoResposta inesperadaTentar novamente
UnknownOutrosAlgo deu erradoTentar 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.

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

Ao iniciar um novo carregamento em uma tela que já possui uma lista exibida, qual abordagem ajuda a evitar “flicker” (piscadas) e manter a UI consistente?

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

Você errou! Tente novamente.

Para evitar flicker, o estado deve manter o conteúdo existente durante o refresh e sinalizar o carregamento separadamente. Ao iniciar, limpe apenas o erro anterior e atualize os campos juntos para não gerar estados inconsistentes.

Próximo capitúlo

Organização e qualidade do projeto Android em Kotlin: modularidade leve, padrões e revisão

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

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.