Flutter 鸿蒙性能优化实战:用 RepaintBoundary 隔离动画重绘
Flutter 鸿蒙性能优化实战:用 RepaintBoundary 隔离动画重绘
动画页面常见的问题是:只有一个小图形在动,但整页都在重复绘制。本文项目用 CustomPainter 画 120 个轨道点,旁边放一段静态文字,开关控制是否把画布包进 RepaintBoundary。
项目地址:flutter_ohos_repaint_demo。画布通过 AnimationController 驱动,CustomPainter.shouldRepaint 只在进度变化时返回 true。边界代码会在测量口径明确后展示。
一、实测环境和测量方式
| 项目 | 实测版本或条件 |
|---|---|
| Flutter OH | 3.44.9+ohos-0.0.1-canary1 |
| Dart | 3.12.2 |
| DevEco Studio | 26.0.0 Release |
| HarmonyOS | 7.0.0.105 (API 26) |
| 构建模式 | profile |
| 动画内容 | CustomPainter 绘制 120 个轨道点 |
隔离和未隔离模式使用同一动画时长、同一刷新率和同一页面内容;调试模式的重绘标记只用于定位,不能用来报告正式帧时间。
RepaintBoundary(
child: AnimatedCanvas(animation: controller),
)
RepaintBoundary 会把子树作为独立绘制层处理,适合动画区域和周围内容相对独立的场景。它不是“加了就一定快”:边界太多会增加合成和纹理管理,动画本身如果覆盖整屏,隔离收益也可能有限。我的验证重点是比较静态文字是否跟随每帧重绘,而不是只看开关后的主观感觉。
在 DevTools Performance 中,可以观察 UI、Raster 和帧时间;在 Widget Inspector 中可以打开重绘彩色标记。鸿蒙设备还应注意刷新率和省电模式,16 ms 只是 60 Hz 下常用的参考线,不适用于所有屏幕。测试时要记录设备刷新率、动画时长和是否开启调试模式。
常见误区是把 AnimatedBuilder、RepaintBoundary 和 const 当成同一种优化。AnimatedBuilder 缩小动画状态通知范围,RepaintBoundary 缩小绘制范围,const 减少不必要的 Widget 重建。三者可以组合,但应该从时间线证据出发。
仓库已通过分析、Widget 测试和无签名 HAP 构建。本文补充了隔离和未隔离两张真机页面图;DevTools 的 Raster 时间线和重绘区域仍需按相同动画时长单独采样,不能只截一张静态页面就宣称动画优化完成。
我的建议是先找到真正频繁变化的区域,再放边界;静态背景、复杂阴影和大面积滤镜要单独评估。性能优化的目标是稳定帧时间,而不是让代码看起来更“高级”。
二、先看容易出问题的动画写法
1. 这次实验只让一个区域动起来
为了避免页面内容过于简单,我使用 CustomPainter 绘制 120 个轨道点,让动画有稳定的 Raster 工作量;旁边的文字和开关保持静态。动画由 AnimationController 驱动,AnimatedWidget 只监听这个 controller,shouldRepaint 也只比较当前进度。开关切换时,唯一变化是画布是否被 RepaintBoundary 包住。
这样设计是为了回答一个具体问题:动画区域之外的静态文字,是否会跟随每一帧一起进入绘制流程。它不等于所有动画加边界都会变快。如果动画本身覆盖整屏,边界没有可隔离的周围内容;如果边界数量太多,还可能增加合成、纹理和内存管理成本。
2. UI、绘制和合成要分开看
AnimatedBuilder 或 AnimatedWidget 主要解决状态通知范围,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;不相关的属性变化不应该触发整张画布重绘。第三,确认画布面积和阴影、裁剪、渐变数量在两组模式中一致。
可以用 AnimatedBuilder 的 child 参数把静态内容提前构建:
AnimatedBuilder(
animation: controller,
child: const StaticDescription(),
builder: (context, child) => Column(
children: [
OrbitPainter(progress: controller.value),
child!,
],
),
)
这段代码解决的是 build 范围;是否还需要 RepaintBoundary,要看静态内容和动画画布在 paint 阶段是否真的互相影响。不要把 AnimatedBuilder 和 RepaintBoundary 当成互换的 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 一定下降。
七、这次实验的实际结论
AnimatedBuilder 或 AnimatedWidget 负责缩小动画状态通知范围,RepaintBoundary 负责建立绘制边界,const 负责稳定 Widget 配置。动画区域较小、周围有较多静态内容且时间线显示重复绘制时,边界才更可能有收益;整屏动画、边界过多或大纹理持续上传时,增加边界可能不划算。
当前文章不填写未经多轮 profile 采样的帧时间数字。迁移到业务前,至少要保留隔离和未隔离两份原始录制,并对比 UI P95、Raster P95、慢帧数和合成层数量。
欢迎加入CPF-Flutter 鸿蒙社区:https://atomgit.com/CPF-Flutter
更多推荐

所有评论(0)