1. 概述

用户反馈"页面卡"时,最常见的一类是滑动或动画卡顿:画面不连贯、一顿一顿。这在 Flutter 里通常对应掉帧(Jank)——某一帧生成耗时超过帧预算,屏幕只能继续显示上一帧。

60Hz 表示屏幕每秒刷新 60 次,相邻两次刷新间隔约 16.6ms90Hz11.1ms120Hz8.3ms

2. Flutter 的渲染管线介绍

Flutter 渲染一帧,大致经过四个阶段:

阶段 做什么 大致耗在哪条线程
Build 根据状态变化构建/更新 Widget 树、Element 树 UI 线程(主线程)
Layout RenderObject 树计算每个节点的大小和位置 UI 线程
Paint 生成绘制指令,构建 Layer tree UI 线程
Composite 将 Layer tree 栅格化成像素,输出到屏幕 Raster 线程(渲染线程/GPU 线程)

其中UI 线程:运行 Dart VM,执行你的 Dart 代码和 Flutter 框架代码,产出 Layer tree;Raster 线程:接收 Layer tree,调用 Skia 或 Impeller 进行栅格化与合成,最终送屏。

性能分析时,两条线程的时间都要看:

  • UI 线程耗时过长,说明 Dart 层有性能问题
  • Raster 线程耗时过长,说明绘制太重(阴影、模糊、大图、saveLayer 等)。

后续主要分析都围绕 UI 线程和 Raster 线程展开,需要先学会通过trace确认它们的 tid。通过查看 trace 的线程清单,重点关注以下角色分类:

