Coil 库的使用

22 阅读6分钟

一、Coil 是什么

Coil 是 Kotlin 团队做的 Android 图片加载库,名字来自 Coroutine Image Loader。2019 年开源,现在由 Google 维护。

  • 协程优先:API 直接基于 Kotlin 协程,不用写回调,也不需要桥接 RxJava 或 LiveData。
  • 体积小:方法数大概 2000 个,APK 增量约 150KB。作为对比,Glide 通常会多出 500KB 以上。如果项目对包体积有要求,可以使用Coil。
  • Kotlin 原生体验:扩展函数、DSL 配置、内联优化这些 Kotlin 特性都使用了,调用简单。

支持的格式 JPEG、PNG、WebP、GIF、SVG、HEIF/HEIC、视频帧等,ImageView、Compose Image、自定义 Target 都有一套统一的加载方式。如果项目已经在用 Kotlin + 协程,用 Coil 基本不需要动任何架构。

二、引入项目

逐个引入

// 核心
implementation("io.coil-kt.coil3:coil:3.1.0")

// Compose 集成
implementation("io.coil-kt.coil3:coil-compose:3.1.0")

// GIF
implementation("io.coil-kt.coil3:coil-gif:3.1.0")

// SVG
implementation("io.coil-kt.coil3:coil-svg:3.1.0")

// 视频帧
implementation("io.coil-kt.coil3:coil-video:3.1.0")

BOM 统一管理版本

如果多个模块都要引 Coil,推荐用 BOM 统一管版本,避免不同模块版本不一致:

// 在 project 级 build.gradle.kts 或 convention plugin 中声明
implementation(platform("io.coil-kt.coil3:coil-bom:3.1.0"))

// 各模块只需声明 artifact,版本号由 BOM 统一控制
implementation("io.coil-kt.coil3:coil")
implementation("io.coil-kt.coil3:coil-compose")
implementation("io.coil-kt.coil3:coil-gif")
implementation("io.coil-kt.coil3:coil-svg")
implementation("io.coil-kt.coil3:coil-video")

或者用 libs.versions.toml(Gradle 版本目录):

[versions]
coil = "3.1.0"

[libraries]
coil-bom = { group = "io.coil-kt.coil3", name = "coil-bom", version.ref = "coil" }
coil-core = { group = "io.coil-kt.coil3", name = "coil" }
coil-compose = { group = "io.coil-kt.coil3", name = "coil-compose" }
coil-gif = { group = "io.coil-kt.coil3", name = "coil-gif" }
coil-svg = { group = "io.coil-kt.coil3", name = "coil-svg" }
coil-video = { group = "io.coil-kt.coil3", name = "coil-video" }

然后在模块中:

implementation(platform(libs.coil.bom))
implementation(libs.coil.core)
implementation(libs.coil.compose)
implementation(libs.coil.gif)

注意:Coil 3.x 包名从 io.coil-kt:coil 迁到了 io.coil-kt.coil3:coil。如果还在用 2.x,包名是 io.coil-kt:coil,BOM 是 io.coil-kt:coil-bom

还要注意 Groovy 写法:

implementation platform('io.coil-kt.coil3:coil-bom:3.1.0')
implementation 'io.coil-kt.coil3:coil'
implementation 'io.coil-kt.coil3:coil-compose'

Coil 最低需要 API 21(Android 5.0),基本不影响现在的项目。

三、使用和封装

3.1 简单使用

// View 体系
imageView.load("https://example.com/avatar.jpg")
// Compose
AsyncImage(
    model = "https://example.com/avatar.jpg",
    contentDescription = "用户头像"
)

但项目中 占位图、错误图、圆角、缓存策略这些都需要统一处理。

3.2 封装成扩展方法

3.2.1 基础:带占位图和错误图

fun ImageView.loadImage(
    url: String?,
    placeholder: Int = R.drawable.ic_placeholder,
    error: Int = R.drawable.ic_error
) {
    load(url) {
        crossfade(300)
        placeholder(placeholder)
        error(error)
    }
}

调用:

avatarIv.loadImage(user.avatarUrl)

3.2.2 圆角和圆形

