Glide não carrega a imagem: como achar a causa
Glide preso no placeholder? Faça o Glide mostrar o erro, veja o que o servidor devolve com um script curl e confira cleartext, URL nula e cache da foto.
A foto do produto fica no placeholder para sempre, mas a mesma URL abre normalmente no navegador do computador. Isso não quer dizer que o Glide esteja quebrado. Quase sempre a resposta que chega ao celular é diferente da que o navegador recebe, ou o próprio Android barrou a requisição antes de ela sair. Este roteiro serve para apps com ImageView e RecyclerView usando Glide 5.0.9, a versão atual no Maven Central. Ele vai em três passos: fazer o Glide dizer o erro, ver o que o servidor devolve e checar as regras do Android.
Se o app é em Jetpack Compose, a configuração muda bastante; veja o guia de imagem remota no Compose com Coil 3.
1. Faça o Glide dizer o motivo
Por padrão, uma carga que falha só troca a imagem. O motivo aparece no Logcat ligando a tag do Glide no aparelho, como manda a página de depuração do Glide:
adb shell setprop log.tag.Glide DEBUG
Para registrar a falha no seu próprio log, ou mandar para o serviço de erros do app, use um RequestListener. O GlideException pode ter várias causas raiz, porque o Glide tenta mais de um caminho para carregar cada modelo. O logRootCauses imprime todas:
Glide.with(holder.itemView)
.load(produto.fotoUrl)
.placeholder(R.drawable.foto_carregando)
.error(R.drawable.foto_indisponivel)
.fallback(R.drawable.foto_sem_cadastro)
.listener(object : RequestListener<Drawable> {
override fun onLoadFailed(
e: GlideException?, model: Any?,
target: Target<Drawable>, isFirstResource: Boolean
): Boolean {
Log.e("FotoProduto", "Falhou: $model")
e?.logRootCauses("FotoProduto")
return false // deixa o Glide mostrar o error
}
override fun onResourceReady(
resource: Drawable, model: Any, target: Target<Drawable>?,
dataSource: DataSource, isFirstResource: Boolean
): Boolean = false
})
.into(holder.foto)
A causa raiz costuma ser uma destas: código HTTP inesperado, falha ao decodificar ou uma mensagem com CLEARTEXT dizendo que a política de rede não permite aquele host. Cada uma leva a um dos passos abaixo.
2. Veja o que o servidor devolve de verdade
O navegador do computador tem os seus cookies, segue o caminho de quem já fez login e não liga para um link vencido aberto há cinco minutos. O app não tem nada disso. Para tirar a dúvida, este script baixa cada URL como um cliente sem sessão e compara três coisas: o status HTTP, o tipo que o servidor declara e o tipo real do arquivo, lido nos primeiros bytes.
#!/usr/bin/env bash
# Uso: ./conferir-imagem.sh URL [URL...]
# Mostra o que o servidor devolve de verdade para cada URL de imagem.
tipo_real() { # olha os primeiros bytes do arquivo
local h; h=$(head -c 12 "$1" | od -An -tx1 | tr -d ' \n')
case $h in
ffd8ff*) echo image/jpeg ;;
89504e47*) echo image/png ;;
47494638*) echo image/gif ;;
52494646????????57454250*) echo image/webp ;;
*) echo "desconhecido" ;;
esac
}
for url in "$@"; do
corpo=$(mktemp)
IFS=$'\t' read -r status tipo tamanho < <(curl -s -L -o "$corpo" \
-w '%{http_code}\t%{content_type}\t%{size_download}\n' "$url")
real=$(tipo_real "$corpo")
if [[ $status != 200 ]]; then veredito="FALHA HTTP $status"
elif [[ $real == desconhecido ]]; then veredito="NAO E IMAGEM"
elif [[ ${tipo%%;*} != "$real" ]]; then veredito="TIPO TROCADO"
else veredito="OK"; fi
printf '%-14s %s | %s | real: %s | %s bytes | %s\n' \
"$veredito" "$status" "$tipo" "$real" "$tamanho" "$url"
rm -f "$corpo"
done
Rodando contra um servidor local que imita os casos mais comuns:
OK 200 | image/jpeg | real: image/jpeg | 10629 bytes | /produtos/123.jpg NAO E IMAGEM 200 | text/html; charset=utf-8 | real: desconhecido | 36 bytes | /produtos/124.jpg FALHA HTTP 403 403 | application/xml | real: desconhecido | 40 bytes | /cdn/125.jpg?expira=1761000000 OK 200 | image/jpeg | real: image/jpeg | 10629 bytes | /cdn/125.jpg?expira=1893456000 TIPO TROCADO 200 | image/jpeg | real: image/png | 1940 bytes | /produtos/126.jpg FALHA HTTP 404 404 | text/plain | real: desconhecido | 14 bytes | /produtos/999.jpg
124.jpg, 200 com HTML. O endpoint de imagens exige sessão e, sem ela, responde com a página de login, com status 200. O Glide recebe um HTML e falha na decodificação. Se a imagem é privada de propósito, mande a credencial no pedido (próxima seção). Se não é, a rota de imagens não deveria estar atrás do login.125.jpgcom prazo vencido, 403. URLs assinadas de storage têm validade. O app guarda a lista de produtos de uma hora atrás, o usuário volta à tela e as URLs já expiraram. Peça URLs novas à API ao recarregar a lista, ou use um prazo maior que o tempo de vida do cache da lista.126.jpg, tipo trocado. O cabeçalho diz JPEG e o arquivo é PNG. O Glide identifica o formato pelos bytes, então esse caso costuma carregar, mas vale corrigir no servidor, porque outros clientes e CDNs confiam no cabeçalho.999.jpg, 404. O cadastro aponta para um arquivo que não existe. Isso se resolve no back-end, não no app.
Imagem que precisa de token
Quando a foto é privada, o Glide aceita cabeçalhos por meio de GlideUrl:
val comToken = GlideUrl(
produto.fotoUrl,
LazyHeaders.Builder()
.addHeader("Authorization", "Bearer $tokenDeAcesso")
.build()
)
Glide.with(holder.itemView).load(comToken).into(holder.foto)
Um cuidado: a chave de cache do GlideUrl é a URL, sem os cabeçalhos. Se duas contas usam o mesmo aparelho e o servidor devolve fotos diferentes para a mesma URL conforme o token, a segunda conta pode ver a imagem em cache da primeira. URLs distintas por recurso evitam o problema.
3. Confira o que o Android permite
Se a causa raiz fala em cleartext, a URL é http://. A documentação da Network Security Configuration diz que, a partir do Android 9 (API 28), o tráfego em texto puro vem desativado por padrão. A correção de verdade é servir as imagens por HTTPS. Para o servidor de desenvolvimento no emulador, que o Android enxerga como 10.0.2.2, libere só esse endereço, num arquivo que exista apenas no build de debug:
<!-- app/src/debug/res/xml/network_security_config.xml -->
<network-security-config>
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="false">10.0.2.2</domain>
</domain-config>
</network-security-config>
O arquivo precisa ser referenciado no android:networkSecurityConfig do manifesto de debug. Ligar usesCleartextTraffic no app inteiro de produção resolve o sintoma e abre a porta para todo o resto.
URL nula não é erro de rede
Produto sem foto cadastrada chega com fotoUrl nula. A página de placeholders descreve a ordem: com URL nula, o Glide mostra o fallback; sem fallback, mostra o error; sem os dois, deixa o placeholder. Por isso o exemplo acima define as três imagens. Assim "sem foto" e "falhou" aparecem diferentes para o usuário e para quem atende o suporte.
Também não é preciso testar nulo antes de chamar o Glide no onBindViewHolder. O guia de início pede só que toda view reaproveitada receba uma carga nova ou um clear(). Se uma linha especial põe um drawable fixo na ImageView sem chamar clear(), a carga antiga daquela view pode terminar depois e trocar a imagem da linha errada.
Foto trocada no servidor, foto velha no app
Esse caso não aparece como erro. O Glide monta a chave de cache a partir do modelo, aqui a URL. Se o servidor grava a foto nova no mesmo endereço, o app continua com a do cache. A página de cache recomenda, antes de tudo, que o servidor mude a URL quando o conteúdo muda. Quando não dá, misture uma versão na chave:
Glide.with(holder.itemView)
.load(produto.fotoUrl)
.signature(ObjectKey(produto.fotoAtualizadaEm)) // muda quando a foto muda
.into(holder.foto)
E, para o app baixar menos, gere miniaturas no upload, como em redimensionar imagem no servidor.
Referência: https://zarelli.wordpress.com/2011/06/13/android-exibindo-uma-imagem-da-web-em-um-imageview/