角色 典型线程名 说明
flutter_ui 1.uiroot.1.uiroot.N.ui Flutter UI 线程,运行 Dart 代码
platform_main 应用包名(如 .my_app 应用主线程
flutter_raster 1.rasterroot.1.raster Flutter Raster 线程,执行光栅化

Flutter 3.35+ 线程合并:从 Flutter 3.35 版本起,OHOS 平台上 UI 线程与应用主线程可能合并为同一条线程,线程名为应用包名(如 .complex_layout),被归类为 platform_main 而非 flutter_ui。此时不能仅靠线程名判断 UI 线程,而应通过搜索 flutter::Animator::BeginFrame trace 来定位——该 slice 所在的 tid 即为 UI 线程。

3. 基于 OHOS平台Trace 的滑动丢帧定位

渲染链路概览:滑动事件在 trace 中的传递链路如下,便于快速理解全貌:

trace name 说明
originEventHandle 多模输入
DispatchTouchEvent 应用主线程
Shell::OnPlatformViewDispatchPointerDataPacket 应用主线程
Engine::DispatchPointerDataPacket UI 线程
flutter::VsyncFireCallback OS_VSyncThread线程,flutter 应用帧信号
Animator::BeginFrame UI 线程,帧开始
GPURasterizer::Draw Raster线程,光栅化
oh_flutter_1Surface flutter提交的生产buffer及其数量
RSMainThread::DoComposition render_service 合成
RenderFrame GPU 绘制
RSHardwareThread::CommitAndReleaseLayers 上屏

3.1 识别滑动过程

分析滑动丢帧的前提是先确定"哪一段 trace 是滑动"。DevEco Profiler 抓取的 trace 通常长达数秒到数十秒,包含应用启动、空闲、滑动、停止等多个阶段。只有先把滑动区间圈出来,后续的丢帧定位才有意义。

flutter::APP_LIST_FLING是应用代码主动埋点的滑动生命周期标记。如果 trace 中存在该 slice,它覆盖了手指拖滑和惯性滑动的完整过程。在 trace 中搜索 APP_LIST_FLING,取其 start 和 end 时间戳,直接作为后续丢帧分析的时间窗口。

注意APP_LIST_FLING 包含手指拖滑和惯性滑动全过程。但在特殊情况下,该标记可能未完全覆盖实际滑动过程(如滑动开始前埋点未触发、或滑动结束后惯性仍持续),此时需要结合触摸事件链扩展窗口。

3.2 识别丢帧位置

3.2.1 定位丢帧

在滑动窗口内定位丢帧。有两种方法,优先使用第一种:

方法一:通过 flutter::SceneDisplayLag 快速定位

Flutter 提供了 flutter::SceneDisplayLag trace 来标识渲染管线中出现的丢帧。在滑动窗口内搜索该 trace,可以直接定位丢帧发生的时间点,无需逐帧对比耗时。

方法二:通过render_service 快速查阅

render_service 提供了 两个trace泳道来说明buffer消费情况。收藏两个泳道,快速查阅两个泳道不重合或空隙的时间点。

泳道 说明
oh_flutter_1Surface flutter提交的生产buffer及其数量。名字通常是oh_flutter_开头+数字+Surface结尾
render_service 消费flutter提交的buffer

3.2.2 判断瓶颈点

ui线程和raster线程耗时长短没有一个基准,根据慢帧的指标分布,判断瓶颈在哪条线程:

指标特征 瓶颈线程 下一步深挖方向
ui 远大于 raster UI 线程 检查 Animator::BeginFramebegin_frame_ / draw_frame_,确认 BUILD/LAYOUT/PAINT 哪个阶段最重
raster 远大于 ui Raster 线程 检查 GPURasterizer::Draw 子调用栈,确认是 shader 编译、图片解码、saveLayer 还是 fence 等待
uiraster 都高 连锁反应 通常是 UI 层重建过大拖累 Raster,先治 UI 线程
ui/raster 都小,但存在丢帧 UI 线程或主线程 检查DartIsolate::HandleMessageAnimator::AwaitVSync、fence 等待、binder 阻塞

锁定最慢帧后,确认两个线程的帧号frame_number:是否相同。以其时间戳为中心收窄窗口,分别查看 UI 线程和 Raster 线程在该窗口内的 slices,找到最耗时的 slice。根据耗时和调用栈信息,判断是 UI 线程还是 Raster 线程的瓶颈。

标记 trace name 说明
起点 flutter::Animator::BeginFrame UI线程绘制
终点 flutter::GPURasterizer::Draw Raster线程光栅化

主要分支一:UI 线程瓶颈

在收窄窗口内,按 UI 线程 tid 过滤 slices,重点关注 BUILDLAYOUTPAINTAnimateBeginFramePlatformConfiguration 等名称的 slice,确认哪个阶段最重。

图片注意事项:截图应展示 UI 线程在慢帧窗口内的调用栈展开,标注 BUILD/LAYOUT/PAINT 各阶段耗时。如未开启 --trace-systrace 则标注缺少这些阶段。应用开发者可以通过flutter内置工具devtools细看耗时原因。

主要分支二:Raster 线程瓶颈

在收窄窗口内,按 Raster 线程 tid 过滤 Flutter/Impeller 相关 slices,重点观察以下 slice 是否出现及其耗时:

slice 名称 指向的根因
impeller::CreateGlyphAtlas / UpdateAtlasBitmap 字形图集冷启动(首次渲染新字形)
Load/Compile Shaders / bisheng_graphic_pipeline_compilation 着色器冷编译(首次使用新 shader)
SurfaceFrame::Submit / Encode 内部无子瓶颈 场景复杂、绘制指令过多
AcquireNextImageKHR / vkQueueSubmit 耗时长 GPU 资源竞争或驱动瓶颈

4 常见根因和修复方案

4.1 根因:ListView 不定高

现象

长列表快速滑动时掉帧,低端机尤其明显。

原因

Flutter 不知道每一项的高度,滚动时每出现一项都要重新测量。itemBuilder 会被高频调用,测量成本被放大。

修复

固定高度直接给 itemExtent

ListView.builder(
  itemCount: data.length,
  itemExtent: 56, // 每一项高度 56,跳过测量
  itemBuilder: (context, i) => ItemCell(data[i]),
);

高度不固定时也可用 prototypeItem 提供一份样板。

案例

问题案例:不定高,逐项测量

Widget _buildBadListView() {
  return ListView.builder(
    itemCount: itemCount,
    // 未设置 itemExtent,每一项高度需实时测量
    itemBuilder: (context, index) =>
        _buildItem(index, fixedHeight: false),
  );
}

未设置 itemExtent 时,ListView 使用 RenderSliverList(变高列表),滚动偏移无法直接计算,每出现一项都要 build + layout 测一次高度。标签数量 2 7 个、是否换行不一,导致每项高度在约 99130px 之间变化,快速滑动时测量成本被放大,存在丢帧。

修复案例:定高,跳过测量

Widget _buildGoodListView() {
  return ListView.builder(
    itemCount: itemCount,
    itemExtent: 136, // 固定高度(足以容纳两行标签),跳过逐项测量
    itemBuilder: (context, index) =>
        _buildItem(index, fixedHeight: true),
  );
}

设置 itemExtent 后,ListView 切换为 RenderSliverFixedExtentList,滚动偏移 = index * 136,无需逐项测量即可定位可见区域。内容与问题案例完全一致,只是较短项底部留白——这是固定高度换取流畅度的预期权衡。

两案例共用同一个 _buildItem,仅 mainAxisSize 不同——不定高案例用 .min(收缩到内容 → 高度参差),定高案例用 .max(填满 itemExtent):

Widget _buildItem(int index, {required bool fixedHeight}) {
  final tagCount = (index % 6) + 2; // 2 ~ 7 个标签
  return Container(
    margin: _itemMargin,
    padding: _itemPadding,
    decoration: _itemDecorations[index % _itemDecorations.length],
    child: Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      mainAxisSize: fixedHeight ? MainAxisSize.max : MainAxisSize.min,
      children: [
        Text('Item $index', style: _titleStyle),
        const SizedBox(height: 8),
        Wrap(
          spacing: 6,
          runSpacing: 6,
          children: List.generate(tagCount, _buildTag),
        ),
      ],
    ),
  );
}

