列表滑动卡到飞起?我把Coil的默认值全调了一遍
上周做用户反馈分析看到一条评论让我整个人愣了一下「滑你们的商品列表比微信朋友圈还卡」。不是说微信朋友圈卡人家性能挺好是说我们的列表卡到让用户开始拿别家产品做参照系——这就很伤人了。我打开 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.3msLruCache get key hashBitmap 上屏命中缓存0.5-2mssetImageBitmap invalidate未命中磁盘读取IO 线程主线程 0ms异步但解码后回调上屏会触发布局Bitmap 解码默认线程池主线程 0ms异步但占用 CPU 影响主线程调度看上去命中缓存的代价很小是的单次很小。但一帧里如果绑定 4 个 ViewHolder典型双列商品流主线程加起来就要 4-12ms叠加 RecyclerView 自身 measure/layout 的 4-6ms再加点 click listener 注册和数据 diff已经濒临 16ms 红线。未命中缓存才是真正杀手——异步解码看似不占主线程但解码完成后回调要做setImageBitmap这会触发一次 invalidate 和潜在的 requestLayout如果 ImageView 没固定尺寸重新走 measurelayout主线程立刻多出 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.5Glide 5.0Fresco 3.x实现语言Kotlin 协程Java5.0 部分模块迁 KotlinJava NativeAPK 体积影响小约 200KB中约 700KB大含 SO3MB默认线程模型协程可换 Dispatcher自建线程池独立 Native 线程内存占用峰值中等较高Bitmap Pool最低ashmemCompose 集成官方原生支持第三方 wrapper不友好图片格式扩展解码器插件化AVIF/WebP/SVG较丰富最丰富含动图优化网络层默认 OkHttp可替换默认 OkHttp 4.x独立的 fbcore 网络栈我的判断什么场景选什么• 新项目 / Compose 重度使用直接 Coil 3.5没什么好犹豫的体积小且和协程生态一致。• 老项目 Glide 4.x除非有强力理由不要轻易迁——Glide 5.0 的 API 改动可控先升小版本拿稳定性收益更划算。• 图片量极大、长列表为主短视频缩略图、电商瀑布流可以考虑 Frescoashmem 在 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 与 transitioncrossfade 在详情页很美在列表里是性能杀手。它会创建CrossfadeDrawable每帧都要混合两个 Bitmap列表里的几十个 ImageView 同时做 crossfadeGPU 直接报警。推荐做法在 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 的红米 9ACoil 默认会吃掉 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_width120dp android:layout_height120dp 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.2ms10.8msP95 帧时间31.4ms17.6ms滑动平均 FPS3654图片库 RSS 占用88MB42MB用户反馈卡顿率2.3%0.4%投入是两个工程师一周时间加几次灰度。这种投入产出比做体验优化的同学应该都明白意味着什么——比新做一个酷炫功能划算太多。写在最后图片加载库的「默认配置」往往是为了通用场景做的折中。在你的具体业务里几乎每一个默认值都值得拿出来重新审视一遍。说实话我最开始也不信「换几个参数能差这么多」毕竟 Coil 已经是 Kotlin 生态里口碑最好的图片库了。结果就是被打脸——再好的库也要懂它的脾气。这次排查之后我们组内做了一次分享把这五条配置作为新人接手列表场景的「checklist」沉淀了下来。