一、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 内部逻辑:
- 先查内存缓存(LruCache),key 由 ImageRequest 的 memoryCacheKey 决定。命中了就直接把 Bitmap 设到 ImageView,结束。
- 没命中就查磁盘缓存(基于 OkHttp 的 DiskLruCache 或自己配的)。命中了就解码 → 写入内存缓存 → 设图。
- 磁盘也没命中,走网络请求。Coil 内部用 OkHttp 下载,响应写入磁盘缓存,然后进入解码流程。
- 解码:默认走 BitmapFactory,API 28+ 走 ImageDecoder(支持 HEIF/HEIC)。按
size()指定的尺寸做采样,同时从 BitmapPool 里复用已有的 Bitmap。 - 变换:RoundedCorners、CircleCrop、Grayscale 这些 Transformation 在子线程执行。
- 最后切主线程,把 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。用 ViewTarget 或 LifecycleTarget,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 回调的线程
onSuccess 和 onError 默认在主线程回调,更新 UI 没问题,不要在里面做耗时任务。
六、最后
使用 Coil 的感受是它不像 Glide 那么"重"——不是说 Glide 不好,而是 Coil 在 Kotlin + 协程的体系里更自然。一行 load() 就可以,自定义 Fetcher / Decoder / Transformation 又能覆盖各种奇怪需求,不需要在"简单"和"灵活"之间做取舍。
再提一下:加 BOM 管版本、封装扩展方法统一占位图和错误图、设置 size()、自定义 Transformation 别忘 cacheKey、GIF 做好尺寸限制。