补充:itemExtent 只消除了"量高度定位"的开销,每一项进入视口时 build + layout 仍会执行。为控制单项构建成本,两案例还共同做了以下处理:

  • 标签用轻量 Container + Text 替代 Chip,避免 Chip 内部 Material/InkWell/shape 裁剪开销;
  • 背景装饰 BoxDecoration + 颜色 shade 预计算为 static final 列表,不在每次 itemBuilder 调用时新建;
  • 样式、边距均提为 static const,全局共享不重建。

效果对比

问题案例 修复案例
itemExtent 未设置 136
每项高度 随标签数量/换行变化(约 99~130px) 固定 136px
滚动定位 逐项 build+layout 测高度 index * 136 直接定位
快速滑动 存在丢帧 流畅
hitrace截图

4.2 根因:过度绘制/离屏缓冲

现象

静态看正常,滑动或动画时掉帧,Raster 线程红。

原因

Flutter 里这些操作容易触发昂贵的 saveLayer(离屏缓冲):

  • Clip.antiAliasWithSaveLayer
  • 动画中使用 Opacity
  • 大面积 BackdropFilter / ImageFiltered 模糊;
  • 复杂阴影。

它们会显著增加 Raster 线程负担。

修复

  • 圆角用默认裁剪,避免 antiAliasWithSaveLayer
  • 动画中需要透明度变化时,用 FadeTransition / AnimatedOpacity 替代直接动画 Opacity
  • 静态半透明直接改颜色 alpha,少用 Opacity Widget;
  • 模糊只用于小区域;
  • 高频重绘的局部区域用 RepaintBoundary 隔离,但别滥用。

案例

50 个 item 各自循环播放透明度动画(0.3↔1.0,2s),两案例动画参数完全相同,差别只在 Widget 选型和裁剪/模糊/阴影配置。

问题案例:四重 saveLayer 叠加

动画驱动用 AnimatedBuilder + 裸 Opacity,opacity 每帧变化触发 paint 阶段 saveLayer

