首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >列表滑动卡到飞起?把图片加载库的默认值全调一遍

列表滑动卡到飞起?把图片加载库的默认值全调一遍

作者头像
陆业聪
发布2026-06-24 19:16:21
发布2026-06-24 19:16:21
960
举报

上周朋友做用户反馈分析,看到一条评论整个人愣了一下:「滑你们的商品列表,比微信朋友圈还卡」。

不是说微信朋友圈卡(人家性能挺好),是说列表卡到让用户开始拿别家产品做参照系——这就很伤人了。打开 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 主线程开销。

代码语言:javascript
复制
// 推荐:显式 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 全局默认关闭,只在详情页等特定场景手动开启。

代码语言:javascript
复制
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 频率上去了。

代码语言:javascript
复制
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 后才比较成熟的能力。原理是在用户即将滑到的位置之前,把图片塞进内存缓存。

代码语言:javascript
复制
// 监听滚动,提前 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 线程的共享池,和你的网络请求、数据库操作抢资源。建议为图片解码单独建一个有限的池:

代码语言:javascript
复制
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,开销巨大。

解决方案:固定尺寸 + 占位图

代码语言:javascript
复制
// 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」沉淀了下来。

本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2026-06-17,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 陆业聪 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档