【共创稿事节】HarmonyOS ArkGraphics 2D酷炫效果展厅实操——用四种动效把画面点亮

先看成品:同一块画布,四种完全不同的画面
这次不从接口目录开始。打开实验 21,点击四个效果按钮,画布会在下面四种画面之间实时切换:

- 霓虹光轨:三条贝塞尔曲线带着发光粒子向前跑;
- 全息雷达:扫描扇区、同心圆和目标点同时脉冲;
- 音浪矩阵:32 根柱形条和一条波形线按相位起伏;
- 粒子星环:三层椭圆轨道、前后景粒子和中心能量核一起转动。
这四个效果都在真机上由 ArkGraphics 2D 直接绘制,顶部的 GIF 是本次录制的连续画面,不是静态图拼接。
这次要做什么
目标是做一个可以反复演示的“动效展厅”,让读者打开应用就能看见 ArkGraphics 2D 能做什么,并且能现场验证暂停、恢复、倍速和复位。
| 画面 | 适合的实际场景 | 绘制重点 |
|---|---|---|
| 霓虹光轨 | 地图路线、游戏技能轨迹、数据流 | Path + 多层 Pen 描边 + 粒子尾迹 |
| 全息雷达 | 扫描页、设备监控、目标探测 | 圆环、扫描扇区、脉冲目标点 |
| 音浪矩阵 | 音乐可视化、直播状态、节奏提示 | 圆角柱、反射、波形路径 |
| 粒子星环 | 科技首页、加载页、能量核心 | 椭圆轨道、深度缩放、中心光晕 |
页面还提供三档速度:0.7x、1.0x、1.5x。暂停后相位和绘制次数保持不变,恢复后从原位置继续。
实验准备
| 项目 | 本次实际环境 |
|---|---|
| 设备 | HUAWEI Mate 60 Pro |
| 系统 | HarmonyOS 7.0 |
| SDK | API 26 |
| 开发方式 | ArkTS / ArkUI |
| 权限 | 不需要相机、麦克风、相册或网络权限 |
| 运行入口 | 首页的“实验 21:NEON FORGE 动效展厅” |
页面里的“音浪”使用程序化数据,只是演示波形绘制,所以不会申请麦克风权限。
第一步:把实验页接到首页
在首页引入实验组件,并用一个状态控制显示:
import { NeonGalleryPage } from '../features/graphics2d/NeonGalleryPage';
@State private showNeonGallery: boolean = false;
build(): void {
if (this.showNeonGallery) {
NeonGalleryPage({
onExit: (): void => {
this.showNeonGallery = false;
}
});
return;
}
// 原有首页内容
}
点击首页卡片时只做一件事:
private startNeonGallery(): void {
this.showNeonGallery = true;
}
这样返回首页不会重新创建整个应用,退出实验页时也能触发后面的资源释放。
第二步:准备一个可自绘的 RenderNode
本实验没有使用一堆 Image 叠图,而是创建一个 RenderNode,在 draw() 中拿到画布尺寸,清空背景,再根据当前模式绘制内容。
class NeonGalleryRenderNode extends RenderNode {
private stateValue: FxState = {
modeIndex: 0,
phase: 0,
intensity: 1
};
draw(context: DrawContext): void {
const width: number = Math.max(1, Math.round(context.sizeInPixel.width));
const height: number = Math.max(1, Math.round(context.sizeInPixel.height));
const canvas: drawing.Canvas = context.canvas;
canvas.clear(0xFF050817);
this.drawBackdrop(canvas, width, height, this.stateValue.modeIndex);
if (this.stateValue.modeIndex === 0) {
this.drawNeonTrails(canvas, width, height, this.stateValue.phase);
} else if (this.stateValue.modeIndex === 1) {
this.drawHologramRadar(canvas, width, height, this.stateValue.phase);
} else if (this.stateValue.modeIndex === 2) {
this.drawWaveMatrix(canvas, width, height, this.stateValue.phase);
} else {
this.drawParticleOrbit(canvas, width, height, this.stateValue.phase);
}
}
}
画布实际尺寸由 aboutToResize() 写回节点,这次真机拿到的是 1174 × 995 px。页面上的“实时绘制证据”卡片会把这个尺寸和累计绘制次数直接显示出来。
第三步:先做最容易看到差别的霓虹光轨
1. 用 Path 画三条路线
三条路线都由两段三次贝塞尔曲线组成。先用较粗、低透明度的 Pen 画光晕,再用细 Pen 画彩色主线,最后叠一条白色高光线,光轨就有了“亮边”。
private drawNeonTrails(
canvas: drawing.Canvas,
width: number,
height: number,
phase: number
): void {
const colors: number[] = [0xFF49D9FF, 0xFF66F5C2, 0xFFC57BFF];
const glowColors: number[] = [0x2249D9FF, 0x2266F5C2, 0x22C57BFF];
const yStarts: number[] = [0.30, 0.52, 0.72];
for (let lane: number = 0; lane < 3; lane += 1) {
const path: drawing.Path = this.createTrailPath(width, height, lane, yStarts[lane]);
this.strokePath(canvas, path, glowColors[lane], width * 0.034, true);
this.strokePath(canvas, path, colors[lane], width * 0.007, true);
this.strokePath(canvas, path, 0xCCFFFFFF, width * 0.0022, true);
const headT: number = (phase + lane * 0.19) % 1;
const point: FxPoint = this.trailPoint(width, height, lane, headT);
this.fillCircle(canvas, point.x, point.y, width * 0.055, glowColors[lane]);
this.fillCircle(canvas, point.x, point.y, width * 0.028, colors[lane]);
}
}
2. 用相位移动粒子头部
phase 始终在 0 到 1 之间循环。每条轨道加一个固定偏移,三条光轨就不会重叠在同一位置;再向前后各取几个采样点,就得到粒子尾巴。