fun ImageView.loadRoundImage(
    url: String?,
    radius: Float = 8f.dpToPx(),
    placeholder: Int = R.drawable.ic_placeholder
) {
    load(url) {
        crossfade(true)
        placeholder(placeholder)
        transformations(RoundedCornersTransformation(radius))
    }
}

fun ImageView.loadCircleImage(
    url: String?,
    placeholder: Int = R.drawable.ic_placeholder
) {
    load(url) {
        crossfade(true)
        placeholder(placeholder)
        transformations(CircleCropTransformation())
    }
}

3.2.3 带回调

有些场景需要知道加载结果,比如拿到 Bitmap 后做颜色提取:

fun ImageView.loadImageWithCallback(
    url: String?,
    onSuccess: ((Bitmap) -> Unit)? = null,
    onError: ((Throwable) -> Unit)? = null
) {
    load(url) {
        crossfade(true)
        listener(
            onSuccess = { _, result ->
                val bitmap = (result as? BitmapImage)?.bitmap
                bitmap?.let { onSuccess?.invoke(it) }
            },
            onError = { _, result ->
                onError?.invoke(result.throwable)
            }
        )
    }
}

3.2.4 尺寸控制

列表里的图标可能只有 48dp,让 Coil 知道目标尺寸节省内存:

fun ImageView.loadThumbnail(
    url: String?,
    width: Int,
    height: Int
) {
    load(url) {
        size(width, height)
        precision(Precision.EXACT)
        scale(Scale.FILL)
    }
}

3.2.5 跳过缓存

验证码、刚上传的头像这种需要实时刷新的图片:

fun ImageView.loadFreshImage(
    url: String?,
    skipMemoryCache: Boolean = false,
    skipDiskCache: Boolean = false
) {
    load(url) {
        memoryCachePolicy(
            if (skipMemoryCache) CachePolicy.DISABLED
            else CachePolicy.ENABLED
        )
        diskCachePolicy(
            if (skipDiskCache) CachePolicy.DISABLED
            else CachePolicy.ENABLED
        )
    }
}

3.2.6 变换组合

多个变换可以链式叠加:

fun ImageView.loadCardImage(
    url: String?,
    radius: Float = 12f.dpToPx()
) {
    load(url) {
        transformations(
            RoundedCornersTransformation(radius),
            GrayscaleTransformation()
        )
    }
}

3.2.7 Compose 中使用封装

@Composable
fun NetworkImage(
    model: Any?,
    modifier: Modifier = Modifier,
    placeholder: Painter? = null,
    contentScale: ContentScale = ContentScale.Crop
) {
    AsyncImage(
        model = model,
        contentDescription = null,
        modifier = modifier,
        placeholder = placeholder,
        contentScale = contentScale,
        imageLoader = LocalContext.current.imageLoader
    )
}

@Composable
fun CircleNetworkImage(
    model: Any?,
    modifier: Modifier = Modifier,
    size: Dp = 48.dp
) {
    AsyncImage(
        model = model,
        contentDescription = null,
        modifier = modifier.size(size).clip(CircleShape),
        contentScale = ContentScale.Crop,
        imageLoader = LocalContext.current.imageLoader
    )
}

3.3 全局配置 ImageLoader

建议不要直接使用 Coil 的默认实例,在 Application 里配一个自定义的:

class MyApplication : Application(), ImageLoaderFactory {

    override fun newImageLoader(): ImageLoader {
        return ImageLoader.Builder(this)
            .crossfade(true)
            .memoryCache {
                MemoryCache.Builder(this)
                    .maxSizePercent(0.25)
                    .build()
            }
            .diskCache {
                DiskCache.Builder()
                    .directory(cacheDir.resolve("image_cache"))
                    .maxSizePercent(0.05)
                    .build()
            }
            .components {
                if (SDK_INT >= 28) {
                    add(ImageDecoderDecoder.Factory())
                } else {
                    add(GifDecoder.Factory())
                }
            }
            .build()
    }
}

实现 ImageLoaderFactory 之后,ImageView.load()AsyncImage 会自动用配好的 ImageLoader。如果项目使用 Hilt 的,也可以走 @Singleton @Provides 提供。

