Flutter 鸿蒙性能优化实战:用 RepaintBoundary 隔离动画重绘

动画页面常见的问题是:只有一个小图形在动,但整页都在重复绘制。本文项目用 CustomPainter 画 120 个轨道点,旁边放一段静态文字,开关控制是否把画布包进 RepaintBoundary

项目地址:flutter_ohos_repaint_demo。画布通过 AnimationController 驱动,CustomPainter.shouldRepaint 只在进度变化时返回 true。边界代码会在测量口径明确后展示。

一、实测环境和测量方式

项目实测版本或条件
Flutter OH3.44.9+ohos-0.0.1-canary1
Dart3.12.2
DevEco Studio26.0.0 Release
HarmonyOS7.0.0.105 (API 26)
构建模式profile
动画内容CustomPainter 绘制 120 个轨道点

隔离和未隔离模式使用同一动画时长、同一刷新率和同一页面内容;调试模式的重绘标记只用于定位,不能用来报告正式帧时间。

RepaintBoundary(
  child: AnimatedCanvas(animation: controller),
)

RepaintBoundary 会把子树作为独立绘制层处理,适合动画区域和周围内容相对独立的场景。它不是“加了就一定快”:边界太多会增加合成和纹理管理,动画本身如果覆盖整屏,隔离收益也可能有限。我的验证重点是比较静态文字是否跟随每帧重绘,而不是只看开关后的主观感觉。

在 DevTools Performance 中,可以观察 UI、Raster 和帧时间;在 Widget Inspector 中可以打开重绘彩色标记。鸿蒙设备还应注意刷新率和省电模式,16 ms 只是 60 Hz 下常用的参考线,不适用于所有屏幕。测试时要记录设备刷新率、动画时长和是否开启调试模式。

常见误区是把 AnimatedBuilderRepaintBoundaryconst 当成同一种优化。AnimatedBuilder 缩小动画状态通知范围,RepaintBoundary 缩小绘制范围,const 减少不必要的 Widget 重建。三者可以组合,但应该从时间线证据出发。

仓库已通过分析、Widget 测试和无签名 HAP 构建。本文补充了隔离和未隔离两张真机页面图;DevTools 的 Raster 时间线和重绘区域仍需按相同动画时长单独采样,不能只截一张静态页面就宣称动画优化完成。

我的建议是先找到真正频繁变化的区域,再放边界;静态背景、复杂阴影和大面积滤镜要单独评估。性能优化的目标是稳定帧时间,而不是让代码看起来更“高级”。

二、先看容易出问题的动画写法

1. 这次实验只让一个区域动起来

为了避免页面内容过于简单,我使用 CustomPainter 绘制 120 个轨道点,让动画有稳定的 Raster 工作量;旁边的文字和开关保持静态。动画由 AnimationController 驱动,AnimatedWidget 只监听这个 controller,shouldRepaint 也只比较当前进度。开关切换时,唯一变化是画布是否被 RepaintBoundary 包住。

这样设计是为了回答一个具体问题:动画区域之外的静态文字,是否会跟随每一帧一起进入绘制流程。它不等于所有动画加边界都会变快。如果动画本身覆盖整屏,边界没有可隔离的周围内容;如果边界数量太多,还可能增加合成、纹理和内存管理成本。

2. UI、绘制和合成要分开看

AnimatedBuilderAnimatedWidget 主要解决状态通知范围,RepaintBoundary 主要解决绘制范围,const 主要减少 Widget 重建。一个动画可以三者组合,但每一层要有自己的验证证据:Widget Inspector 看重建,Performance 时间线看 UI/Raster,重绘彩色标记看哪些区域反复绘制。

我不会用一张静态截图证明动画优化成功。真机截图只能证明开关确实改变了页面状态;真正的帧稳定性要在相同动画时长、相同刷新率和相同后台负载下采样。16 ms 只适合本次 60 Hz 参考线,不能直接套到高刷新率设备。

3. 先确认没有把动画做成整页重建

没有边界的对照实现可以很简单:把画布和静态内容都放在同一个动画监听分支里。每一帧虽然只改变一个圆点的位置,但父级 build、静态文字和装饰背景都会重新参与绘制:

AnimatedBuilder(
  animation: controller,
  builder: (context, child) {
    return Column(
      children: [
        OrbitPainter(progress: controller.value),
        const StaticDescription(),
      ],
    );
  },
)

优化后把静态子树作为 child 传入,并只让画布读取动画值,再根据绘制范围决定是否增加 RepaintBoundary。这样优化目标就很明确:静态区域不再因为动画值变化而反复进入 build 或 paint,而不是泛泛地追求组件数量减少。

三、换成动画区域的局部隔离

1. 边界的代价也要测

