Programação Por Equipe DevBras

Capa do artigo: Imagem da internet no Jetpack Compose com Coil 3

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.
  • DebugLogger mostra 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 o if.

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ódulo coil-network-cache-control, que faz o Coil obedecer ao Cache-Control da resposta. Está no exemplo acima. Ela é marcada como @ExperimentalCoilApi, daí o @OptIn, e a documentação pede coreLibraryDesugaring para 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 ContentScale para 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.
  • key no items mantém cada linha presa ao seu produto quando a lista é reordenada ou filtrada.
  • Três imagens de reserva. placeholder aparece enquanto carrega, error quando a requisição falha e fallback quando a URL é null. Se você não passar fallback, ele assume o mesmo valor de error. 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.

Avalie este artigo

Referência: http://www.thiengo.com.br/colocando-uma-imagem-da-web-no-imageview-no-android