在轻规划(AeroPlan)的打卡场景中,用户完成习惯后需要即时获得情绪反馈,烟花粒子特效承担了这部分交互。原文的实现没有套用WebView去跑Lottie或HTML5 Canvas,而是基于鸿蒙ArkUI原生Canvas组件自研粒子渲染系统。原因很直接:Web容器冷启动慢、内存占用大,声明式组件与Web容器之间还存在数据交换时延,跨语言边界通信也可能带来稳定性和响应迟滞问题。对于追求高帧率的打卡动画,这些代价都不合适。
一、渲染管线:绕开高频状态变更
粒子特效的本质是在高频心跳时钟下,对数百个粒子做物理位移重算、擦除和重绘。在ArkUI声明式UI中,频繁变更状态会触发UI树重排与重绘,成本较高。原文方案通过直接持有CanvasRenderingContext2D句柄,在requestAnimationFrame回调中直接操作Canvas,把物理更新和绘制放在原生绘制层级,避免频繁走声明式状态机制。
组件侧使用RenderingContextSettings(true)开启反锯齿,并绑定CanvasRenderingContext2D。Canvas的onReady回调只做初始化确认。真正的帧循环由requestAnimationFrame驱动,对应系统VSync时钟,原文目标是120Hz刷新率下的流畅渲染。每一帧先clearRect清除上一帧残留,再倒序遍历粒子数组:存活粒子执行update和draw,死亡粒子从数组移除并回收到对象池。粒子全部消亡后注销动画循环,避免空转占用CPU。
- private loop = () => {
- this.ctx.clearRect(0, 0, this.ctx.width, this.ctx.height);
- for (let i = this.particles.length - 1; i >= 0; i--) {
- const p = this.particles[i];
- const isAlive = p.update();
- if (isAlive) {
- p.draw(this.ctx);
- } else {
- this.particles.splice(i, 1);
- ParticlePool.recycle(p);
- }
- }
- if (this.particles.length > 0) {
- this.animationId = requestAnimationFrame(this.loop);
- } else {
- this.animationId = -1;
- console.info('HapticCanvas', 'All particles died. Animation loop paused.');
- }
- }
复制代码
这里倒序遍历是为了在移除死亡粒子时避免数组索引偏移。循环是否继续由粒子队列是否为空决定,这也是控制功耗的一个关键检查点。
二、粒子物理模型与绘制参数
每个烟花碎片都是一个FireworkParticle。原文采用半隐式欧拉积分近似模拟运动:先按摩擦系数衰减速度,再叠加垂直重力,最后更新位置并衰减透明度。核心参数包括重力加速度0.18、空气阻力0.96,以及每帧透明度衰减值decay。粒子reset时随机生成爆炸角度0到2π、初速度4到12像素/帧,并把Y轴初速度减去2,形成向上喷射后自然炸裂的弧度。粒子半径随机在2到5像素之间,decay随机在0.012到0.027左右,以增强碎片层次感。
绘制时先save保存Canvas状态栈,设置globalAlpha和fillStyle,再beginPath、arc、fill绘制圆形粒子,最后restore恢复状态。触发一次烟花时,原文从暖红、橙色、明黄、嫩绿、蔚蓝、魅紫等颜色中随机取色,单次引爆120个粒子,在视觉饱满度和CPU负载之间取平衡。
从原文实现可以推导出,性能关键不只是Canvas绘图API本身,而是把高频更新从ArkUI状态机制中剥离,并控制对象分配与Native绘制指令数量。
三、防丢帧重点:对象池复用粒子
打卡烟花引爆瞬间,如果直接创建120个FireworkParticle实例,短时间内大量new小对象会迅速填满ArkTS虚拟机的新生代内存空间,触发Minor GC。原文指出,Minor GC虽然耗时短,但在120Hz高刷屏下,任何超过帧预算的主线程停顿都可能破坏VSync对齐,造成画面撕裂或顿挫,并影响互动响应度INP。
解决方案是粒子对象池。池容量上限设为300,obtain时优先从池尾pop并调用reset复用,池为空时才降级new;recycle时只在池未满时push,避免闲置对象常驻导致内存膨胀。原文给出的结果是,引入对象池后内存抖动波动幅度减小了90%,连续引爆多场烟花时丢帧率为0%。
- export class ParticlePool {
- private static pool: FireworkParticle[] = [];
- private static MAX_POOL_SIZE = 300;
- public static obtain(x: number, y: number, color: string): FireworkParticle {
- if (this.pool.length > 0) {
- const p = this.pool.pop()!;
- p.reset(x, y, color);
- return p;
- }
- return new FireworkParticle(x, y, color);
- }
- public static recycle(p: FireworkParticle) {
- if (this.pool.length < this.MAX_POOL_SIZE) {
- this.pool.push(p);
- }
- }
- }
复制代码
四、进阶优化:批量绘制与离屏缓冲
对象池解决的是内存碎片和GC抖动问题。当同屏粒子达到500以上时,逐个粒子调用ctx.arc、ctx.fill等绘图指令会带来较高的Native桥接调用开销。原文给出的进一步手段是按相同颜色分组做批量绘制:同一颜色组的粒子合并到同一个Path中,一次性fill,减少Canvas状态切换和Native渲染指令投递频次。
另一个方向是OffscreenCanvas离屏双缓冲。把物理坐标计算和图形绘制放到后台或内存中预处理,再通过drawImage把离屏图像一次性合成到主屏Canvas,减少在主屏画布上做大量零散像素修改带来的渲染压力。原文将这类做法视为游戏开发中常用的优化法则,适合粒子数量更多的极端场景。
整体来看,这套方案的核心取舍是:用原生Canvas和VSync帧循环换掉WebView容器,用直接绘制换掉高频声明式状态更新,用对象池控制GC抖动,再用批量绘制和离屏缓冲压低Native绘制开销。对于HarmonyOS/ArkUI上的高频粒子动效,这些调优点比单纯堆视觉参数更值得优先验证。 |