NEON FORGE ArkGraphics 2D酷炫效果展厅封面

先看成品:同一块画布,四种完全不同的画面

这次不从接口目录开始。打开实验 21,点击四个效果按钮,画布会在下面四种画面之间实时切换:

四种ArkGraphics 2D效果连续巡演

  • 霓虹光轨:三条贝塞尔曲线带着发光粒子向前跑;
  • 全息雷达:扫描扇区、同心圆和目标点同时脉冲;
  • 音浪矩阵:32 根柱形条和一条波形线按相位起伏;
  • 粒子星环:三层椭圆轨道、前后景粒子和中心能量核一起转动。

这四个效果都在真机上由 ArkGraphics 2D 直接绘制,顶部的 GIF 是本次录制的连续画面,不是静态图拼接。

这次要做什么

目标是做一个可以反复演示的“动效展厅”,让读者打开应用就能看见 ArkGraphics 2D 能做什么,并且能现场验证暂停、恢复、倍速和复位。

画面适合的实际场景绘制重点
霓虹光轨地图路线、游戏技能轨迹、数据流Path + 多层 Pen 描边 + 粒子尾迹
全息雷达扫描页、设备监控、目标探测圆环、扫描扇区、脉冲目标点
音浪矩阵音乐可视化、直播状态、节奏提示圆角柱、反射、波形路径
粒子星环科技首页、加载页、能量核心椭圆轨道、深度缩放、中心光晕

页面还提供三档速度:0.7x1.0x1.5x。暂停后相位和绘制次数保持不变,恢复后从原位置继续。

实验准备

项目本次实际环境
设备HUAWEI Mate 60 Pro
系统HarmonyOS 7.0
SDKAPI 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 始终在 01 之间循环。每条轨道加一个固定偏移,三条光轨就不会重叠在同一位置;再向前后各取几个采样点,就得到粒子尾巴。

霓虹光轨真机效果

霓虹光轨真机截图

第四步:把同一个 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

1.5倍速真机状态

复位

点击“复位”会把 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。处理方式是给 onFrameonRepeatonFinishonCancel 都设置空函数,再调用 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

小结

这个实验没有堆叠现成图片,而是把 PathPenBrush、圆形、椭圆和文字组合到一个实时绘制节点里,再用一个 Animator 驱动相位。换一个 modeIndex 就能换一整套视觉效果,暂停、倍速、复位和退出也都有明确反馈。

如果继续扩展,可以把四个绘制函数拆成独立的效果类,再把相位输入替换成真实的地图轨迹、传感器数据或音频采样,展厅就能直接变成业务页面。

官方资料:

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