Imagem da internet no Jetpack Compose com Coil 3
Foto da internet no Jetpack Compose com Coil 3: o módulo de rede que falta, ImageLoader com cache, Cache-Control, AsyncImage em LazyColumn e fallback.
No Jetpack Compose, mostrar uma foto que vem da internet é um AsyncImage com uma URL. A parte que dá trabalho fica em volta dessa linha. Quem vem do Coil 2 descobre que as URLs pararam de carregar. Numa lista com duzentos produtos, a rolagem engasga. E a foto que o lojista trocou no painel continua a antiga no celular por dias. Este guia monta a configuração do Coil 3 para um catálogo em LazyColumn, com cada escolha explicada a partir da documentação do Coil.
Versões de referência: Coil 3.6.3, publicada no Maven Central em 18/09/2026. O código é Kotlin para Android e não foi executado no ambiente de testes deste blog, que não tem SDK do Android; as APIs foram conferidas uma a uma na documentação dessa versão. Se o seu app usa Views com ImageView e Glide, o post certo é o de descobrir por que a URL da imagem falha no Glide.
Coil 3 não baixa da rede sozinho
Esta é a mudança que mais quebra migração. A página de imagens da rede diz que o Coil 3 não inclui suporte a URLs de rede por padrão, para não forçar uma dependência de rede grande em quem não precisa. Sem o artefato de rede, nenhum componente sabe buscar uma URL https://, a requisição falha e o AsyncImage mostra a imagem de erro. Com OkHttp, basta incluir o módulo:
// app/build.gradle.kts
dependencies {
implementation("io.coil-kt.coil3:coil-compose:3.6.3")
implementation("io.coil-kt.coil3:coil-network-okhttp:3.6.3")
// opcional: respeitar o Cache-Control do servidor
implementation("io.coil-kt.coil3:coil-network-cache-control:3.6.3")
}
E o app precisa da permissão android.permission.INTERNET no manifesto, como qualquer app que acessa a rede.
Um ImageLoader para o app inteiro
O Coil cria um ImageLoader único com configuração padrão se você não fizer nada. Para ajustar cache e rede, a recomendação para Android é implementar SingletonImageLoader.Factory na classe Application:
class LojaApp : Application(), SingletonImageLoader.Factory {
@OptIn(ExperimentalCoilApi::class) // CacheControlCacheStrategy é experimental
override fun newImageLoader(context: Context): ImageLoader =
ImageLoader.Builder(context)
.components {
add(OkHttpNetworkFetcherFactory(
cacheStrategy = { CacheControlCacheStrategy() }
))
}
.memoryCache {
MemoryCache.Builder().maxSizePercent(context, 0.25).build()
}
.diskCache {
DiskCache.Builder()
.directory(context.cacheDir.resolve("image_cache"))
.maxSizePercent(0.02)
.build()
}
.crossfade(true)
.apply { if (BuildConfig.DEBUG) logger(DebugLogger()) }
.build()
}
- Cache em memória de até 25% da memória disponível para o app e cache em disco de até 2% do espaço em disco, os valores do exemplo da página de Image Loaders. Ajuste depois de medir.
crossfadeé uma função de extensão. No Coil 3, as funções específicas de plataforma precisam de import próprio (coil3.request.crossfade); se o editor não achar, é isso.DebugLoggermostra no Logcat cada requisição, se veio da memória, do disco ou da rede, e o motivo de cada erro. O FAQ do Coil avisa que ele deve ficar só no build de debug, por isso oif.
A foto que mudou e o app não viu
Por padrão, o Coil 3 não respeita o cabeçalho Cache-Control e sempre grava a resposta no cache de disco. Se o servidor publica a foto nova no mesmo endereço, /produtos/123.jpg, o celular continua com a cópia antiga até o cache ser limpo.
Há duas saídas:
- URL que muda com o conteúdo, como
https://api.minhaloja.com.br/produtos/123.jpg?v=8f3a1c, com o hash do arquivo. É a solução mais simples e funciona com qualquer cache, inclusive CDN. CacheControlCacheStrategy, do módulocoil-network-cache-control, que faz o Coil obedecer aoCache-Controlda resposta. Está no exemplo acima. Ela é marcada como@ExperimentalCoilApi, daí o@OptIn, e a documentação pedecoreLibraryDesugaringpara rodar em Android 7.1 (API 25) ou anterior.
Com a segunda opção, quem decide é o servidor: um max-age curto faz o app voltar a perguntar logo; um longo, combinado com URL versionada, deixa a foto no disco sem nova consulta.
A lista
@Composable
fun ListaDeProdutos(produtos: List<Produto>) {
LazyColumn {
items(produtos, key = { it.id }) { produto ->
Row(
modifier = Modifier.padding(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
AsyncImage(
model = produto.fotoUrl, // String?: null cai no fallback
contentDescription = null, // o nome já está no Text ao lado
contentScale = ContentScale.Crop,
placeholder = painterResource(R.drawable.foto_carregando),
error = painterResource(R.drawable.foto_indisponivel),
fallback = painterResource(R.drawable.foto_sem_cadastro),
modifier = Modifier
.size(72.dp)
.clip(RoundedCornerShape(8.dp))
)
Spacer(Modifier.width(12.dp))
Text(produto.nome)
}
}
}
}
O que cada parte resolve:
- Tamanho fixo no modifier. O AsyncImage usa as restrições do composable e o
ContentScalepara decidir em que tamanho decodificar a imagem. Com 72 dp, uma foto de 4000 pixels vira um bitmap pequeno na memória. Sem tamanho, a linha muda de altura quando a imagem chega e a lista pula. keynoitemsmantém cada linha presa ao seu produto quando a lista é reordenada ou filtrada.- Três imagens de reserva.
placeholderaparece enquanto carrega,errorquando a requisição falha efallbackquando a URL énull. Se você não passarfallback, ele assume o mesmo valor deerror. Separar os dois ajuda no suporte: "foto indisponível" é problema de rede ou de servidor; "sem foto cadastrada" é cadastro.
Duas trocas que parecem iguais e não são
rememberAsyncImagePainter no lugar de AsyncImage. Dá um Painter e acesso ao estado, mas, segundo a documentação, não detecta o tamanho em tela e carrega a imagem nas dimensões originais. Numa lista, isso é a diferença entre miniatura e foto inteira na memória. Se precisar dele, passe um SizeResolver, como rememberConstraintsSizeResolver().
SubcomposeAsyncImage para pôr um CircularProgressIndicator enquanto carrega. Funciona, mas usa subcomposição, que é mais lenta. A própria documentação diz que ela pode não servir para partes da interface sensíveis a desempenho, como uma LazyList. Na lista, fique com placeholder; o spinner cabe numa tela de detalhe.
O servidor também precisa ajudar
O Coil reduz o bitmap na memória, mas o download continua sendo do arquivo inteiro. Uma foto de 3 MB por linha pesa no 4G do cliente e no cache de disco, mesmo exibida em 72 dp. Gerar miniaturas no upload resolve na origem; o passo a passo em PHP está em redimensionar imagem no servidor. Para ver o tráfego real do app enquanto ajusta tudo isso, use um aparelho físico com depuração, seguindo os cuidados de depurar no celular Android sem deixar a porta aberta.
Referência: http://www.thiengo.com.br/colocando-uma-imagem-da-web-no-imageview-no-android