资讯动态

Flutter 与 OpenHarmony 下用 CustomPaint 实现轻量粒子系统实战

发布时间:2026/10/18 5:06:02 来源:尧图企业网站定制
先交代一下背景。最近在调一个运行于 OpenHarmony 设备上的休闲小游戏玩法本身不复杂但总是被反馈“打击感不够”“反馈太干”。想想也是消除、弹射、连击这类玩法如果没有即时的视觉反馈爽快感基本为零。于是决定在这个 Flutter 渲染层里加一套粒子系统先做两件事命中时的简易爆炸以及得分飘字。做之前以为要引入重量级游戏引擎或复杂插件做完才发现在 Flutter 与 OpenHarmony 的组合下用 CustomPaint 手写一套轻量粒子引擎反而是最稳、性价比最高的路线。这篇就把整个过程拆开讲包括原理、取舍、代码结构、真机上的坑以及最后的效果参数给需要在 OpenHarmony 上做游戏或动态效果的朋友一些参考。1. 为什么 Flutter OpenHarmony 需要自己动手写粒子1.1 从选型说起现成方案都不省心最早接到需求第一反应是找现成的粒子库。Flutter 生态里确实有几套不错的方案比如基于 Flame 引擎的粒子模块或者一些独立的粒子特效插件。但放在 OpenHarmony 环境下问题就来了这些插件大多依赖完整的 Flutter 插件生态而 OpenHarmony 对 Flutter 的适配目前主要集中在框架本身的运行层面第三方原生插件的兼容性参差不齐。再加上我们只是想要“爆炸”和“得分飞字”这两种有限效果引入一个完整引擎属实有点小题大做。我把几种路线在表格里做了个对比结论其实很明显方案接入成本可定制性OpenHarmony 兼容风险包体积影响Flame 引擎粒子模块中高需引入引擎强但被引擎框架约束中引擎层依赖较多较大独立粒子插件低但 API 固定弱效果类型受限高往往依赖原生代码不确定自绘 CustomPaint高一切自己写完全可控低纯 Flutter 绘制层几乎无说到底我们的需求并不是做一个“通用粒子系统”而是做两个具体效果。自研只用实现需要的功能代码精简稳定性也可控。于是定了方向用 Flutter 自带的CustomPaintCanvas完成全部渲染不依赖任何三方库。1.2 理解 Flutter 游戏渲染的最小闭环很多做应用开发的朋友对 CustomPaint 不陌生但到了游戏/动态场景就发怵核心是没理解“渲染循环”这件事。平时写界面数据变了用setState刷新但游戏里每一帧都在变不可能靠 setState 再去重建 Widget 树。正确思路是用Ticker或用AnimationController驱动每帧回调在回调里更新粒子数据然后用一个持续的重绘请求让CustomPainter重新执行paint方法。画布上的内容完全由数据决定数据每一帧都变画面自然就动起来。这样设计的好处是Widget 树几乎不需要重建帧率开销极低。粒子的状态管理跟 UI 逻辑彻底解耦。渲染层只关心“这一帧我要画什么”逻辑非常清晰。这套模式就是 Flutter 里做游戏/动态效果的“最小闭环”即便以后要接游戏循环也完全够用。2. 粒子系统内核数据模型、生命周期与对象池2.1 Particle 类与状态的三个阶段粒子系统的本质很简单一堆粒子对象每个对象有位置、速度、大小、颜色、存活时间这几个属性。update阶段根据时间增量推进这些属性render阶段把它们画出来。我定义的Particle类长这样class Particle { // 当前活跃状态 Offset position; Offset velocity; double size; Color color; // 生命周期状态 double life; // 剩余存活时间 double maxLife; // 初始生命周期用于计算归一化进度 t // 额外修饰 double drag; // 空气阻力系数让移动更自然 double gravity; // 重力加速度仅对部分粒子开启 Particle({ required this.position, required this.velocity, required this.size, required this.color, required this.life, this.drag 0, this.gravity 0, }) : maxLife life; /// 更新粒子状态dt 为距上一帧的时间单位秒 void update(double dt) { life - dt; if (life 0) return; // 施加重力 velocity velocity Offset(0, gravity * dt); // 移动 position position velocity * dt; // 空气阻力指数衰减简单用线性近似 if (drag 0) { final factor 1 - (drag * dt).clamp(0.0, 1.0); velocity velocity * factor; } } /// 归一化生命周期进度0 表示刚出生1 表示即将消亡 double get progress (maxLife - life) / maxLife; }这里有一个很关键的细节生命周期是用倒计时而不是累计计时。我偏好倒计时是因为它天然支持“在不同时间点为粒子设置不同剩余寿命”的场景对于爆炸这种一次性批量发射的粒子使用起来很顺手。maxLife这个字段值得一提。它记录粒子出生时的总生命时长这样在渲染时我们可以用progress算出这一帧应该用什么颜色、什么大小、什么透明度。后面爆炸效果要变色的核心就是它。2.2 对象池这是减少 GC 卡顿的关键玩过游戏开发的朋友对“对象池”一定不陌生但在 Flutter 里有些朋友会忽略它的重要性。游戏运行中每一帧都可能产生新粒子比如爆炸一炸就是 20 多个。如果每次都用new创建对象用完就丢弃连续几次爆炸之后内存新手区会被短命对象塞满GC 一触发就会造成肉眼可见的掉帧。我的做法是维护一个空闲对象列表class ParticleSystem { final ListParticle _activeParticles []; final ListParticle _pool []; Particle _acquire() { if (_pool.isNotEmpty) { return _pool.removeLast(); } return Particle(position: Offset.zero, velocity: Offset.zero, size: 0, color: Colors.black, life: 0); } void _release(Particle p) { _pool.add(p); } }每次需要新粒子时先尝试从_pool获取没有才新建。粒子消亡后把它放回池里而不是直接丢弃。实测下来对于一个粒子存活时间在 0.8~1.2 秒的爆炸场景对象池大小稳定维持在 30 个左右就够用内存几乎零增长。2.3 发射器把爆炸参数集中管理为了不让粒子系统的 API 越来越复杂我把“发射”这个动作集中封装成“发射器”Emitter的概念。一次爆炸、一次得分飞字本质都是不同配置的发射行为。class EmitterConfig { final int count; final double lifeMin, lifeMax; final double speedMin, speedMax; final double sizeMin, sizeMax; final Color colorStart, colorEnd; final double gravity; final double drag; const EmitterConfig({ required this.count, required this.lifeMin, required this.lifeMax, required this.speedMin, required this.speedMax, required this.sizeMin, required this.sizeMax, required this.colorStart, required this.colorEnd, this.gravity 0, this.drag 0, }); }这样设计的好处是爆炸和飞字虽然是两套完全不同的效果但它们复用的是同一套粒子系统内核只是发射参数不同。以后想加“连击碎屏”“冰冻裂纹”之类的效果只需要新增一套 EmitterConfig不用再动底层。3. 简易爆炸效果让碎片“炸开”的核心细节3.1 速度分布从中心向外散射的数学爆炸效果要像首先要解决“碎片往哪飞”的问题。最简单粗暴的做法是给每个粒子一个随机方向、随机速度。但如果你真这么做了会发现爆炸效果很“糊”——粒子飞得没有章法感觉像一堆杂乱的墨点。我调出来的经验是速度方向应该围绕一个中心均匀散射而不是纯随机。具体做法是把 360 度分成 N 等份为每个粒子分配一个基础角度再叠加一个很小的随机扰动// 角度均匀分布 小范围随机抖动 final baseAngle (i * 2 * pi / count) (rand.nextDouble() - 0.5) * 0.8; // 速度大小在一个区间里随机 final speed speedMin (speedMax - speedMin) * rand.nextDouble(); final velocity Offset(cos(baseAngle), sin(baseAngle)) * speed;这样粒子会从爆炸中心向四周均匀扩散视觉上就是完整、对称的爆破裂痕。而不是几个方向特别密、几个方向特别空。广角随机抖动的幅度是个调参点。抖动太小粒子分布太规则像齿轮抖动太大又失去“圆形爆炸”的形态。我实测 0.6~0.8 弧度左右的抖动范围效果比较自然。3.2 颜色渐变与大小曲线真实感的胜负手很多初学粒子系统的朋友会忽略一件事粒子颜色不是恒定不变的而是在生命周期里动态渐变。真实爆炸的视觉特征是中心温度极高呈亮黄白色向外扩散后温度下降颜色变橙、变红最后变暗消失。我们不需要做真的物理模拟只需要在渲染时根据progress把颜色从“亮黄”渐变到“暗红”即可Color _lerpColor(double t, Color a, Color b) { return Color.lerp(a, b, t)!; }颜色渐变做好了爆炸的“温度感”就出来了。具体参数上我采用的是三段渐变而不是两段。两段渐变在视觉上会有一个明显的变色调突变三段黄→橙→红过渡起来更细腻Color _bandColor(double t) { if (t 0.5) { return Color.lerp( const Color(0xFFFFF59D), // 亮黄 const Color(0xFFFFB74D), // 橙 t * 2, )!; } else { return Color.lerp( const Color(0xFFFFB74D), const Color(0xFFD84315), // 深红 (t - 0.5) * 2, )!; } }粒子大小也完全不是恒定的。爆炸刚发生时碎片应该呈现微小的亮斑然后随着膨胀逐渐变大最后在消亡阶段略微收小。用这种三段式曲线比线性变化更生动但为了代码简洁我直接用了带峰值的线性近似实践效果完全够。3.3 完整的爆炸发射实现把上面这些串起来发射一次爆炸就是void emitBurst({required Offset origin, required EmitterConfig config}) { for (int i 0; i config.count; i) { final p _acquire(); // 角度均匀分布 随机抖动 final baseAngle (i * 2 * pi / config.count) (_rand.nextDouble() - 0.5) * 0.8; final speed config.speedMin (config.speedMax - config.speedMin) * _rand.nextDouble(); final life config.lifeMin (config.lifeMax - config.lifeMin) * _rand.nextDouble(); final size config.sizeMin (config.sizeMax - config.sizeMin) * _rand.nextDouble(); p ..position origin ..velocity Offset(cos(baseAngle), sin(baseAngle)) * speed ..size size ..color config.colorStart ..life life ..maxLife life ..drag config.drag ..gravity config.gravity; _activeParticles.add(p); } }emitBurst内部的粒子数据完全是随机产生的但整体的分布遵循物理直觉方向均匀、速度有范围、寿命有长短。这样每次爆炸的瞬间形态都不同但整体观感始终稳定。渲染阶段也很直接。因为我们要画的是带渐变的圆形小碎片Canvas提供了drawCircle配一个Paint对象override void paint(Canvas canvas, Size size) { for (final p in particles) { final t p.progress; // 计算透明度最后 30% 生命快速淡出 final alpha (1 - (t - 0.7).clamp(0.0, 0.3) / 0.3); final paint Paint() ..color _bandColor(t).withOpacity(alpha) ..maskFilter MaskFilter.blur(BlurStyle.normal, 2); // 小模糊模拟光晕 canvas.drawCircle(p.position, p.size, paint); } }MaskFilter 是一个性价比很高的设计。给粒子画一层小小的模糊会让亮斑看起来像发光体而不是硬邦邦的实心圆。但注意模糊不能给所有粒子都用同一个大的 blur 值不然整个画面都是糊的。我实测blur 1 ~ 2已经是上限了。4. 得分飞字用粒子思想做游戏 UI4.1 为什么不用 Text 控件得分飞字看起来就是一个从底部弹起、渐渐淡出的文字。很多人的第一反应是用Text控件 AnimationController来做位移和透明度。这确实可行但有个问题得分飞字在游戏里往往是高频事件连击时可能同一帧触发好几个。如果用 Widget 实现每个飞字都是一个独立的 WidgetWidget 树的频繁挂载/卸载会带来额外的 build 和 layout 开销在低端设备上容易卡顿。换成粒子方案就清爽了。飞字的本质也是“有生命周期的、动态移动的元素”把它当成一个特殊的粒子由同一个渲染层绘制。这样得分事件只是向粒子系统添加几个对象不涉及 Widget 树的改变。4.2 用 TextPainter 直接画到画布上Flutter 里想在 Canvas 上直接画文字靠的是TextPainter。这个类负责文字排版、度量然后通过paint方法把内容绘制到画布。因为不是控件它不参与 Widget 树性能自然好很多。飞字粒子的实现核心就是update阶段做文本粒子的状态推进render阶段用TextPainter绘制class FloatingTextParticle { Offset position; String text; double fontSize; Color color; double life; double maxLife; void update(double dt) { life - dt; // 向上飘 轻微加速 position position.translate(0, -60 * dt); } void render(Canvas canvas) { final t 1 - (maxLife - life) / maxLife; // 渐入渐出前 15% 快速显现最后 40% 淡出 final opacity t 0.15 ? t / 0.15 : t 0.6 ? (1 - t) / 0.4 : 1.0; final painter TextPainter( text: TextSpan( text: text, style: TextStyle( color: color.withOpacity(opacity), fontSize: fontSize, fontWeight: FontWeight.bold, ), ), textDirection: TextDirection.ltr, )..layout(); painter.paint(canvas, position - Offset(painter.width / 2, painter.height / 2)); } }这里重点是painter.paint(canvas, position - Offset(...))中的偏移修正。TextPainter.layout()之后要通过painter.width和painter.height计算中心点位置让文本严格围绕我们给定的坐标居中显示。如果不做这个修正所有飞字都会从左上角错位飘出观感非常突兀。飞字的动效我也特意加了一个小细节不是匀速上升而是在上升过程中有一个轻微减速。这个“先快后慢”的缓动符合真实物体抛出后的视觉经验比匀速自然得多。4.3 文本复用降低 layout 开销TextPainter 的layout其实是有计算成本的。如果一个界面同时飘 5 个飞字每个都要做一次 layout性能就会开始衰减。我的优化方法是把同字号、同颜色的飞字放到一个 map 里做缓存TextPainter 尽可能复用final MapString, TextPainter _textPainterCache {}; TextPainter _getTextPainter(String text, double fontSize, Color color) { final key $text-$fontSize-${color.value}; return _textPainterCache.putIfAbsent(key, () { return TextPainter( text: TextSpan( text: text, style: TextStyle(color: color, fontSize: fontSize, fontWeight: FontWeight.bold), ), textDirection: TextDirection.ltr, )..layout(); }); }因为飞字内容通常只有“100”“200”这种少量组合缓存命中率极高。这个优化在处理大量文本粒子时能省掉一大笔 layout 开销。5. OpenHarmony 真机适配与性能优化实录5.1 生命周期处理后台挂起与恢复OpenHarmony 设备上踩的第一个坑是生命周期管理。游戏切到后台时Flutter 进程并没有立即销毁但如果 Ticker 还在跑粒子系统会继续更新一旦切回前台就会发现粒子已经跑到屏幕外了。解决思路很直接在路由级或组件级监听 App 生命周期在paused状态时暂停所有粒子更新在resumed时恢复。Flutter 的WidgetsBindingObserver在这里很好用class GamePageState extends StateGamePage with SingleTickerProviderStateMixin, WidgetsBindingObserver { override void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); } override void didChangeAppLifecycleState(AppLifecycleState state) { if (state AppLifecycleState.paused) { _ticker.stop(); } else if (state AppLifecycleState.resumed) { _ticker.start(); } } override void dispose() { WidgetsBinding.instance.removeObserver(this); super.dispose(); } }这个小改动很容易被忽略但一旦忽略游戏从后台切回时画面状态就会乱用户观感极差。5.2 帧率与粒子数量的实测数据在 OpenHarmony 真机上做了几轮粒子数量与帧率的压测。测试环境是某款搭载 OpenHarmony 的平板设备屏幕刷新率 60Hz。粒子渲染采用 CustomPaint 简单圆形绘制无多余特效。结果如下同时活跃粒子数平均帧率fps掉帧情况5060无10060无20058~60偶发轻微波动50052~55明显可见掉帧100040 以下卡顿明显结论是对这种 2D 休闲游戏同时活跃粒子控制在 150 以内是绝对安全线。我们的爆炸一次 20~30 个粒子哪怕连续触发三连爆也就 90 个左右完全不会成为性能瓶颈。如果后续要做更大规模的粒子特效优化方向有两个一是给不同粒子分配不同的绘制批次减少 Paint 对象切换二是通过Canvas.saveLayerBlendMode把多个粒子合并到一张离屏画布再一次性叠加到主画布上。不过现阶段没这个必要。5.3 RepaintBoundary把刷新范围隔离CustomPaint 粒子系统一旦开始重绘它会触发所在区域的刷新。如果你的游戏界面里还有其他复杂的背景、控件整个区域每帧都会重绘CPU/GPU 开销就会翻倍。在 Flutter 里解决这个问题的标准手法是RepaintBoundary。用一个 RepaintBoundary 把粒子层包起来就断开了它向上传播的重绘范围保证只有粒子层自己每帧刷新其他区域保持不变RepaintBoundary( child: CustomPaint( painter: ParticlePainter(particleSystem), size: Size.infinite, ), )这是 Flutter 自带的最容易被忽视的性能优化之一成本极低效果显著。做任何持续动画效果时建议都加上。5.4 字体回退与文本绘制兼容OpenHarmony 系统字体与 Android/iOS 有一定差异。不同设备预装字体集不同如果飞字里用了特殊字体族比如自定义美术字体在真机上可能出现“字体缺失回退到默认字体”的情况导致文字宽度和预期的 TextPainter 缓存 key 不匹配。解决的思路是对于游戏内使用的中文字符串明确指定平台通用字体族fontFamilyFallback服务端或配置下发字体时的命名统一。我这边为了稳妥飞字统一用系统默认字体不特意指定 fontFamily兼容性最广。字体加载这块如果涉及到自定义 TTF需要在资源加载完成后先做一次FontLoader的加载注册再初始化 TextPainter避免首帧出现空字。6. 完整接入把粒子系统挂到真实游戏循环里6.1 模块拆分与对外接口粒子系统不能和具体游戏逻辑纠缠在一起。我把它拆成了三个独立文件particle.dart粒子数据模型。particle_system.dart粒子系统的核心管理类负责对象池、发射、更新、渲染。game_board_view.dart游戏主界面负责把粒子系统挂到 Ticker 和 CustomPaint 上。对外暴露的接口非常简单class ParticleSystem { /// 触发一次爆炸效果 void burst({required Offset origin, EmitterConfig? config}); /// 触发一次得分飞字 void addFloatingText({required Offset origin, required String text, Color? color}); /// 每帧调用更新所有粒子 void update(double dt); /// 交给 CustomPainter 的渲染接口 void render(Canvas canvas); }这样游戏逻辑层只需要调用burst和addFloatingText完全不关心粒子内部怎么算。以后要增加新特效扩展 EmitterConfig 即可游戏层完全无感知。6.2 游戏循环里的协同方式在游戏主界面的 Ticker 回调中先更新游戏逻辑再更新粒子系统。两套更新互不干扰但共享同一个时间基void _tick(Duration elapsed) { final dt (elapsed - _lastElapsed).inMicroseconds / Duration.microsecondsPerSecond; _lastElapsed elapsed; // 更新游戏逻辑这里只做示意 _gameLogic.update(dt); // 更新粒子系统 _particleSystem.update(dt); // 触发重绘 setState(() {}); }注意这里的setState不是每帧必然执行的。如果粒子系统没有活跃粒子可以跳过重绘请求减少无用功耗。判断依据是粒子系统的活跃列表是否为空if (_particleSystem.isActive) { setState(() {}); }这个优化在长时间没有爆炸/飘字的静止游戏画面里尤其有用能省下不少电量。实测静止画面上加入这个判断后 GPU 占用率下降了约 60%帧率保持稳定不变。6.3 实测表现与两个可复用的调参建议整套系统在真机上的效果已经很让我满意了爆炸粒子 30 个飞字 2~3 个同时飘帧率稳定 60完全感觉不到性能负担。代码量只增加了 300 多行还不依赖任何插件。最后分享两个调参经验直接套就行。第一个是关于粒子数量。追求“华丽”的新手往往会加粒子数量这是个误区。20 个粒子加上正确的颜色渐变和模糊比 100 个纯色粒子好看得多。数量不是关键关键是视觉层次。爆炸的层次感来自“速度差”一部分粒子飞得快、寿命短看起来像火花一部分速度慢、寿命长像碎屑。用两套不同速度的粒子叠加组合效果比单纯加数量提升明显。第二个是关于飞字的停留时间。太短看不清楚太长拖沓。我实测在 1.0~1.3 秒之间是最舒服的区间。太短的飞字看起来像闪现太长的飞字又会让玩家觉得“飘字挡住了我的视线”。尾声从需求提出到整套粒子系统跑通大概花了三个晚上的时间。框架部分不算复杂真正花时间的是调参数和踩真机适配的坑。回过头看最值得说的不是代码本身而是“别急着上重型方案”这个思路。在做有限效果爆炸、飞字时用 CustomPaint 手写一套轻量粒子系统代码清晰、性能可控、维护舒心。以后如果要做全屏大招级别的粒子特效这套内核也完全撑得住——架构上只需要扩展发射器配置和粒子数据类型不需要推翻重来。要是你对某个参数的具体调法有兴趣或者也遇到 OpenHarmony 上的渲染适配问题可以顺着这篇的思路自己跑一遍试试。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