@override
Widget build(BuildContext context) {
  return AnimatedBuilder(
    animation: _animation,
    builder: (context, child) {
      return Opacity(
        opacity: _animation.value,  // 每帧变化 → saveLayer
        child: child,
      );
    },
    child: _buildBadContent(widget.index),
  );
}

内容层还叠加了另外三个 saveLayer / 高耗操作:

Widget _buildBadContent(int index) {
  return ClipRRect(
    clipBehavior: Clip.antiAliasWithSaveLayer,    // ① 离屏裁剪
    borderRadius: _overdrawClipRadius,
    child: Container(
      // ...
      decoration: BoxDecoration(
        gradient: LinearGradient(colors: ...),
        boxShadow: const [
          BoxShadow(blurRadius: 12, spreadRadius: 2, ...),  // ③ 大面积阴影
        ],
      ),
      child: Stack(
        children: [
          Positioned.fill(
            child: BackdropFilter(               // ② 全区域高斯模糊
              filter: ImageFilter.blur(sigmaX: 8, sigmaY: 8),
              child: Container(color: Colors.white.withValues(alpha: 0.1)),
            ),
          ),
          Center(child: Text('Item $index', style: _overdrawTextStyle)),
        ],
      ),
    ),
  );
}

每个可见 item 每帧 = Opacity saveLayer + antiAliasWithSaveLayer saveLayer + 全区域 BackdropFilter 模糊 + 大面积阴影重绘,Raster 线程迅速过载。

修复案例:逐项消除 saveLayer

@override
Widget build(BuildContext context) {
  return RepaintBoundary(              // ④ 隔离重绘范围
    child: FadeTransition(            // ①→合成层 opacity,不 saveLayer
      opacity: _animation,
      child: _buildGoodContent(widget.index),
    ),
  );
}
Widget _buildGoodContent(int index) {
  return ClipRRect(
    clipBehavior: Clip.hardEdge,     // ②→硬边裁剪,不 saveLayer
    borderRadius: _overdrawClipRadius,
    child: Container(
      // ...
      decoration: BoxDecoration(
        gradient: LinearGradient(colors: ...),
        boxShadow: const [
          BoxShadow(blurRadius: 6, spreadRadius: 0, ...),  // ③→缩小阴影
        ],
      ),
      child: Center(
        child: Text('Item $index', style: _overdrawTextStyle),
      ),
    ),
  );
}

核心改动:

  • OpacityFadeTransition:opacity 下沉到合成层(OpacityLayer),子树只 paint 一次,之后每帧仅 compositor 调整 layer opacity,paint 阶段不再 saveLayer
  • antiAliasWithSaveLayerhardEdge:直接在 paint 时跳过裁剪区域外像素,不开离屏 buffer;
  • BackdropFilter 直接移除:背景只是渐变,模糊无实际视觉收益,删掉省掉逐像素卷积;
  • BoxShadow 缩小(blur 12→6, spread 2→0):减少阴影重绘面积;
  • 外层 RepaintBoundary:动画只脏标记自己的 layer,不向父级 ListView 传播。

补充:AnimatedBuilderchild 参数应传入不变的内容(如上 _buildBadContent),而非在 builder 内部每次调用——这样 Widget tree 只构建一次。问题案例虽仍因 Opacity 触发 saveLayer,但至少避免了每帧重建子树的开销。

效果对比

开销来源 问题案例 修复案例
透明度动画 Opacity → paint 阶段 saveLayer FadeTransition → 合成层调 opacity
圆角裁剪 antiAliasWithSaveLayer → 离屏 buffer hardEdge → 直接裁剪
背景模糊 全区域 BackdropFilter sigma=8 移除
阴影 blur 12 / spread 2 blur 6 / spread 0
重绘隔离 无显式 RepaintBoundary RepaintBoundary
快速滑动 Raster 线程红,明显掉帧 流畅
hitrace截图

5. 参考与延伸

Logo

开源鸿蒙跨平台开发社区汇聚开发者与厂商,共建“一次开发,多端部署”的开源生态,致力于降低跨端开发门槛,推动万物智联创新。

更多推荐