上周朋友做用户反馈分析,看到一条评论整个人愣了一下:「滑你们的商品列表,比微信朋友圈还卡」。
不是说微信朋友圈卡(人家性能挺好),是说列表卡到让用户开始拿别家产品做参照系——这就很伤人了。打开 Profile,看 Frame 时间,确实在快速滑动时帧率跌到 35 FPS 上下,掉帧高峰几乎全部对应 RecyclerView 的 onBindViewHolder 阶段,而瓶颈最终指向图片加载库。
这玩意儿坑了朋友整整三天。最后定位下来,问题不在 Coil 本身,而在对它的「默认配置」过于信任。今天这篇文章,把这次排查全过程梳理一遍,顺便把 Coil 3.5 / Glide 5.0 / Fresco 三家最新版本在「列表滚动场景」下的真实差异讲清楚——不是 Benchmark 跑分,是真实生产代码里能落地的优化项。
一、为什么列表滚动会卡?拆解一下耗时去向
先说一个很多人忽略的事实:RecyclerView 滑动卡顿,图片加载库的「主线程开销」是大头,但和你想的可能不一样。
单次 onBindViewHolder 的耗时分布
用 Systrace 抓一帧 16.6ms 内的耗时分布(单次 ViewHolder 绑定,假设其中调用了一次 imageView.load(url)):
阶段 | 主线程耗时 | 说明 |
|---|---|---|
URL 解析与 Request 构建 | 0.3-0.8ms | 字符串处理、Builder 模式开销 |
内存缓存查询 | 0.1-0.3ms | LruCache get + key hash |
Bitmap 上屏(命中缓存) | 0.5-2ms | setImageBitmap + invalidate |
未命中:磁盘读取(IO 线程) | 主线程 0ms | 异步,但解码后回调上屏会触发布局 |
Bitmap 解码(默认线程池) | 主线程 0ms | 异步,但占用 CPU 影响主线程调度 |
看上去命中缓存的代价很小?是的,单次很小。但一帧里如果绑定 4 个 ViewHolder(典型双列商品流),主线程加起来就要 4-12ms,叠加 RecyclerView 自身 measure/layout 的 4-6ms,再加点 click listener 注册和数据 diff,已经濒临 16ms 红线。
未命中缓存才是真正杀手——异步解码看似不占主线程,但解码完成后回调要做 setImageBitmap,这会触发一次 invalidate 和潜在的 requestLayout(如果 ImageView 没固定尺寸),重新走 measure+layout,主线程立刻多出 3-8ms,掉帧的就是这一帧。
卡顿定位流程
列表滑动掉帧
↓
用 Macrobenchmark 抓 trace
↓
看是否集中在 onBindViewHolder?
✅ 是 → 进一步看是图片库还是 layout/measure
❌ 否 → 检查 ItemDecoration / GC / 主线程 IO
↓
区分:缓存命中率低 vs 解码慢 vs 上屏抖动
二、Coil 3.5 / Glide 5.0 / Fresco 在列表场景的差异
我们项目原本用 Glide 4.x,去年迁到 Coil 2.x,这次趁 Coil 3.5 beta 发布顺手做了一次横评。先说结论再讲过程。
关键能力对比
维度 | Coil 3.5 | Glide 5.0 | Fresco 3.x |
|---|---|---|---|
实现语言 | Kotlin + 协程 | Java(5.0 部分模块迁 Kotlin) | Java + Native |
APK 体积影响 | 小(约 200KB) | 中(约 700KB) | 大(含 SO,3MB+) |
默认线程模型 | 协程,可换 Dispatcher | 自建线程池 | 独立 Native 线程 |
内存占用峰值 | 中等 | 较高(Bitmap Pool) | 最低(ashmem) |
Compose 集成 | 官方原生支持 | 第三方 wrapper | 不友好 |
图片格式扩展 | 解码器插件化(AVIF/WebP/SVG) | 较丰富 | 最丰富,含动图优化 |
网络层 | 默认 OkHttp,可替换 | 默认 OkHttp 4.x | 独立的 fbcore 网络栈 |
我的判断:什么场景选什么
• 新项目 / Compose 重度使用:直接 Coil 3.5,没什么好犹豫的,体积小且和协程生态一致。
• 老项目 Glide 4.x:除非有强力理由,不要轻易迁——Glide 5.0 的 API 改动可控,先升小版本拿稳定性收益更划算。
• 图片量极大、长列表为主(短视频缩略图、电商瀑布流):可以考虑 Fresco,ashmem 在 Android 8+ 仍有内存优势,但 APK 体积代价不小。
三、Coil 3.5 在列表中的五个性能开关
说回这次的实际优化。这五个配置是这次降帧的关键,按重要性排序。
1. 显式指定目标尺寸(Size)——最大优化项
Coil 默认会用 ImageView 的当前尺寸推断目标 Size,但这有两个问题:一是 ViewHolder 被回收后 ImageView 可能还没 layout 出最终大小,二是推断阶段也会触发一次跨线程同步。在列表里,固定尺寸 + 显式 size 能减少 30% 以上的 onBind 主线程开销。
// 推荐:显式 size,关闭推断
imageView.load(url) {
size(240, 240)
scale(Scale.FILL)
precision(
Precision.INEXACT
)
crossfade(false)
}// 不推荐:让库自己推断
imageView.load(url) {
// 隐含 size 推断 + crossfade
}关于 precision(INEXACT):默认是 EXACT,意味着解码出来的 Bitmap 必须精确匹配请求尺寸,会做更多采样和缩放。INEXACT 允许略大于目标尺寸(一般是 2 的幂次对齐),解码更快,内存差别不大,列表场景完全可以接受。
2. 关闭 crossfade 与 transition
crossfade 在详情页很美,在列表里是性能杀手。它会创建 CrossfadeDrawable,每帧都要混合两个 Bitmap,列表里的几十个 ImageView 同时做 crossfade,GPU 直接报警。
推荐做法:在 ImageLoader 全局默认关闭,只在详情页等特定场景手动开启。
val loader = ImageLoader.Builder(ctx)
.crossfade(false)
.memoryCachePolicy(
CachePolicy.ENABLED
)
.diskCachePolicy(
CachePolicy.ENABLED
)
.respectCacheHeaders(
false
)
.build()respectCacheHeaders(false) 这条很多人不敢动——它的意思是忽略 HTTP Cache-Control 头,强制按本地策略缓存。对于商品图、头像这种不会频繁变更的资源,关掉它收益巨大;对于需要时效性的图(活动 banner),单独走一个 ImageLoader 实例就好。
3. 内存缓存调优——不是越大越好
Coil 默认内存缓存是「可用内存的 25%」,听起来挺合理,但实际上对中低端机非常激进。我们生产数据:1.5GB RAM 的红米 9A,Coil 默认会吃掉 90MB 给 Bitmap 缓存,结果 GC 频率上去了。
val loader = ImageLoader.Builder(ctx)
.memoryCache {
MemoryCache.Builder()
.maxSizePercent(
ctx, 0.12
)
.strongReferencesEnabled(
true
)
.weakReferencesEnabled(
false
)
.build()
}
.diskCache {
DiskCache.Builder()
.directory(
ctx.cacheDir
.resolve("img")
)
.maxSizeBytes(
100L * 1024 * 1024
)
.build()
}
.build()关掉 weakReferences 是另一个反常识的优化:Coil 的弱引用机制是为了「图片在屏幕外仍可能被复用」准备的,但在快速滑动场景,弱引用维护本身的开销比命中收益还大。我们关掉之后,FPS 提升约 4 帧。
4. 预取(Prefetch)—— 让滑动跟手
这是 Coil 3.x 后才比较成熟的能力。原理是在用户即将滑到的位置之前,把图片塞进内存缓存。
// 监听滚动,提前 5 个 item 预取
recyclerView.addOnScrollListener(
object : RecyclerView
.OnScrollListener() {
override fun onScrolled(
rv: RecyclerView,
dx: Int,
dy: Int
) {
val last = layoutMgr
.findLastVisible()
val range =
(last + 1)..
(last + 5)
range.forEach { i ->
items.getOrNull(i)
?.imageUrl?.let {
loader.enqueue(
ImageRequest
.Builder(ctx)
.data(it)
.size(240, 240)
.build()
)
}
}
}
}
)注意:预取要节流,不要每次 onScrolled 都 enqueue 一遍——可以加一个 debounce 或者只在滑动方向稳定后触发。我们的实现里用了一个 80ms 的 throttle,效果就很好。
5. 解码 Dispatcher 调优
Coil 3.5 默认用 Dispatchers.IO 做网络与解码。但 IO Dispatcher 是 64 线程的共享池,和你的网络请求、数据库操作抢资源。建议为图片解码单独建一个有限的池:
val decodeDispatcher =
Executors
.newFixedThreadPool(
4,
priorityFactory(
Thread.NORM_PRIORITY
- 1
)
)
.asCoroutineDispatcher()val loader = ImageLoader.Builder(ctx)
.decoderDispatcher(
decodeDispatcher
)
.build()两个细节:线程数定 4 而不是更多——是因为 CPU bound 的解码任务,过多线程会引起调度抖动;优先级降低一档,让出主线程算力,减少和 UI 的资源竞争。
四、上屏抖动:被忽视的最后一公里
前面所有优化做完,我们的 P95 帧时间从 31ms 降到了 18ms。还差最后一脚——快速滑动时偶尔会有 50ms 的尖峰,定位下来是「Bitmap 上屏后触发了 requestLayout」。
原因是我们的 ImageView 用了 wrap_content。每张图加载完成,就要重新算一遍 ImageView 的尺寸,触发整个 RecyclerView 的 layout pass,开销巨大。
解决方案:固定尺寸 + 占位图
// XML 中固定尺寸
<ImageView
android:layout_width="120dp"
android:layout_height="120dp"
android:scaleType=
"centerCrop"
/>// 加载时显式 placeholder
imageView.load(url) {
size(240, 240)
placeholder(
R.drawable.img_ph
)
error(
R.drawable.img_err
)
}如果产品要求图片比例随商品图变化,强烈建议用「服务端返回宽高比 + AspectRatioImageView」方案,比让 ImageView 自己撑大撑小要稳得多。
五、最终收益:用数据说话
指标(红米 9A 实测) | 优化前 | 优化后 |
|---|---|---|
P50 帧时间 | 14.2ms | 10.8ms |
P95 帧时间 | 31.4ms | 17.6ms |
滑动平均 FPS | 36 | 54 |
图片库 RSS 占用 | 88MB | 42MB |
用户反馈卡顿率 | 2.3% | 0.4% |
投入是两个工程师一周时间,加几次灰度。这种投入产出比,做体验优化的同学应该都明白意味着什么——比新做一个酷炫功能划算太多。
写在最后
图片加载库的「默认配置」往往是为了通用场景做的折中。在你的具体业务里,几乎每一个默认值都值得拿出来重新审视一遍。
说实话,我最开始也不信「换几个参数能差这么多」,毕竟 Coil 已经是 Kotlin 生态里口碑最好的图片库了。结果就是被打脸——再好的库也要懂它的脾气。这次排查之后,朋友做了一次分享,把这五条配置作为新人接手列表场景的「checklist」沉淀了下来。