ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Flutter CustomPainter在OpenHarmony上做小游戏渲染的实践与优化

Flutter CustomPainter在OpenHarmony上做小游戏渲染的实践与优化 最近在做一个小游戏Demo把Flutter的CustomPainter渲染管线跑在了OpenHarmony设备上算是把自定义绘制玩明白了。Flutter本身是UI框架但它的CustomPainter暴露了底层Canvas能力做轻量级游戏画面渲染完全能胜任尤其适合2D打飞机、贪吃蛇、跳一跳这类节奏型小游戏。这篇文章就把我在OpenHarmony上结合Flutter与CustomPainter做游戏渲染的完整思路、代码拆解和踩坑记录分享出来给正在纠结怎么在OpenHarmony上做游戏画面的朋友一个可落地的参考。1. 需求拆解与整体方案设计1.1 在OpenHarmony上选择Flutter的核心理由OpenHarmony系统的原生应用开发通常使用ArkTS语言和声明式UI这套体系写复杂业务还行但到了游戏场景由于游戏需要高频刷新、直接控制像素绘制原生框架反而有点放不开手脚。对比下来Flutter在跨平台一致性、动画流畅度、Canvas绘制能力上都有天然优势而且OpenHarmony已经有社区适配的Flutter引擎主流的Flutter API在OpenHarmony上大多能直接跑。选择Flutter还有一个实际原因团队里已经有Flutter技术积累不需要额外学一套ArkTS渲染体系。特别是在画布级渲染这件事上Flutter的CustomPainter和OpenHarmony的Canvas API在概念上很接近但Flutter的声明式刷新机制和Dart语言的GC管理让开发轻量游戏的速度快得多。我做的这个模拟项目就是一套飞机射击游戏所有游戏元素都是用CustomPainter绘制的没有引入任何游戏引擎运行效果稳定在接近60fps。1.2 CustomPainter为什么适合游戏画面渲染CustomPainter本质上是Flutter提供的一个自定义绘制钩子只要我们实现paint方法就能拿到一个Canvas对象在上面画直线、矩形、圆形、图片、甚至是一整张Bitmap。游戏画面渲染的核心就是两个问题一是画面怎么画出来二是怎么让画面动起来。CustomPainter加一个Ticker就能解决这两件事。很多开发者一提到游戏渲染就想到游戏引擎其实对于小游戏来说引擎的物理引擎、粒子系统、场景图这些重量级功能根本用不上反而增加了包体和移植负担。CustomPainter的最大好处是“轻”它不依赖GPU直通而是走Flutter自身的渲染管线在OpenHarmony的Flutter引擎支持下渲染结果能高效地提交到屏幕。缺点是它适合中小规模的2D绘制如果几千个对象同时做复杂变换那还是得评估是否需要上引擎。做游戏画面渲染先把CustomPainter吃透是最实在的。2. Flutter for OpenHarmony环境准备与工程构建2.1 搭建跨端开发环境在开始写渲染代码之前首先得有一套能跑OpenHarmony的Flutter开发环境。我使用的是社区维护的Flutter for OpenHarmony分支安装流程大体分三步。下载适配OpenHarmony的Flutter SDK解压后配置到系统PATH环境变量并执行flutter doctor确认工具链就绪。安装OpenHarmony的开发工具链和SDK包包括编译工具和系统映像。这里要注意的是OpenHarmony的SDK版本一般要和开发板固件匹配版本不一致会出现运行时报错。创建新的Flutter工程执行flutter create命令指定输出目录。如果已有Flutter工程也可以直接转到OpenHarmony目标平台。环境准备好后需要在工程目录中检查devices的支持情况可以在终端运行flutter devices能看到OpenHarmony设备说明连接正常。我第一次搭建时卡在设备识别不到的问题上后来发现是USB调试模式没有开启OpenHarmony的开发者选项里需要手动打开“允许USB调试”跟其他系统一致。2.2 将Flutter模块接入OpenHarmony工程Flutter for OpenHarmony的集成方式跟标准Flutter有些差异。标准Flutter使用gradle构建Android包而OpenHarmony则用自家的构建体系管理原生工程。好在我们不需要修改太多地方重点是引入Flutter引擎产物和Dart打包结果。工程层面需要注意一个关键点Flutter引擎需要以预编译组件的形式依赖进来类似Android里的AAR机制OpenHarmony也有对应的模块化产物。配置的时候要在构建文件中添加Flutter引擎依赖同时把Flutter模块的路径注册到工程里。很多新手在集成时遇到编译失败通常是因为版本不匹配比如Flutter SDK版本和OpenHarmony SDK的API级别对不上。如果你只是做一个纯Flutter的OpenHarmony应用其实不需要手动写原生代码直接通过Flutter工程构建就能生成HAP包。我这边做了混合应用需要从原生页面跳转到Flutter游戏页面才在OpenHarmony工程里做了模块集成。建议大多数场景直接走纯Flutter模式省心。2.3 运行第一个CustomPainter页面工程跑通后我用最精简的方式验证了CustomPainter在OpenHarmony上的可用性在MaterialApp里放一个Scaffoldbody区域直接挂一个CustomPaint画笔画出蓝色矩形。整个过程没有任何原生代码直接flutter run就能把页面推到开发板。import package:flutter/material.dart; void main() { runApp(const GameApp()); } class GameApp extends StatelessWidget { const GameApp({super.key}); override Widget build(BuildContext context) { return MaterialApp( home: Scaffold( body: Center( child: CustomPaint( size: const Size(200, 200), painter: BoxPainter(), ), ), ), ); } } class BoxPainter extends CustomPainter { override void paint(Canvas canvas, Size size) { final paint Paint() ..color const Color(0xFF3088FF) ..style PaintingStyle.fill; canvas.drawRect(const Rect.fromLTWH(20, 20, 160, 160), paint); } override bool shouldRepaint(covariant CustomPainter oldDelegate) false; }运行结果和在模拟器上没区别说明CustomPainter的基本API在OpenHarmony的Flutter引擎里已经完成适配。这一步走通了后面的游戏画面渲染就有了基础。3. CustomPainter游戏渲染核心实现3.1 游戏画面渲染的基本模型游戏画面渲染可以用一句话概括每帧清除画面按新的游戏状态重新绘制所有对象。对于CustomPainter来说就是让paint方法在每一帧都被调用而游戏状态保存在内存中每帧根据时间或用户输入更新坐标、旋转角、碰撞结果等数据。所以我的实现模型是用一个自定义类GameState保存所有游戏对象包括玩家位置、子弹列表、敌机列表、分数。用一个Ticker来产生逐帧回调每次回调更新GameState然后通知CustomPainter重绘。CustomPainter的paint方法里只做一件事情读取当前GameState用Canvas API画出画面。这样做的优势在于代码职责清晰逻辑计算和绘制彻底分离。游戏逻辑无论多复杂只要把状态算好绘制函数就能高效地完成。我在这个项目里把游戏逻辑更新写在一个update(double dt)方法里每次Ticker回调传入经过时间然后调用setState更新页面CustomPaint依赖的painter对象收到通知后就会重新绘制。3.2 以飞机射击游戏为例的绘制过程我这个小游戏模拟了经典的飞机射击玩法玩家在屏幕底部移动左右按射击键发射子弹敌机从顶部掉落。所有元素都靠纯Canvas绘制没有用任何图片资源。首先定义游戏对象的数据结构比如玩家就是一个包含坐标、宽度、速度的类。子弹是列表每一帧向上移动。敌机可以用不同颜色代表不同类型。这些对象不关心怎么绘制只负责携带数据。class Player { Offset position; double width 40; double height 30; double speed 300; Player(this.position); } class Bullet { Offset position; double speed 600; Bullet(this.position); } class Enemy { Offset position; double speed 100; int hp 1; Enemy(this.position); }然后是真正的绘制类继承CustomPainter。在paint方法里先用背景色清空画面然后遍历所有游戏对象调用Canvas绘制方法。class GamePainter extends CustomPainter { final GameState state; GamePainter(this.state) : super(repaint: state); override void paint(Canvas canvas, Size size) { // 清空画面 canvas.drawRect(Offset.zero size, Paint()..color const Color(0xFF0B1026)); // 绘制玩家一个三角形小飞机 final player state.player; final path Path() ..moveTo(player.position.dx, player.position.dy - player.height / 2) ..lineTo(player.position.dx - player.width / 2, player.position.dy player.height / 2) ..lineTo(player.position.dx player.width / 2, player.position.dy player.height / 2) ..close(); canvas.drawPath(path, Paint()..color const Color(0xFF00FF88)); // 绘制子弹 final bulletPaint Paint()..color const Color(0xFFFFFF44); for (final bullet in state.bullets) { canvas.drawRect(Rect.fromCenter(center: bullet.position, width: 4, height: 12), bulletPaint); } // 绘制敌机红色方块 final enemyPaint Paint()..color const Color(0xFFE04444); for (final enemy in state.enemies) { canvas.drawRect(Rect.fromCenter(center: enemy.position, width: 28, height: 28), enemyPaint); } // 绘制分数 final textPainter TextPainter( text: TextSpan( text: 分数: ${state.score}, style: const TextStyle(color: Colors.white, fontSize: 18), ), textDirection: TextDirection.ltr, )..layout(); textPainter.paint(canvas, const Offset(12, 12)); } override bool shouldRepaint(covariant GamePainter oldDelegate) true; }这个GamePainter的构造方法里传入了GameState并且把GameState作为repaint的监听对象。这样GameState只要发出通知CustomPainter就会自动重绘不需要我们手动调用setState。这是CustomPainter在游戏渲染中非常关键的一招通常被忽略repaint参数直接绑定状态对象才能做到逐帧精准渲染。3.3 动画循环与帧率控制游戏画面必须动起来因此需要一个驱动循环。在Flutter里最简单的做法是使用Ticker它是引擎层面的帧回调机制每次刷新屏幕时会触发一次回调。也可以使用AnimationController但后者功能更重包含插值和动画方向控制纯游戏场景用Ticker更直接。class GamePage extends StatefulWidget { override StateGamePage createState() _GamePageState(); } class _GamePageState extends StateGamePage with SingleTickerProviderStateMixin { late Ticker _ticker; late GameState _state; late GamePainter _painter; override void initState() { super.initState(); _state GameState(); _painter GamePainter(_state); _ticker createTicker((elapsed) { final dt (elapsed - _lastElapsed).inSeconds; // 注意要计算两帧间隔 _state.update(dt); _state.notifyListeners(); _lastElapsed elapsed; }); _ticker.start(); } override Widget build(BuildContext context) { return CustomPaint( size: Size.infinite, painter: _painter, ); } }这里有一个非常容易踩的坑直接用elapsed.inSeconds当dt会因为四舍五入导致时间间隔不准。正确做法是保存上一帧时间用elapsed - lastElapsed算出差值再转换成秒。为了简化代码我在项目里直接使用了毫秒换算final dt (elapsed - _lastElapsed).inMicroseconds / 1000000.0; _lastElapsed elapsed;游戏里的移动距离等于速度乘以dt更新位置时需要利用这个时间增量否则在高刷新率设备和低刷新率设备上速度会不一样。我在模拟项目里定的是60Hzdt大约是0.016秒。帧率控制上Flutter引擎通常按设备的屏幕刷新率来驱动Ticker不需要自己锁帧。但如果游戏逻辑过于复杂可以主动减小更新频率比如只让游戏状态每两帧更新一次绘制仍然全量。实操中用个计数器每帧加一然后判断是否需要更新代码很简单。3.4 触摸交互与游戏控制用手指控制玩家移动是刚需。我在CustomPaint外层包了一个GestureDetector监听拖拽和点击事件。拖拽时直接把玩家坐标移动到触摸位置附近点击时产生一颗子弹。GestureDetector( onTapDown: (details) { setState(() { _state.fire(); }); }, onPanUpdate: (details) { setState(() { _state.updatePlayerByDelta(details.delta); }); }, child: CustomPaint( size: Size.infinite, painter: _painter, ), )这里有个坐标映射问题需要处理GestureDetector的details.localPosition是相对于当前组件左上角的坐标但CustomPainter的Canvas坐标原点是画布左上角两者在大部分情况下是一致的。不过在OpenHarmony设备上如果Home指示条或者系统栏占用了部分屏幕实际的窗口尺寸和设备逻辑尺寸可能不一样建议在构建游戏页面时获取MediaQuery.sizeOf(context)然后动态转换为游戏逻辑坐标。对于玩家控制的平滑度我更喜欢用onPanUpdate加上速度积分而不是直接设置绝对位置。后者会让飞机完全跟着手指走缺少游戏手感。但做一个Demo级别的小游戏直接用delta累加到玩家位置就已经够用。实际开发时如果是需要手柄或键盘输入逻辑也是同一套把输入事件转换成玩家控制指令再交给游戏状态更新函数。3.5 游戏逻辑更新与碰撞检测碰撞检测是游戏功能的核心虽然跟渲染没直接关系但影响画面表现。我采用最简单的是“画了个什么就检测什么”的思路把所有对象的位置都当成矩形然后做矩形相交检测。比如子弹和敌机都用各自的中心点和宽高构建Rect再用Rect.overlaps判断是否重叠。bool checkCollision(Rect a, Rect b) a.overlaps(b);实战中要特别注意碰撞检测的频率别拿所有子弹和所有敌机做暴力的双重循环那个复杂度O(n*m)在对象多的时候会卡顿。我这边数量不大还好如果敌人和子弹超过50个建议用网格分块或者简单四叉树。另外每次更新状态时要删除已经出界的子弹和碰撞后死亡的敌人这个列表操作我习惯在update里做避免处理绘制期间修改集合的并发问题。4. 性能优化与避坑指南4.1 正确实现shouldRepaint避免无意义的全量重绘游戏状态放到GameState里以后很多人直接让shouldRepaint返回true理由是“每帧都在变肯定要重绘”。从结果看没问题但会造成不必要的性能浪费。CustomPainter.shouldRepaint的语义是当传入新的painter实例时引擎判断是否需要重新调用paint。如果始终返回true那么任何CustomPaint的setState都会触发全区域重绘。在游戏场景里如果背景是静态的可以考虑把背景和动态游戏对象分成两个CustomPainter层级背景层布置在底层shouldRepaint返回false游戏对象层放在上层擦除后重绘。OpenHarmony的Flutter引擎同样支持这种分层优化。我试过把背景中的星空和云层拆成单独的静态Painter整体帧率提升了大约12%尤其在低端设备上很明显。另一个技巧是使用RepaintBoundary包裹动态区域。把CustomPaint用RepaintBoundary包一层这样引擎会尽量把这块绘制结果缓存成图层避免与上层UI混合时发生额外的合成开销。对于全屏游戏来说RepaintBoundary不一定能提升性能但对局部透明弹窗、小地图、血条这类需要独立刷新的UI效果立竿见影。4.2 减少Canvas开销预创建对象避免频繁分配CustomPainter的paint方法会在每一帧被调用因此在paint内部创建Paint、Path、TextPainter这些对象是非常不划算的。例如在paint里直接写Paint()每帧都会产生新对象Dart的GC压力加大游戏会随机出现掉帧。正确的做法是把不变化的画笔、路径、文本样式提到painter的成员变量里在构造函数中初始化。对于每帧会变化的对象比如敌机的Path也可以复用同一个Path实例用path.reset()和path.moveTo重新填充数据。再有一点是Canvas的绘制方法本身。绘制大量相同的几何图形时比如很多子弹不要一个一个调用drawRect应该合并成drawRawPoints或用drawVertices批量绘制。我这里子弹数量在50发以下所以仍然用循环但如果你做弹幕游戏一定要用批量绘制API。4.3 与OpenHarmony的适配细节屏幕、内存和纹理在OpenHarmony设备上跑Flutter游戏最明显的差异是屏幕尺寸和设备性能。开发板的物理分辨率与手机不同有些是横屏有些是竖屏需要在游戏启动时统一处理方向。我在项目里用SystemChrome.setPreferredOrientations锁定横屏并且在页面布局中使用SafeArea避免系统导航条遮挡绘制区域。内存也要重点监控。OpenHarmony上很多设备内存只有2GB到4GB游戏内的图片纹理和列表数据都要小心控制。如果直接加载高分辨率图片当背景建议先压缩到屏幕分辨率也可以用canvas.drawImageRect把大图按目标区域绘制减少upload到GPU的内存占用。此外Flutter引擎在OpenHarmony上的Debug模式性能比Release模式低很多千万别在Debug模式下做性能结论必须打Release包测试。这里有一个真实的教训我一开始在Debug模式下测量游戏帧率发现只有30fps当时以为是CustomPainter的问题还准备改方案。后来切换到Release模式跑同样场景轻松稳定60fps。原因是Debug模式开启了Dart虚拟机的大量检查性能损耗很大这是Flutter跨平台的老问题OpenHarmony也不例外。4.4 常见问题与排查技巧实录现象可能原因处理方法画面白屏无任何绘制CustomPaint的size为无穷大父组件未限制尺寸给CustomPaint显式指定size或放在SizedBox/Expanded内游戏帧率低只有30fpsDebug模式运行改打Release包重新测量触摸位置不准确点偏未考虑设备安全区或DPR转换获取MediaQuery数据使用localPosition并做逻辑坐标换算游戏闪退提示OOM内存分配过多图片太大压缩图片及时清理已出界对象用setState时不要让列表无限增长运行后一直黑屏但日志无报错Flutter引擎初始化和页面路由问题检查OpenHarmony侧是否加载了Flutter引擎产物必要时在原生工程清理并重新构建除了表格里的问题我还想专门提醒两个隐藏很深的坑。第一个是GameState使用了ChangeNotifier但如果没有调用notifyListeners画面不会刷新。很多人误以为修改了状态就会自动重绘结果卡在那里。第二个是在Ticker回调里直接调用setState如果回调频率很高可能会让widget频繁重建建议使用ValueNotifier或自定义状态通知让painter自身监听变化而不是通过setState层层传递。5. 后续扩展与个人体会这个游戏Demo做到后面我发现CustomPainter还可以扩展出很多玩法。比如用drawImage播放逐帧动画用Path绘制复杂的地图和轨迹叠加多个CustomPainter做视差滚动。对于粒子特效可以用一个粒子数组加透明度渐变用Paint的maskFilter做模糊效果完全不输引擎。如果需求量很大超过几千个对象推荐配合Canvas.saveLayer做局部模板或者把静态图形缓存成Picture这样可以避免每帧重复绘制复杂路径。Flutter的PictureRecorder可以直接记录一段绘制指令然后在paint方法里用canvas.drawPicture播放性能比重新执行绘制命令快很多这一点在OpenHarmony适配的Flutter引擎里也得到了验证。最后说点个人的实际感受OpenHarmony上跑FlutterCustomPainter这条路线完全可行尤其适合中小型游戏和丰富UI特效。Flutter的渲染引擎本身做了很好的跨平台封装在OpenHarmony上从运行到绘制都没有遇到不可逾越的障碍。如果你正准备做一款OpenHarmony上的小游戏或只是想用Flutter实现复杂的自定义动效可以考虑抛弃重引擎直接上手CustomPainter。先把源码跑起来再一点点加对象、加交互你会发现游戏渲染这件事真的没那么神秘。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进