用户反馈“页面卡”,十有八九指的是滑动或动画不连贯。在 Flutter 里这类现象对应丢帧(Jank):某一帧的生成耗时超过帧预算,屏幕只能继续显示上一帧。60Hz 屏幕的预算约 16.6ms,90Hz 约 11.1ms,120Hz 约 8.3ms。本文以 OHOS 平台为例,梳理一套用 DevEco Profiler 抓取 trace、圈定滑动区间、判定瓶颈线程、再对症修复的完整流程。
一、先分清 UI 线程与 Raster 线程
Flutter 渲染一帧大致要经过构建、布局、绘制、栅格化合成几个阶段,落到线程上只有两条是开发者必须盯住的:UI 线程运行 Dart VM,执行 Dart 代码和框架代码,产出 Layer tree;Raster 线程接收 Layer tree,调用 Skia 或 Impeller 完成栅格化与合成,最后送屏。
UI 线程慢说明 Dart 层有问题;Raster 线程慢说明绘制太重,比如阴影、模糊、大图、saveLayer。分析时必须把两条线程的耗时一起看。
在 OHOS 上有个容易踩的坑:从 Flutter 3.35 起,UI 线程可能与应用主线程合并成同一条线程,线程名直接是应用包名(例如 .complex_layout),归类为 platform_main 而不是 flutter_ui。这时靠线程名判断 UI 线程会直接判错。正确做法是在 trace 中搜索 flutter::Animator::BeginFrame,该 slice 所在的 tid 才是 UI 线程。
二、在 trace 里圈出“滑动窗口”
DevEco Profiler 抓下来的 trace 往往长达数秒到数十秒,中间混杂启动、空闲、滑动、停止等多个阶段。不先把滑动区间圈出来,后面的丢帧定位就没有意义。
应用代码可以主动埋点 flutter::APP_LIST_FLING 这个滑动生命周期标记。如果 trace 中存在该 slice,它覆盖了手指拖滑加惯性滑动的完整过程,直接取它的 start 与 end 时间戳作为后续分析窗口即可。
需要注意,这个标记不一定完全覆盖实际滑动:滑动开始前埋点未触发,或滑动结束后惯性仍在继续,都会导致窗口偏差。此时要结合触摸事件链把窗口向外扩展。
三、定位丢帧位置
在滑动窗口内找丢帧有两种方法,优先用第一种。
方法一,搜索 flutter::SceneDisplayLag。Flutter 提供了这条 trace 直接标识渲染管线中的丢帧,在滑动窗口里搜到它就能定位丢帧时间点,不必逐帧比对耗时。
方法二,看 render_service 提供的两条 buffer 消费泳道。把这两条泳道收藏起来,找它们不重合或中间出现空隙的时间点。
锁定最慢那一帧之后,先确认 UI 线程与 Raster 线程记录的 frame_number 是否相同,确保看的是同一帧;再以该帧时间戳为中心收窄窗口,分别查看两条线程在窗口内的 slice,谁最耗时,瓶颈就在谁身上。两条线程的耗时并没有绝对基准,要结合慢帧的指标分布来判断。
若结论在 UI 线程:按 UI 线程 tid 过滤 slice,重点看 BUILD、LAYOUT、PAINT、Animate、BeginFrame、PlatformConfiguration 这些名称,确认哪个阶段最重。若抓 trace 时没有开启 --trace-systrace,这些阶段 slice 可能缺失,需要重新采集。想进一步确认耗时原因,可以把数据交给 Flutter 自带的 DevTools 细看。
若结论在 Raster 线程:按 Raster 线程 tid 过滤 Flutter/Impeller 相关 slice,观察其出现情况与耗时,再回代码核对是否存在 saveLayer、模糊、阴影这类高开销绘制操作。
四、根因一:ListView 不定高,布局测量被放大
结论先说:列表项没有固定高度时,Flutter 必须逐项测量高度,快速滑动时布局成本被成倍放大,最终表现为丢帧。
现象特征是长列表快速滑动时丢帧,低端机尤其明显;每出现一个新 item,UI 线程的 LAYOUT 就会变长。Trace 上的判定信号,是慢帧窗口内 UI 线程 LAYOUT 耗时明显偏高。
代码层面的验证很直接:检查 ListView.builder 或 ListView.separated 有没有提供 itemExtent 或 prototypeItem。高度固定就直接给 itemExtent:
- ListView.builder(
- itemCount: itemCount,
- itemExtent: 136, // 固定高度,跳过逐项测量
- itemBuilder: (context, index) => _buildItem(index, fixedHeight: true),
- )
复制代码
高度确实不固定,也可以用 prototypeItem 提供一条高度样板,让框架按样板估算。改完后回到同一条 trace 上复采对比,看 LAYOUT 是否回到正常水平。
五、根因二:过度绘制与离屏缓冲压垮 Raster 线程
结论同样先给:动画或滑动过程中大量使用 saveLayer、Opacity、模糊、阴影时,Raster 线程绘制负载过重,同样会丢帧。
现象特征很好区分:静态看一切正常,一滑动或一播动画就卡;UI 线程耗时正常,只有 Raster 线程耗时长。
排查方向是逐个检查可见 item 及其动画子树里有没有这些东西:Clip.antiAliasWithSaveLayer、动画中直接使用的 Opacity Widget、大面积的 BackdropFilter 或 ImageFiltered 模糊、复杂的 BoxShadow,以及该加 RepaintBoundary 却没加的高频重绘区域。
修复思路:把 Opacity 换成 FadeTransition,让透明度变化走合成层而不是每帧产生 saveLayer;圆角优先用 Clip.hardEdge;缩小或移除阴影与模糊;高频重绘区域补上 RepaintBoundary。
- // 不推荐:Opacity 每帧变化,会在 paint 阶段触发 saveLayer
- Opacity(opacity: _animation.value, child: child)
- // 推荐:FadeTransition 在合成层调整 opacity,不产生 saveLayer
- FadeTransition(opacity: _animation, child: child)
复制代码
改完之后同样要复采对比:Raster 线程慢帧窗口内的 slice 耗时是否下降,是最直接的验证方式。
六、把排查流程固化成团队习惯
这套方法最好在团队里标准化下来:先用 APP_LIST_FLING 圈出滑动窗口,再用 flutter::SceneDisplayLag 找丢帧,确认 frame_number 一致后收窄窗口,按 tid 分流到 UI 线程或 Raster 线程,最后对号入座排查 itemExtent 缺失与 saveLayer 滥用这两类高频根因。多数滑动卡顿问题会在前两步就暴露出来,剩下的只是按图索骥地验证与修复。 |