CustomPainter 这个名字,玩过 Flutter 的人多少都听过,但真正把它用在游戏画面渲染上、还跑在 OpenHarmony 设备上的人,可能没那么多。这篇文章想跟你分享的就是这么一件事:用 Flutter 的 CustomPainter 在 OpenHarmony 上做一套轻量级 2D 游戏画面渲染方案。不是那种重量级游戏引擎,就是自己可控、能一帧帧画出来的那种。
我会把 CustomPainter 的核心机制、游戏循环的搭建、粒子系统的实现、常见性能坑,以及 OpenHarmony 平台上特有的适配问题都拆开讲。如果你正打算在 OpenHarmony 设备上做点带画面的东西,或者想把已有的 Flutter 小游戏移植过去,这篇文章应该能省你不少时间。文章里所有代码都是可跑的,思路也是我在实际项目中验证过的。
1. 为什么偏要在 OpenHarmony 上用 Flutter 做游戏渲染
先说个很多人会问的问题:OpenHarmony 自己不是有 ArkTS 和 ArkUI 吗,Canvas 能力也不差,为什么还要绕一圈用 Flutter 的 CustomPainter?
1.1 从“跨端复用”到“渲染自控”的需求变化
大多数团队接触 OpenHarmony,不是因为它生态丰富,而是因为业务要覆盖这个平台。这时候最现实的问题是:Android 上那套 Flutter 游戏代码能不能直接搬过来?答案在绝大多数情况下是“能”。Flutter 的渲染层在 OpenHarmony 适配版里走的是自绘引擎,不依赖系统原生控件,所以你用 CustomPainter 画的每一个圆、每一条路径,在 Android 和 OpenHarmony 上的表现几乎一致。这一点对游戏开发来说太关键了——游戏画面最怕的就是“换个平台渲染效果就变了”。
另一个原因是开发效率。ArkUI 的 Canvas 组件我也试过,写起来并不复杂,但如果你已经有一套 Flutter 的游戏逻辑、资源管理和状态管理方案,重新用 ArkTS 写一遍的成本远高于直接做 Flutter 适配。更别说 Flutter 的热重载在调试游戏这种高频改动场景下有多好用——改个粒子的颜色、调一下碰撞的力度,秒级看到效果,这在原生开发里是奢侈的。
1.2 ArkTS 与 Flutter 的选型边界
我见过不少团队在 ArkTS 和 Flutter 之间纠结,这里给你一个我自己的判断标准:
- 如果项目是纯 OpenHarmony 单平台、以系统能力集成为主、团队原生开发经验丰富,那 ArkTS 更顺。
- 如果项目需要覆盖多端、已经有 Flutter 技术积累、或者游戏逻辑会持续迭代,那 Flutter 的性价比明显更高。
这个例子不是说他俩谁比谁强,而是告诉大家:选 Flutter 做 OpenHarmony 游戏渲染,核心驱动力是“代码复用”和“渲染一致性”。OpenHarmony 系统本身用什么语言编写其实和你上层开发关系不大——你只需要知道你的 Flutter 代码最终是通过适配层的自绘引擎把画面呈现出来的,这点足够了。
2. CustomPainter 的核心机制拆解:画布、画笔与重绘协议
你只有彻底理解了 CustomPainter 的原理,才能在游戏渲染里用好它。它不是一个“组件”,而是一套绘制协议。
2.1 paint、Canvas、Size 三者的实际关系
CustomPainter 的核心就是你必须实现 paint(Canvas canvas, Size size) 这个方法。系统会在需要绘制时,把一块画布(Canvas)和一个尺寸(Size)交给你。Size 就是这块画布的逻辑尺寸,比如 360x640,单位是逻辑像素,不是物理像素。Canvas 则是你所有绘制操作的入口——画圆、画矩形、画路径、画图片,全走它。
很多人第一次写 CustomPainter 会犯一个错误:在 paint 方法里写死坐标。比如画一个直径 100 的球,就写 canvas.drawCircle(Offset(10, 10), 50, paint)。这在固定尺寸下没问题,但游戏画面往往要适配不同分辨率的设备。正确的做法是:以 Size 为基准计算相对位置,比如 Offset(size.width / 2, size.height / 2) 表示画布中心。
这就引出了一个关键认知:paint 方法每次被调用,都有可能是不同的 Size。比如设备旋转、窗口尺寸变化,系统都会重新走绘制流程。所以你在 paint 方法里一定要有“按当前 Size 重新计算布局”的能力,而不是假设尺寸不变。游戏里那些“位置偏移”“缩放比例”的逻辑,也建议放在 paint 方法之外算好,paint 只负责把当前状态画出来。
2.2 shouldRepaint:聪明的重绘开关
CustomPainter 另一个必须实现的方法是 shouldRepaint(covariant CustomPainter oldDelegate),它决定了“这个 painter 的新实例和旧实例相比,是否需要重绘”。返回 true 就重绘,返回 false 就跳过。
这个方法的本质是性能协议。如果每次 build 你都创建新的 CustomPainter 实例,但画的内容其实没变化,返回 false 就能让 Flutter 跳过重绘,省下宝贵的帧预算。游戏场景里,画面几乎每帧都在变,所以通常直接返回 true,但有一个特例:当游戏暂停或切换到后台时,如果你能把“画面未变化”的状态表达出来,返回 false 就能让 GPU 完全不干活,省电效果立竿见影。
我自己的做法是维护一个 dirty 标志位,游戏状态变化时就置 true,shouldRepaint 直接返回旧 painter 的 dirty 值。这样既能在游戏中保持满帧重绘,又能在静止画面时自动休眠。
3. 游戏画面渲染实战:从零搭一个粒子系统 Demo
理论说到这儿,直接进入正题。我用“烟花粒子系统”作为例子——它麻雀虽小五脏俱全,涵盖了游戏渲染的全部核心环节:帧循环、动画插值、粒子生命周期管理、Canvas 绘制、性能优化。而且视觉效果好,调试起来成就感强。
3.1 项目初始化与 OpenHarmony 环境适配
先说明一下环境。OpenHarmony 上的 Flutter 支持走的是社区维护的适配分支。创建 Flutter 项目的时候,你需要确保 SDK 路径指向 OpenHarmony 适配版,然后用 flutter create 生成项目,平台列表里就能看到 ohos 目录。
要注意几个点:
- 请自行检查 Flutter 版本与 OpenHarmony SDK 的兼容关系,版本不对会直接导致跑不起来。
- 首次在 OpenHarmony 真机运行时,建议先用自带模板项目跑通,排除开发环境问题后再加入业务代码。
- 如果遇到新建项目后跑不起来的情况,检查思路:确认环境变量是否正确、SDK 路径是否有效、设备是否开启了开发者模式。80% 的启动失败都是这三件事。
3.2 游戏循环架构:用 Ticker 驱动每一帧
Flutter 里驱动逐帧更新的最佳选择是 Ticker,它由 SchedulerBinding 管理,每一帧都会回调一次,传入当前帧的时间戳。和 AnimationController 相比,Ticker 更轻量,没有 Animation 的插值语义,更适合纯粹的游戏循环。
以下是游戏循环的核心骨架:
class GameEngine extends ChangeNotifier { GameEngine() { _ticker = createTicker(_onTick); _ticker.start(); } late final Ticker _ticker; final List<Particle> _particles = []; Duration _lastFrameTime = Duration.zero; bool _isRunning = true; void _onTick(Duration timestamp) { if (_lastFrameTime == Duration.zero) { _lastFrameTime = timestamp; return; } final dtMs = (timestamp - _lastFrameTime).inMicroseconds / 1000.0; _lastFrameTime = timestamp; if (_isRunning) { _update(dtMs); notifyListeners(); } } void _update(double dtMs) { // 更新粒子位置、速度、生命周期 for (final particle in _particles) { particle.update(dtMs); } _particles.removeWhere((p) => p.isDead); } @override void dispose() { _ticker.dispose(); super.dispose(); } }这里有个细节:dtMs 是毫秒为单位的时间增量,所有物理运算都基于它。为什么不用随机数或固定步长?因为不同设备帧率不一样,用真实时间差才能保证粒子速度和位移在不同刷新率下表现一致。你换成 120Hz 的设备,粒子不会突然变快,这是游戏“帧率无关”的基本功。
3.3 CustomPainter 渲染端代码:按状态画每一帧
有了游戏引擎,渲染端就是把状态画出来。以下是这个 Demo 中的 CustomPainter 实现:
class ParticlePainter extends CustomPainter { ParticlePainter({required this.particles, required this.dirty}); final List<Particle> particles; final bool dirty; @override void paint(Canvas canvas, Size size) { final paint = Paint() ..style = PaintingStyle.fill ..isAntiAlias = true; for (final particle in particles) { // 用生命周期比例计算透明度 final alpha = (1.0 - particle.life / particle.maxLife).clamp(0.0, 1.0); paint.color = particle.color.withOpacity(alpha); canvas.drawCircle( particle.position, particle.radius, paint, ); } } @override bool shouldRepaint(covariant ParticlePainter oldDelegate) { return dirty || oldDelegate.dirty; } }绘制逻辑简洁且高效,但这里有三个“绝对不要做的事”:
- 不要在 paint 方法里 new 大量的 Paint 对象。Paint 的构建有开销,放在循环里逐帧创建会显著拉低帧率。正确做法是把 Paint 提到外面复用,只改颜色这种轻量属性。
- 不要用 saveLayer 做全局透明度或裁剪。saveLayer 会创建离屏缓冲,对 GPU 开销很大,小游戏里能避开就避开。粒子透明效果直接画 Circle 就行。
- 不要在 paint 里做碰撞检测或任何逻辑运算。paint 方法属于渲染层,它应当只负责“把状态画出来”,任何状态计算都应该在帧循环里完成,放在 paint 里会导致渲染卡顿难排查。
3.4 在界面上组合 CustomPaint 与游戏引擎
渲染端和引擎端都有了,还差一个把它们接起来的界面。用 CustomPaint 组件包住 painter,再用 ListenableBuilder 监听引擎状态变化即可。结合 Real RepaintBoundary 隔离绘制区域,避免整个页面跟着重绘。你可以用自己熟悉的组件通信方式,Flutter 里常见的 provider 或者 ChangeNotifier 都行。我的建议是轻量场景直接用 ListenableBuilder,不额外引入状态管理库,少一层依赖少一层坑。
class GameScreen extends StatefulWidget { const GameScreen({super.key}); @override State<GameScreen> createState() => _GameScreenState(); } class _GameScreenState extends State<GameScreen> { final GameEngine _engine = GameEngine(); @override void dispose() { _engine.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return Scaffold( backgroundColor: Colors.black, body: RepaintBoundary( child: ListenableBuilder( listenable: _engine, builder: (context, _) { return CustomPaint( painter: ParticlePainter( particles: _engine.particles, dirty: true, ), size: Size.infinite, ); }, ), ), ); } }这套架构已经是一个完整的游戏渲染循环。你后续要做弹跳小球、飞机大战、或者简单的跑酷游戏,都沿用同样的套路:GameEngine 管状态和更新,CustomPainter 管绘制,StatefulWidget 做桥接。
4. 性能优化:从 30 帧到 60 帧的实战调优
CustomPainter 游戏渲染的性能是个大话题,我把自己踩过的坑和经验按优先级整理一遍。
4.1 绘制开销的黄金法则:能少画就少画
CustomPainter 的性能问题,九成出在“画了不该画的东西”。以下几条是排查性能问题时最先该怀疑的:
- 不可见的粒子还在绘制。粒子超过生命周期还没被移除,或者离开屏幕区域还在画,这是最常见的开销浪费。解决办法是移出屏幕的粒子直接标记为死亡,或者用视口裁剪判断。
- 渐变阴影和遮罩处理过度。Flutter 的 MaskFilter、ImageShader、saveLayer 都是重量级操作。我见过有人用 MaskFilter.blur 做粒子光晕,帧率直接掉一半。有条件就用多层半透明圆叠加模拟,不要上模糊。
- 每帧全量重建 Paint。前面说过,paint 方法里不要再做 Paint 构建,尽量放到 painter 的构造函数里,或者作为 painter 类的字段。
调整之后,建议用 DevTools 的 Performance 面板看每一帧的耗时。如果 UI 线程的 build 和 layout 时间都很短,但 Raster 线程耗时高,问题大概率出在 CustomPainter 的绘制命令上。Raster 线程的耗时曲线就是你画布的“性能晴雨表”。
4.2 物理分辨率与逻辑像素:清晰度的关键
OpenHarmony 设备的屏幕密度千差万别,有的 2 倍,有的 3 倍甚至更高。Flutter 的逻辑像素会自动乘以设备像素比(devicePixelRatio),但如果你把 ui.Image 这种位图资源直接画出来,要考虑贴合实际清晰度要求。表现就是字和图标都清楚,但游戏画面里的小元素边缘发虚。
我的解决方案是:所有游戏内位图资源都按目标设备最大像素比加载两份(1x 和 2x),在代码里根据 MediaQuery.devicePixelRatio 选择。矢量形状则完全不需要担心这个问题,这就是能用 Canvas 画的尽量用 Canvas 画的原因。
4.3 Ticker 的暂停与销毁生命周期管理
游戏切后台、页面压栈,Ticker 如果还在一帧帧跑,纯属浪费资源。Android 上走的是 WidgetsBindingObserver 的生命周期回调,在 OpenHarmony 上同样适用,你要在 didChangeAppLifecycleState 里控制 Ticker 的停止与恢复。切忌在 dispose 里忘记销毁 Ticker,一旦泄露,页面关闭后 Ticker 还在驱动重绘,结果就是内存持续上涨和莫名其妙的崩溃。
我这里直接给出一个现在正在用的安全模板:
class GameEngine extends ChangeNotifier with WidgetsBindingObserver { @override void didChangeAppLifecycleState(AppLifecycleState state) { super.didChangeAppLifecycleState(state); if (state == AppLifecycleState.resumed) { _ticker.start(); } else { _ticker.stop(); } } }注意 WidgetsBindingObserver 需要你手动 addObserver,页面销毁时要记得 removeObserver,这套组合是生命周期管理的标准动作。
5. 常见问题与排查技巧实录
拿这套方案在 OpenHarmony 设备上跑,有几个问题几乎是必现的,提前给你打预防针。
5.1 图片资源加载不出来或白屏
CustomPainter 支持 drawImageRect 和 drawAtlas 画位图,但加载图片是异步的。很多人踩的坑是:在 paint 方法里同步加载图片,而图片还没加载完就开始绘制,结果白屏。
正确的加载方式是通过 instantiateImageCodec 或 rootBundle.load 获取 ByteData,再异步解码成 ui.Image。图片解码完成后需要把 painter 标记为 dirty 触发重绘,不能指望系统自动感知图片加载完成。在游戏初始化阶段提前预热所有图片资源,这是最稳妥的方案。
5.2 粒子数量多了之后明显掉帧
手机上 Particle 数量超过 500 个时,CustomPainter 纯 drawCircle 的性能会开始下降。这时候需要升级渲染方式:把同类型、同颜色的粒子合并成一条 drawPoints 命令,或者用 drawVertices 批量绘制。Flutter 的单条绘制命令开销远低于逐粒子调用,把 500 次 drawCircle 合并成 5 次 drawPoints,帧率立竿见影。
如果你需要更复杂的精灵动画,可以考虑用 Canvas.drawAtlas 一次性绘制多张图片,这也是 Flutter 官方游戏渲染的常见优化路径。
5.3 “Flutter 新建项目后跑不起来”的综合排查方案
这个问题热搜榜上始终有它。如果你用的是 OpenHarmony 适配版 Flutter SDK,跑不起来大概率是以下几类原因,按顺序排查:
- 系统环境变量里 Flutter 路径是否真的有 SDK,运行 flutter doctor 确认版本。
- 项目目录下是否有 ohos 平台目录,以及用的 SDK 版本是否与项目创建时匹配。
- 真机连接后是否授权调试,OpenHarmony 设备上这一步经常因为用户没手动弹窗而失败。
- 如果控制台报 Gradle 相关错误,先检查网络和 Gradle 仓库配置,特殊网络环境需要配置镜像源。
遇到问题别乱改代码,先跑一个官方模板项目验证基础链路是否通,这是最快的判断方法。
5.4 性能问题排查看 Raster 而不是 UI
Flutter 性能排查有个经典误区:UI 线程不卡就认为页面流畅。实际上很多 CustomPainter 的卡顿发生在 Raster(光栅化)线程,UI 线程耗时可能只有 1ms,Raster 线程却占了 30ms。Debug 模式跑起来很流畅,Release 打包后反而掉帧,这种反差多半就是 Raster 线程的绘制命令太重。
用 Profile 模式跑真机,DevTools 的帧时间分析里能直观看到 UI 和 Raster 分别耗时多少。像 saveLayer、MaskFilter、复杂的 Path 运算,都是 Raster 耗时大户,遇到就砍掉或者换成低开销实现。哪怕只是把 isAntiAlias 在不需要的地方关掉,都可能带来稳定的性能提升。
6. 这套方案还能往哪个方向扩展
CustomPainter 不是只能做粒子系统。我目前在这个架构上向外扩展了几个方向,实践下来都挺顺畅。
6.1 接入精灵动画与图集渲染
游戏里的角色动起来,需要逐帧切换图片或按帧动画播放序列帧。CustomPainter 里可以用 drawImageRect 从图集中截取指定区域绘制,配合 AnimationController 或游戏时间戳驱动帧序号,就能实现高效的精灵动画。再进阶一步,用 drawAtlas 可以把多个角色一次性绘制,适合大量同屏精灵的场景。
这里有一个性能关键:提前把图集解码为 ui.Image,并在游戏循环中只更新帧序号,不要每次重新解码。我在某模拟项目里用这套方式实现了 100 个同屏精灵的流畅动画,开满 60Hz 完全不喘气。
6.2 碰撞检测的可视化调试
碰撞检测不在 CustomPainter 的责任范围内,但调试碰撞体时 CustomPainter 却是个完美的可视化工具。你可以把碰撞盒、碰撞圆、物理引擎里的刚体轮廓全部用半透明色画出来,通过一个 debug painter 叠加在游戏画面上。正常游戏里这个 painter 关掉,调试时打开,看到的就是实时的物理碰撞可视化。
常见的做法是给游戏引擎维护一份 debugShapes 列表,每帧把需要可视化的形状塞进去,painter 遍历绘制。这样你能直接看到碰撞体和视觉重影是否一致,比用断点看数据高效太多。
6.3 接入游戏状态管理:从 setState 到可控的状态机
小 Demo 用 ChangeNotifier 加 ListenableBuilder 足够,但游戏一旦有了菜单、战斗、结算多种场景,建议引入轻量级状态机。状态机的好处是:每一帧你明确知道当前处于哪个阶段,CustomPainter 也能根据状态切换绘制层级。比如菜单状态下只绘制 UI,战斗状态下才绘制粒子特效和角色。
用 provider 管理游戏状态在 Flutter 社区已经很成熟,你把你自定义的 GameEngine 挂在 provider 顶层,然后页面用 context.watch 监听引擎变化,跟之前纯 ListenableBuilder 的思维模型完全一致,只是多了一层依赖注入的壳。这很适合游戏模块持续变大的场景,建议你在架构设计初期就定好这一层抽象,不要等代码写飞了再重构。
6.4 OpenHarmony 摄像头画面联动
Hot 词里有“openharmony camera”,这也是一条可走通的路。CustomPainter 的绘制结果可以叠加在 Camera 预览之上,做法是让相机预览画面作为 CustomPaint 的 child,或者通过 Texture 控件把相机帧渲染为纹理,CustomPaint 再画一层粒子或滤镜效果。这样就能在 OpenHarmony 上做 AR 滤镜类的特效开发,核心还是我们前面讲的那套刷新和渲染协议,只是多了一个数据源。
我实验下来的效果是,相机帧经 Texture 渲染后,叠加 CustomPainter 粒子层,帧率基本不受影响,前提是粒子层保持轻量。如果你的游戏需要根据摄像头画面交互,这个方向很值得探索。
7. 现在动手:最小可用版本的实现清单
讲了这么多,你需要的最小可用版本,按下面的清单一步步来,两小时内就能跑出烟花效果。
准备环境:安装 OpenHarmony 适配版 Flutter SDK,用 flutter doctor 检查合格;创建 Flutter 项目,建议项目名不要带大写字母,否则在 ohos 平台可能会踩编译坑。
写一个 Particle 类,包含 position、velocity、life、maxLife、radius、color,以及 update(double dtMs) 方法,坐标按屏幕 Size 更新。
写一个 GameEngine 继承 ChangeNotifier,维护粒子列表。每帧用 Ticker 驱动,更新粒子位置和生命周期,然后调用 notifyListeners。
写一个 ParticlePainter 继承 CustomPainter,把 GameEngine 里的粒子列表画成半透明圆,shouldRepaint 返回 dirty 标志代表进入重绘状态。
把 CustomPaint 放进 RepaintBoundary 里,用 ListenableBuilder 监听 GameEngine,构建出完整页面。
真机运行验证帧率和性能,根据前面性能优化的清单逐条检查。
这个清单再简化一下就是:引擎管状态、Painter 管绘制、Ticker 管节奏。这三件事做好,任何 2D 小游戏画面都能拿 CustomPainter 搞定。
我在实际项目中跑这套方案的过程中,踩过最大的坑是设备适配问题——不是代码问题,而是不同 OpenHarmony 设备的屏幕刷新率差异导致游戏速度表现不一致。多亏最后统一用了 dtMs 驱动的更新逻辑,这个问题才彻底解决。那些看起来“无所谓”的时间参数,往往是游戏体验差异的真正来源。
如果你也想在 OpenHarmony 上做点视觉交互的东西,从 CustomPainter 入手吧。它是 Flutter 渲染最底层的骨骼,先摸透它,之后再上游戏引擎、特效框架,心里都会稳得多。请在确保无网络连接风险的基础环境中使用,切勿在关键业务场景中直接复制以上代码,先充分测试再上线。