第四步:把同一个 phase 换成全息雷达
雷达不需要新的动画对象,仍然使用同一个 phase。相位只负责旋转扫描角度和控制目标点亮度:
private drawHologramRadar(
canvas: drawing.Canvas,
width: number,
height: number,
phase: number
): void {
const centerX: number = width * 0.5;
const centerY: number = height * 0.53;
const radius: number = Math.min(width * 0.36, height * 0.39);
const sweepAngle: number = phase * Math.PI * 2 - Math.PI / 2;
for (let ring: number = 4; ring >= 1; ring -= 1) {
this.strokeCircle(canvas, centerX, centerY,
radius * ring / 4, 0x5536C9B0, width * 0.0024, true);
}
const sweep: drawing.Path = new drawing.Path();
sweep.moveTo(centerX, centerY);
sweep.lineTo(centerX + Math.cos(sweepAngle - 0.24) * radius,
centerY + Math.sin(sweepAngle - 0.24) * radius);
sweep.lineTo(centerX + Math.cos(sweepAngle + 0.06) * radius,
centerY + Math.sin(sweepAngle + 0.06) * radius);
sweep.close();
this.fillPath(canvas, sweep, 0x334BFFD0, true);
}
这里的关键不是“画一个圆”,而是把同一份时间相位同时喂给扫描扇区、脉冲环和目标点,所有元素自然处于同一节奏。


第五步:用柱形条和 Path 做音浪矩阵
音浪矩阵使用 32 根柱形条。每根柱子的高度由正弦函数和柱子索引共同决定,底部再画一份低透明度反射,最后用 Path 连成一条波形线。
const count: number = 32;
const left: number = width * 0.07;
const baseline: number = height * 0.61;
const gap: number = width * 0.86 / count;
for (let index: number = 0; index < 32; index += 1) {
const wave: number = 0.5 + 0.5 * Math.sin(
phase * Math.PI * 2 + index * 0.42
);
const barHeight: number = height * (0.10 + wave * 0.48);
const x: number = left + index * gap;
this.fillRoundRect(canvas, x, baseline - barHeight,
x + gap * 0.60, baseline, gap * 0.18, 0xFF73DFFF, true);
this.fillRoundRect(canvas, x, baseline + height * 0.018,
x + gap * 0.60, baseline + height * (0.035 + wave * 0.16),
width * 0.009, 0x3363C8FF, true);
}


第六步:用椭圆轨道做粒子星环
星环的空间感来自三个小细节:轨道的透明度不同、粒子半径随深度缩放、粒子绘制顺序按远近排序。中心能量核则用多层圆和 8 条脉冲射线完成。
for (let orbit: number = 0; orbit < 3; orbit += 1) {
const rx: number = width * (0.18 + orbit * 0.085);
const ry: number = height * (0.16 + orbit * 0.075);
const orbitPath: drawing.Path = new drawing.Path();
for (let step: number = 0; step <= 72; step += 1) {
const angle: number = step / 72 * Math.PI * 2;
const x: number = centerX + Math.cos(angle) * rx;
const y: number = centerY + Math.sin(angle) * ry;
if (step === 0) {
orbitPath.moveTo(x, y);
} else {
orbitPath.lineTo(x, y);
}
}
orbitPath.close();
this.strokePath(canvas, orbitPath,
orbit === 1 ? 0xAA58F0C7 : 0x5536C9B0,
width * (orbit === 1 ? 0.004 : 0.0024), true);
for (let point: number = 0; point < 10; point += 1) {
const angle: number = point / 10 * Math.PI * 2 + phase * Math.PI * 2;
const depth: number = 0.5 + 0.5 * Math.sin(angle);
const x: number = centerX + Math.cos(angle) * rx;
const y: number = centerY + Math.sin(angle) * ry;
this.fillCircle(canvas, x, y, width * (0.008 + depth * 0.012),
depth > 0.55 ? 0xFF66F5C2 : 0xFFC57BFF);
}
}