每增加一个 RepaintBoundary,渲染树就多一个可能独立缓存的层。边界适合相对独立、频繁变化且面积可控的区域;把整页拆成很多小边界,可能增加合成、纹理上传和内存占用。验证时建议同时开启 Performance overlay 和重绘彩色标记,观察动画区域是否稳定、合成层是否异常增加。

现象更可能的原因下一步
UI 线程峰值高动画监听范围过大AnimatedBuilder.child 缩小 build
Raster 线程峰值高画布、阴影或滤镜太重缩小绘制面积、减少效果
合成层数量暴涨边界过多或层级复杂合并边界后重新采样

这张表帮助区分问题阶段,避免用 RepaintBoundary 去修复实际发生在 JSON 解析或图片解码中的卡顿。

最终验收至少保留一份未隔离和一份隔离模式的原始录制,并注明录制时长、设备刷新率、后台负载和是否首次播放。这样后续修改画布数量或视觉效果后,仍然可以回到同一基线判断边界是否继续有收益。

2. 动画页面的三个检查点

第一,确认动画值只被动画区域读取。若页面顶部标题、按钮状态或整个 Scaffold 都依赖同一个 controller,父级仍然会每帧重建。第二,确认 CustomPainter.shouldRepaint 没有无条件返回 true;不相关的属性变化不应该触发整张画布重绘。第三,确认画布面积和阴影、裁剪、渐变数量在两组模式中一致。

可以用 AnimatedBuilderchild 参数把静态内容提前构建:

AnimatedBuilder(
  animation: controller,
  child: const StaticDescription(),
  builder: (context, child) => Column(
    children: [
      OrbitPainter(progress: controller.value),
      child!,
    ],
  ),
)

这段代码解决的是 build 范围;是否还需要 RepaintBoundary,要看静态内容和动画画布在 paint 阶段是否真的互相影响。不要把 AnimatedBuilderRepaintBoundary 当成互换的 API。

四、帧时间和重绘范围如何验证

1. 帧时间应该怎样统计

动画录制需要固定时长,例如 3 秒;开始录制前先等待页面稳定,结束后再移除 addTimingsCallback。除了平均帧时间,还应记录 UI P95、Raster P95、超过目标帧间隔的帧数,以及动画区域是否出现绘制峰值。平均值很容易掩盖某几帧因为纹理上传或 shader 编译产生的尖峰。

真机第一次播放可能包含 shader 预热、图片解码和字体准备,建议丢弃第一次冷启动动画,随后再做多次采样。文章中的前后截图只用来证明开关状态,不能替代动画时间线。

2. 什么时候不要加边界

如果动画覆盖整页、静态内容很少,边界几乎没有可隔离的区域;如果画布很小但边界数量很多,合成开销可能超过节省的绘制开销;如果动画内容本身每帧都需要重新生成大纹理,边界也无法减少 Raster 工作。先找到重复绘制的区域,再决定边界位置,通常比全局套一层更稳妥。

3. 动画和滚动同时存在时

动画区域在列表中时,RepaintBoundary 的位置还会影响子项复用和层数量。不要为每一行都创建一个持续运行的 controller;应让不可见条目暂停动画,或者只让当前可见项播放。快速滚动期间可以降低动画频率,停止滚动后再恢复,避免滚动和动画同时争用 Raster 时间。

五、复现命令和测试范围

flutter pub get
flutter analyze
flutter test
flutter build hap --debug --no-codesign
flutter run --profile -d <ohos-device-id>

启动后分别保存隔离和未隔离状态,打开 DevTools Performance,执行相同时间段的动画录制,并记录 Raster 峰值、慢帧数量和设备刷新率。出现收益后还要检查合成层数量,避免用一个局部优化换来更高的整体合成成本。

六、真机操作和结果边界

本 Demo 的两张真机截图用于确认隔离开关和动画页面状态。隔离模式和未隔离模式必须使用同一动画时长、同一画布内容、同一设备和同一刷新率;截图不能替代 Performance 时间线,也不能单独证明 Raster 一定下降。

七、这次实验的实际结论

AnimatedBuilderAnimatedWidget 负责缩小动画状态通知范围,RepaintBoundary 负责建立绘制边界,const 负责稳定 Widget 配置。动画区域较小、周围有较多静态内容且时间线显示重复绘制时,边界才更可能有收益;整屏动画、边界过多或大纹理持续上传时,增加边界可能不划算。

当前文章不填写未经多轮 profile 采样的帧时间数字。迁移到业务前,至少要保留隔离和未隔离两份原始录制,并对比 UI P95、Raster P95、慢帧数和合成层数量。

欢迎加入CPF-Flutter 鸿蒙社区:https://atomgit.com/CPF-Flutter

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