四、进阶:底层原理 和 高级用法

4.1 一次简单的调用到底走了哪些步骤

调用 imageView.load(url) 的时候,Coil 内部逻辑:

  1. 先查内存缓存(LruCache),key 由 ImageRequest 的 memoryCacheKey 决定。命中了就直接把 Bitmap 设到 ImageView,结束。
  2. 没命中就查磁盘缓存(基于 OkHttp 的 DiskLruCache 或自己配的)。命中了就解码 → 写入内存缓存 → 设图。
  3. 磁盘也没命中,走网络请求。Coil 内部用 OkHttp 下载,响应写入磁盘缓存,然后进入解码流程。
  4. 解码:默认走 BitmapFactory,API 28+ 走 ImageDecoder(支持 HEIF/HEIC)。按 size() 指定的尺寸做采样,同时从 BitmapPool 里复用已有的 Bitmap。
  5. 变换:RoundedCorners、CircleCrop、Grayscale 这些 Transformation 在子线程执行。
  6. 最后切主线程,把 Bitmap/Drawable 设到 ImageView,顺便跑 crossfade 动画。

整个链路都在协程里跑,Coil 自己管理调度,不需要调用者关心线程切换。

4.2 BitmapPool 是怎么省内存的

Coil 的 BitmapPool 逻辑不复杂但很实用:当一个 Bitmap 被 LRU 从内存缓存淘汰时,不是直接 recycle(),而是扔进池子里。下次解码新图片时,如果池子里有尺寸 ≥ 目标尺寸的 Bitmap,直接拿过来当解码画布,省掉一次内存分配。

val imageLoader = ImageLoader.Builder(context)
    .bitmapPoolPercentage(0.5) // 池占用可用内存的比例
    .build()

4.3 自定义 Fetcher:从任意数据源拉图片

图片不一定都在 HTTP 服务器上,可能是加密文件、数据库 BLOB、甚至自定义协议。这时候实现一个 Fetcher

class EncryptedFileFetcher(
    private val file: File,
    private val key: SecretKey
) : Fetcher {

    override suspend fun fetch(): FetchResult {
        val encryptedBytes = file.readBytes()
        val decryptedBytes = decrypt(encryptedBytes, key)
        return SourceResult(
            source = ImageSource(decryptedBytes),
            mimeType = "image/jpeg"
        )
    }

    class Factory(private val secretKey: SecretKey) : Fetcher.Factory<File> {
        override fun create(
            data: File,
            options: Options,
            imageLoader: ImageLoader
        ): Fetcher? {
            if (data.extension == "enc") {
                return EncryptedFileFetcher(data, secretKey)
            }
            return null
        }
    }
}

注册:

val imageLoader = ImageLoader.Builder(context)
    .components {
        add(EncryptedFileFetcher.Factory(secretKey))
    }
    .build()

4.4 自定义 Transformation:比如加水印

Coil 自带的变化种类不多,可能需要自己写。比如添加水印的例子:

class WatermarkTransformation(
    private val watermark: Bitmap,
    private val position: Gravity = Gravity.BOTTOM or Gravity.END
) : Transformation {

    override val cacheKey: String
        get() = "Watermark-${watermark.hashCode()}-$position"

    override suspend fun transform(
        input: Bitmap,
        size: Size
    ): Bitmap {
        val output = input.copy(Bitmap.Config.ARGB_8888, true)
        val canvas = Canvas(output)
        val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
            alpha = 128
        }
        val left = when {
            position and Gravity.END != 0 ->
                size.width.toInt() - watermark.width - 16
            position and Gravity.CENTER_HORIZONTAL != 0 ->
                (size.width.toInt() - watermark.width) / 2
            else -> 16
        }
        val top = when {
            position and Gravity.BOTTOM != 0 ->
                size.height.toInt() - watermark.height - 16
            position and Gravity.CENTER_VERTICAL != 0 ->
                (size.height.toInt() - watermark.height) / 2
            else -> 16
        }
        canvas.drawBitmap(watermark, left.toFloat(), top.toFloat(), paint)
        return output
    }
}

4.5 自定义 Decoder