第七步:用一个 Animator 驱动四种画面
四种效果共享一个动画进度,不为每个效果重复创建 Animator。页面初始化时设置 60 FPS 期望值,onFrame 收到进度后只更新状态并让 RenderNode 重绘:
private startAnimator(): void {
this.detachAnimator();
this.animator = this.getUIContext().createAnimator({
duration: Math.round(6000 / SPEED_VALUES[this.speedIndex]),
easing: 'linear',
delay: 0,
fill: 'both',
direction: 'normal',
iterations: -1,
begin: 0,
end: 1
});
this.animator.setExpectedFrameRateRange({
min: 60,
max: 60,
expected: 60
});
this.animator.onFrame = (progress: number): void => {
this.phase = progress;
this.controller.setPhase(progress);
this.frameCallbacks += 1;
this.refreshMetrics();
};
this.animator.play();
}
模式切换只改 modeIndex:
private switchMode(index: number): void {
this.modeIndex = index;
this.controller.updateState({
modeIndex: index,
phase: this.phase,
intensity: 1
});
}
因此切换效果时动画不会重新起步,扫描角度、柱形条和粒子轨道都保持连续。
第八步:验证暂停、恢复、倍速和复位
暂停和恢复
暂停时调用 AnimatorResult.pause(),不清空 phase,所以画面和统计数字都会停住。恢复时调用 play(),从暂停位置继续。

真机截图中,暂停后的状态是“粒子星环已暂停”,按钮变成“继续播放”;等待约 2 秒后再次截图,画面位置和统计仍保持不变。



倍速
点击 1.5x 后,页面会重新创建 Animator,时长从 6000 ms 缩短为 4000 ms,画面从起点开始,右上角速度标记变为 1.5x。

复位
点击“复位”会把 phase 设为 0,保留当前效果和速度重新播放:
private resetEffect(): void {
const wasRunning: boolean = this.running;
this.phase = 0;
this.controller.updateState(this.currentState());
this.startAnimator();
if (!wasRunning) {
this.animator?.pause();
this.running = false;
}
}

第九步:退出时释放动画和绘制节点
页面退出和页面销毁都走同一个释放函数,先把回调替换为空函数,再取消 Animator,最后释放 RenderNode:
private releaseAll(action: string): void {
if (this.released) {
return;
}
this.released = true;
this.detachAnimator();
this.controller.release();
hilog.info(DOMAIN, TAG,
'FX_RELEASE action=%{public}s', action);
}

实操中遇到的情况
1. 第一次构建出现 ArkTS 类型错误
Animator 的回调属性不能直接赋 undefined。处理方式是给 onFrame、onRepeat、onFinish 和 onCancel 都设置空函数,再调用 cancel()。同时,ArkUI 的扩展链需要把 layoutWeight() 放在自定义扩展之前,否则会出现链式调用类型不匹配。
2. 动图不能用一张截图循环
如果只把一张截图转成 GIF,读者看不到相位变化,也无法证明是真动画。本次每个效果都在真机连续抓取 8 帧;“暂停保持”连续抓取 3 帧,三张画面的文件哈希一致,恢复播放的 8 帧再次变化。四种效果合计 32 帧的巡演 GIF 为 5.34 秒、6 FPS。
3. “音浪”不代表真的采集麦克风
本次只验证 ArkGraphics 2D 的绘制能力,音浪数据由正弦函数生成,没有接入音频输入。这样可以在没有权限弹窗的情况下稳定复现实验。
真机结果
| 验收项 | 结果 |
|---|---|
| 霓虹光轨 | 通过,三条光轨和粒子尾迹持续运动 |
| 全息雷达 | 通过,扫描扇区、圆环和目标点持续变化 |
| 音浪矩阵 | 通过,32 根柱形条和波形持续变化 |
| 粒子星环 | 通过,三层轨道和深度粒子持续变化 |
| 暂停 / 等待 | 通过,相位和绘制统计保持 |
| 恢复 | 通过,从原位置继续播放 |
| 0.7x / 1.0x / 1.5x | 通过,1.5x 截图状态可见 |
| 复位 | 通过,当前效果回到动画起点 |
| 退出释放 | 通过,返回首页并记录释放日志 |
本轮真机日志中可以看到完整事件链:
FX_READY modes=4 durationMs=6000 expectedFps=60 permissionRequired=false
FX_RESIZE widthVp=425 heightVp=360
FX_SWITCH mode=1 name=全息雷达 phasePermille=658 running=true
FX_SWITCH mode=2 name=音浪矩阵 phasePermille=644 running=true
FX_SWITCH mode=3 name=粒子星环 phasePermille=618 running=true
FX_PAUSE mode=3 phasePermille=576 drawCount=3801 callbacks=3797
FX_RESUME mode=3 phasePermille=576 drawCount=3801 callbacks=3797
FX_SPEED index=2 speed=1.5 running=true
FX_RESET mode=3 running=true
FX_RELEASE action=EXIT mode=3 drawCount=61411 callbacks=61407 phasePermille=250
小结
这个实验没有堆叠现成图片,而是把 Path、Pen、Brush、圆形、椭圆和文字组合到一个实时绘制节点里,再用一个 Animator 驱动相位。换一个 modeIndex 就能换一整套视觉效果,暂停、倍速、复位和退出也都有明确反馈。
如果继续扩展,可以把四个绘制函数拆成独立的效果类,再把相位输入替换成真实的地图轨迹、传感器数据或音频采样,展厅就能直接变成业务页面。
官方资料:
更多推荐

所有评论(0)