Flutter 列表滑动掉帧排查实录:我给每个 item 加了 RepaintBoundary,结果更卡了
上周三晚上十一点多,我在改一个二手交易 App 的聊天列表页。测试同学甩过来一段录屏,红米 Note 11(天玑 810,60Hz)上滑到大概 400 条消息那个位置,帧率从 60 掉到 38 左右,手指松开还会顿一下。我当时第一反应是老套路:给每个 item 套一层 RepaintBoundary。改完自己跑了一遍,比没改还差,掉到 31 帧。
这事让我把 ListView 的源码又翻了一遍,发现一个挺多人不知道的细节:ListView.builder 的构造函数里有个参数叫 addRepaintBoundaries,默认值就是 true。也就是说 Flutter 已经在每个 child 外面自动包了一层 RepaintBoundary。你再手动包一层,等于套了两层,每层各开一块独立的 layer,合成开销和纹理内存都是双份的。我那 11 处手动包裹,9 处是白加的。
先别动代码,把测量做对
我用的是 flutter run --profile。必须强调是 profile,debug 模式下的数字没法看——光 assert 和各种 hot reload 钩子就能吃掉十几毫秒,你会误判成代码有问题。
然后开 DevTools 的 Performance 页面,录 5 秒左右的滑动,重点看 Timeline 上那两条线:UI 线程和 Raster 线程。
判断逻辑其实很简单:UI 线程高,说明是 build/layout 的问题,八成在 widget 树太深或者 setState 范围太大;Raster 线程高,才是绘制和图层的问题,这时候 RepaintBoundary 才轮得到出场。我那次录出来 UI 平均 6.2ms、Raster 平均 14.8ms,问题确实在 Raster,所以方向上我没走错,只是手法用反了。
还有个偷懒的办法,在 main 里加一行 debugRepaintRainbowEnabled = true,滑动的时候如果整屏彩虹色闪,说明在疯狂重绘。这个 flag 记得只在 debug 开,忘了关会跟着发版出去,别问我怎么知道的。
真正把帧率拉回来的是三个参数
第一件事,把手动加的 RepaintBoundary 全删了。搜出来 11 处,删完帧率回到 52 左右,还是不够。
第二件,给 ListView.builder 补上 itemExtent。我们的消息气泡高度其实就两种:纯文字的 92 逻辑像素,带图的也能在拿到图后固定住。设了 itemExtent: 92 之后,Flutter 不用再去 measure 每个 child,滚动偏移可以直接算出来。这一步把 Raster 从 14.8ms 压到 9ms 上下。副作用是滚动条长度终于准了,之前 scrollbar 一跳一跳的,就是这个原因。
第三件是 cacheExtent,默认值 250,单位逻辑像素,意思是在可视区上下各预构建 250 像素。我们这个列表全是网络头像,我调到 600,滑动是顺了,但内存涨了不少——DevTools 的 Memory 面板上从 143MB 到 161MB,涨了大概 18MB。这个值没有标准答案,item 越重越该调小,别盲目拉大。
改完这三处,稳定在 57 帧左右。没到 60,但 400 条那个位置不顿了,我认了。
头像图片:一个被忽略的内存黑洞
排查到后半段我才发现,真正的大头根本不是重绘,是图片解码。我们的头像服务端返回的是 800×800 的 JPG,大约 180KB。前端显示尺寸是 48×48 逻辑像素,3x 屏算下来也就 144×144 物理像素。
不设 cacheWidth 的话,Flutter 会按原图尺寸解码,一张位图就是 800 × 800 × 4 字节 ≈ 2.56MB 的 RGBA。一屏 7 个头像,就是 18MB 的解码内存,再加上滚出去的历史图片,内存曲线是锯齿状往上爬的。
加上 cacheWidth: 144, cacheHeight: 144 之后,144 × 144 × 4 ≈ 82KB 一张,差了 31 倍。这个改动把内存峰值从 161MB 拉到 118MB 左右,GC 压力也跟着小了。
注意 cacheWidth 传的是物理像素,不是逻辑像素。写 48 的话在 3x 屏上会糊成一片,这个我踩过。
顺便说个对比:ListView.builder 还是 SliverList
如果你的列表只是单纯一列,ListView.builder 就够了,别为了架构好看硬上 CustomScrollView + SliverList,那多出来的 sliver 协议开销在低端机上是有成本的,只是平时看不出来。
但如果你的页面里已经有 SliverAppBar、吸顶分类头、或者列表和网格混排,那就老老实实上 CustomScrollView。混着用最要命——我见过在 CustomScrollView 里塞 ListView 并且忘了给 shrinkWrap 和内层滚动物理设置有,那是两套滚动体系打架,神仙难救。
最后几句
关于 Impeller,Android 从 3.16 开始能手动开预览,到 3.27 变成默认。它把渲染管线重写了一遍,但 Raster 线程那套分析方法基本还能用,Timeline 的读法没变。所以别怕工具换代,套路是通的。
我现在的工作习惯是:任何性能相关的改动,先录一段 Performance,改完再录一段,两个截图放一起对比。凭感觉优化最容易骗自己,尤其是那种「改完好像顺了一点」的错觉——很可能只是你重启了 App。