默认解码器覆盖了常见格式,但如果有裸 YUV 数据或者需要对 WebP 动图做逐帧控制,可以自己实现 Decoder

class RawYuvDecoder(
    private val source: ImageSource,
    private val width: Int,
    private val height: Int
) : Decoder {
    override suspend fun decode(): DecodeResult {
        val bytes = source.readAllBytes()
        val bitmap = yuvToBitmap(bytes, width, height)
        return DecodeResult(
            image = BitmapImage(bitmap),
            isSampled = false
        )
    }
}

4.6 缓存清理

val imageLoader = context.imageLoader

// 清掉某张图片的全部缓存
val request = ImageRequest.Builder(context)
    .data("https://example.com/avatar.jpg")
    .build()
imageLoader.diskCache?.remove(request.diskCacheKey)
imageLoader.memoryCache?.remove(request.memoryCacheKey)

// 清空内存缓存(比如响应 onTrimMemory)
imageLoader.memoryCache?.clear()

4.7 复用 OkHttp 配置

Coil 网络层就使用的 OkHttp,项目里已有的 OkHttp 拦截器可以直接复用:

val okHttpClient = OkHttpClient.Builder()
    .addInterceptor { chain ->
        val newRequest = chain.request().newBuilder()
            .addHeader("Authorization", "Bearer ${getToken()}")
            .build()
        chain.proceed(newRequest)
    }
    .build()

val imageLoader = ImageLoader.Builder(context)
    .okHttpClient { okHttpClient }
    .build()

对需要统一加 Header、日志拦截、证书固定 的项目比较方便。

五、踩过的坑

5.1 RecyclerView 图片错位

Coil 内部已经做了 View 生命周期绑定,正常滑列表不会错位。但如果在 listener 回调里手动 setImageBitmap(),就绕开了这个保护。别在 listener 里手动设图,用 Coil 的 target 机制。

5.2 内存泄漏

两种常见情况:

一是自己写的 Target 里持了 Activity/Fragment。用 ViewTargetLifecycleTarget,Coil 会在合适时机自动取消请求。

二是把 ImageLoader 存到静态变量。Coil 内部用 WeakReference 持有 Context,实际上不太会漏,但还是建议放 Application 里,用 DI 管理。

5.3 GIF 吃内存

Coil 解码 GIF 后所有帧都放在内存里。如果 GIF 又长又大,内存飙到几十 MB 很正常。解法:用 size() 限制解码尺寸,或者在列表里只显示首帧不要自动播放。

imageView.load(gifUrl) {
    size(300, 300)
}

5.4 图片换了但显示没变

URL 一样但服务端图片更新了,Coil 以为缓存命中就不会重新拉。在 URL 上加个版本号或时间戳参数是最简单的办法:

val urlWithVersion = "${baseUrl}?v=${user.avatarVersion}"
imageView.load(urlWithVersion)

5.5 cacheKey 没写对

自定义 Transformation 的时候,如果 cacheKey 没有把参数差异体现出来,不同参数的变换会命中同一个缓存,出来的图就不对了。确保 cacheKey 覆盖所有会影响输出的参数。

5.6 OOM——大图没做采样

原图 4000×3000,不解码出来有 45MB+。如果不限制 Coil 目标尺寸,它就按原图采样。要用 size() 指定 View 的实际尺寸。

imageView.load(url) {
    size(imageView.measuredWidth, imageView.measuredHeight)
}

5.7 占位图变形

scale(Scale.FILL) 碰上比例不匹配的占位资源就会拉伸。要让占位资源比例和显示区域一致,或者用 .fit()

5.8 listener 回调的线程

onSuccessonError 默认在主线程回调,更新 UI 没问题,不要在里面做耗时任务。

六、最后

使用 Coil 的感受是它不像 Glide 那么"重"——不是说 Glide 不好,而是 Coil 在 Kotlin + 协程的体系里更自然。一行 load() 就可以,自定义 Fetcher / Decoder / Transformation 又能覆盖各种奇怪需求,不需要在"简单"和"灵活"之间做取舍。

再提一下:加 BOM 管版本、封装扩展方法统一占位图和错误图、设置 size()、自定义 Transformation 别忘 cacheKey、GIF 做好尺寸限制。