在这里插入图片描述
在这里插入图片描述

用代码构建一个视觉上令人沉醉的 N 边形万花筒旋阵,在 HarmonyOS(API 24)上体验三角函数与 Canvas 渲染的完美融合。


一、引言

在移动端应用中,视觉效果往往是吸引用户的第一道门槛。一个精妙绝伦的动画,能够在瞬间传递出应用品质感与开发者对细节的追求。本文将以一个 N 边形对称万花筒 Canvas 动画 为案例,完整演示在 HarmonyOS NEXT(API 24)上,如何使用 @ComponentV2 组件化架构 + CanvasRenderingContext2D 2D 渲染上下文,构建一个可交互的、60fps 流畅运行的视觉盛宴。

这个应用我们将其命名为 “万花筒旋阵”(Kaleidoscope),它实现了以下核心效果:

  • N 边形对称旋转:滑块调节 3~12 边,实时切换正三角形、四边形、五边形……十二边形
  • 多层叠加深度感:2~8 层,每层半径递减、转速递增、透明度递减,形成无限延伸的视觉纵深
  • 三角函数实时驱动cos / sin 计算顶点坐标,setLineDash / lineDashOffset 营造动态虚线环
  • HSL 色调循环:色相 0~360° 可调,各层色相偏移 + 动画角度驱动色相漂移
  • 粒子系统漫游:80 个粒子沿轨道运动 + 邻近粒子距离连线(类星云效果)
  • 顶点光点呼吸:脉冲大小 3 + 2*sin(angle*2 + i) 实现呼吸效果,径向渐变光晕渲染
  • 内接星形弦图:N≥4 时自动绘制星形虚线连线,带动态偏移滚动

二、HarmonyOS API 24 特性概览

本项目的 build-profile.json5 中配置如下:

{
  "targetSdkVersion": "6.1.1(24)",
  "compatibleSdkVersion": "6.1.1(24)"
}

API 24(HarmonyOS NEXT)带来了若干重要更新,本项目充分利用了以下几项:

1. @ComponentV2 装饰器

API 24 推荐的组件定义方式。相比传统的 @Component@ComponentV2 配合 @Local 装饰器,实现了更精细的响应式更新控制:

  • @Local:仅装饰器所在组件会被标记为脏并重渲染,不会冒泡到父组件
  • @Param:明确标记从父组件传入的参数,类型安全
  • @Event:子组件向父组件发射事件的标准化方式

在我们的万花筒中,所有可调参数(sideCountlayerCountrotationSpeedhueBase)均使用 @Local 装饰,当用户拖动 Slider 时,只有这些参数变化触发 Canvas 重绘,而顶层 Index 组件不受影响。

2. CanvasRenderingContext2D 完整 2D 上下文

API 24 提供了完整的 Canvas 2D 上下文,支持:

  • 路径操作:beginPathmoveTolineToarcclosePath
  • 样式控制:strokeStylefillStyleshadowColorshadowBlur
  • 渐变系统:createRadialGradient 径向渐变(多层光晕核心)
  • 虚线控制:setLineDashlineDashOffset(动态虚线环)
  • 变换矩阵:translaterotatescale(预留扩展)

3. Toggle Switch 组件

API 24 的 Toggle 组件配合 ToggleType.Switch,提供了原生级的开关交互体验,支持自定义选中颜色和滑块颜色,完美适配深色主题。

4. Slider 滑块组件

滑块组件支持整数和浮点数步进,配合 trackThickness 自定义轨道粗细、selectedColor 自定义选中段颜色,与 HSL 动态色相互联,实现视觉统一。


三、完整代码清单

以下是 KaleidoscopePage.ets 的全部代码,共计 474 行,覆盖了从粒子数据结构、动画循环、核心绘制到 UI 交互面板的完整实现:

/**
 * 万花筒旋阵 - N 边形对称 Canvas 动画
 * ============================================
 * 功能: N 边形对称万花筒,三角函数驱动动画,多层旋转叠加透明度
 * 技术栈: @ComponentV2 + CanvasRenderingContext2D + setTimeout ~60fps
 *
 * Index.ets 挂载方式:
 *   import { KaleidoscopePage } from './KaleidoscopePage';
 *   Stack() { KaleidoscopePage() }
 *
 * @since API 24
 */

// ===== 粒子结构 =====
interface KDParticle {
  angle: number;
  radius: number;
  size: number;
  speed: number;
  alpha: number;
  offset: number;
  hueOffset: number;
}

@ComponentV2
export struct KaleidoscopePage {
  // ---- Canvas 上下文 ----
  private settings: RenderingContextSettings = new RenderingContextSettings(true);
  private canvasContext: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.settings);

  // ---- 动画状态 ----
  @Local currentAngle: number = 0;
  private animationId: number = 0;

  // ---- 可调参数(UI 控件绑定) ----
  @Local sideCount: number = 6;        // N 边形边数 3~12
  @Local layerCount: number = 5;        // 层数 2~8
  @Local rotationSpeed: number = 1.0;   // 转速 0.1~3.0
  @Local hueBase: number = 200;         // 色相基底 0~360
  @Local showParticles: boolean = true;
  @Local showOutline: boolean = true;

  // ---- 粒子系统 ----
  private particles: KDParticle[] = [];

  // ---- 生命周期 ----
  aboutToAppear(): void {
    this.initParticles();
    this.startAnimation();
  }

  aboutToDisappear(): void {
    this.stopAnimation();
  }

  // ---- 粒子初始化 ----
  private initParticles(): void {
    this.particles = [];
    for (let i = 0; i < 80; i++) {
      this.particles.push({
        angle: Math.random() * Math.PI * 2,
        radius: 30 + Math.random() * 200,
        size: 1 + Math.random() * 3,
        speed: 0.1 + Math.random() * 0.6,
        alpha: 0.15 + Math.random() * 0.5,
        offset: Math.random() * Math.PI * 2,
        hueOffset: Math.random() * 60 - 30
      });
    }
  }

  // ---- 动画循环 ----
  private startAnimation(): void {
    const animate = () => {
      this.currentAngle += 0.006 * this.rotationSpeed;
      this.drawKaleidoscope();
      this.animationId = setTimeout(animate, 16); // ~60fps
    };
    animate();
  }

  private stopAnimation(): void {
    if (this.animationId) {
      clearTimeout(this.animationId);
      this.animationId = 0;
    }
  }

  // ---- 核心颜色工具 ----
  private hsl(h: number, s: number, l: number, a: number): string {
    return `hsla(${((h % 360) + 360) % 360},${s}%,${l}%,${a})`;
  }

  // ---- 主绘制函数 ----
  private drawKaleidoscope(): void {
    const ctx = this.canvasContext;
    const w = ctx.width;
    const h = ctx.height;
    if (w <= 0 || h <= 0) return;

    const cx = w / 2;
    const cy = h / 2;
    const maxR = Math.min(w, h) * 0.42;
    const angle = this.currentAngle;
    const n = this.sideCount;
    const layers = this.layerCount;

    // ========== 1. 清空画布 + 背景 ==========
    ctx.clearRect(0, 0, w, h);

    // 深色径向渐变背景
    const bgGrad = ctx.createRadialGradient(cx, cy, 0, cx, cy, maxR * 2);
    bgGrad.addColorStop(0, `hsla(${this.hueBase},70%,15%,0.6)`);
    bgGrad.addColorStop(0.5, `hsla(${this.hueBase},60%,8%,0.3)`);
    bgGrad.addColorStop(1, `hsla(${this.hueBase},50%,3%,1)`);
    ctx.fillStyle = bgGrad;
    ctx.fillRect(0, 0, w, h);

    // ========== 2. 外围光晕 ==========
    for (let ring = 0; ring < 3; ring++) {
      const ringR = maxR * (0.7 + ring * 0.15);
      ctx.beginPath();
      ctx.arc(cx, cy, ringR, 0, Math.PI * 2);
      ctx.strokeStyle = this.hsl(this.hueBase + ring * 20, 80, 60, 0.06 - ring * 0.015);
      ctx.lineWidth = 2 + ring;
      ctx.setLineDash([6, 12 + ring * 6]);
      ctx.lineDashOffset = -angle * (30 - ring * 8);
      ctx.stroke();
    }
    ctx.setLineDash([]);

    // ========== 3. 多层 N 边形旋转叠加(核心万花筒效果)==========
    for (let layer = 0; layer < layers; layer++) {
      const layerRatio = 1 - layer / (layers + 1);  // 1.0 → 0.2
      const layerR = maxR * (0.2 + 0.65 * layerRatio);
      const layerAngle = angle * (1 + layer * 0.4) + layer * 0.5;
      const alpha = 0.25 - layer * 0.025;  // 外层亮,内层暗
      const hue = this.hueBase + layer * 25 + angle * 10;
      const isEven = layer % 2 === 0;

      if (alpha <= 0) continue;

      // ---- 3a. 绘制 N 边形轮廓(双层描边实现发光感)----
      if (this.showOutline) {
        ctx.beginPath();
        for (let i = 0; i <= n; i++) {
          const a = layerAngle + (i % n) * (Math.PI * 2 / n) + (isEven ? 0 : Math.PI / n);
          const r = layerR;
          const x = cx + Math.cos(a) * r;
          const y = cy + Math.sin(a) * r;
          if (i === 0) ctx.moveTo(x, y);
          else ctx.lineTo(x, y);
        }
        ctx.closePath();
        ctx.strokeStyle = this.hsl(hue, 85, 65, alpha * 1.2);
        ctx.lineWidth = 1.5 + 0.5 * Math.sin(angle * 2 + layer);
        ctx.shadowColor = this.hsl(hue, 90, 70, 0);
        ctx.shadowBlur = 8;
        ctx.stroke();

        // 内发光细描边
        ctx.strokeStyle = this.hsl(hue + 30, 90, 80, alpha * 0.5);
        ctx.lineWidth = 0.5;
        ctx.shadowBlur = 0;
        ctx.stroke();
      }

      // ---- 3b. 从顶点到中心的连线(辐射线)----
      ctx.shadowBlur = 0;
      for (let i = 0; i < n; i++) {
        const a = layerAngle + i * (Math.PI * 2 / n) + angle * 0.3;
        const targetR = layerR * (0.3 + 0.7 * Math.abs(Math.sin(angle * 0.5 + i + layer)));

        ctx.beginPath();
        ctx.moveTo(cx, cy);
        ctx.lineTo(
          cx + Math.cos(a) * targetR,
          cy + Math.sin(a) * targetR
        );
        ctx.strokeStyle = this.hsl(hue + i * 10, 70, 50, alpha * 0.15);
        ctx.lineWidth = 0.5;
        ctx.stroke();
      }

      // ---- 3c. 顶点光点 ----
      for (let i = 0; i < n; i++) {
        const a = layerAngle + i * (Math.PI * 2 / n);
        const px = cx + Math.cos(a) * layerR;
        const py = cy + Math.sin(a) * layerR;
        const pulseSize = 2 + 2 * Math.sin(angle * 2 + i + layer * 1.5);
        const dotAlpha = alpha * (0.5 + 0.5 * Math.sin(angle + i));

        const dotGrad = ctx.createRadialGradient(px, py, 0, px, py, pulseSize * 4);
        dotGrad.addColorStop(0, '#FFFFFF');
        dotGrad.addColorStop(0.3, this.hsl(hue + i * 15, 90, 75, 1));
        dotGrad.addColorStop(1, this.hsl(hue + i * 15, 90, 75, 0));
        ctx.fillStyle = dotGrad;
        ctx.beginPath();
        ctx.arc(px, py, pulseSize * 4, 0, Math.PI * 2);
        ctx.fill();
      }

      // ---- 3d. 边中点连线(形成内接星形)----
      if (n >= 4) {
        const starAlpha = alpha * 0.3;
        ctx.beginPath();
        for (let i = 0; i < n; i++) {
          const a = layerAngle + (i * 2) % n * (Math.PI * 2 / n);
          const starR = layerR * (0.6 + 0.2 * Math.sin(angle + i));
          const x = cx + Math.cos(a) * starR;
          const y = cy + Math.sin(a) * starR;
          if (i === 0) ctx.moveTo(x, y);
          else ctx.lineTo(x, y);
        }
        ctx.closePath();
        ctx.strokeStyle = this.hsl(hue + 20, 80, 70, starAlpha);
        ctx.lineWidth = 0.8;
        ctx.setLineDash([2, 3]);
        ctx.lineDashOffset = -angle * 15;
        ctx.stroke();
        ctx.setLineDash([]);
      }
    }

    // ========== 4. 中心光芒(多层径向渐变)==========
    ctx.shadowBlur = 0;
    for (let c = 0; c < 3; c++) {
      const coreR = maxR * (0.05 + c * 0.08);
      const coreGrad = ctx.createRadialGradient(cx, cy, 0, cx, cy, coreR * 3);
      coreGrad.addColorStop(0, '#FFFFFF');
      coreGrad.addColorStop(0.2, this.hsl(this.hueBase + c * 30, 90, 75, 1));
      coreGrad.addColorStop(0.6, this.hsl(this.hueBase + c * 30, 80, 60, 0.3 - c * 0.08));
      coreGrad.addColorStop(1, this.hsl(this.hueBase + c * 30, 80, 60, 0));
      ctx.fillStyle = coreGrad;
      ctx.beginPath();
      ctx.arc(cx, cy, coreR * 3, 0, Math.PI * 2);
      ctx.fill();
    }

    // ========== 5. 外部飘浮粒子(沿轨道运动)==========
    if (this.showParticles) {
      for (const p of this.particles) {
        const pAngle = p.angle + angle * p.speed * 0.4;
        const pR = p.radius + 20 * Math.sin(angle * 1.5 + p.offset);
        const px = cx + Math.cos(pAngle) * pR;
        const py = cy + Math.sin(pAngle) * pR;
        const pAlpha = p.alpha * (0.4 + 0.6 * Math.sin(angle * 1.2 + p.offset));
        const pHue = this.hueBase + p.hueOffset + angle * 20;

        ctx.beginPath();
        ctx.arc(px, py, p.size, 0, Math.PI * 2);
        ctx.fillStyle = this.hsl(pHue, 85, 70, pAlpha);
        ctx.fill();

        // 粒子外围光晕
        const glowGrad = ctx.createRadialGradient(px, py, 0, px, py, p.size * 3);
        glowGrad.addColorStop(0, this.hsl(pHue, 90, 80, pAlpha * 0.3));
        glowGrad.addColorStop(1, this.hsl(pHue, 90, 80, 0));
        ctx.fillStyle = glowGrad;
        ctx.beginPath();
        ctx.arc(px, py, p.size * 3, 0, Math.PI * 2);
        ctx.fill();
      }

      // 粒子间连线(附近粒子相互连接)
      const connectDist = 80;
      for (let i = 0; i < this.particles.length; i += 2) {
        const p1 = this.particles[i];
        const p2 = this.particles[(i + 3) % this.particles.length];

        const a1 = p1.angle + angle * p1.speed * 0.4;
        const a2 = p2.angle + angle * p2.speed * 0.4;
        const r1 = p1.radius + 20 * Math.sin(angle * 1.5 + p1.offset);
        const r2 = p2.radius + 20 * Math.sin(angle * 1.5 + p2.offset);
        const x1 = cx + Math.cos(a1) * r1;
        const y1 = cy + Math.sin(a1) * r1;
        const x2 = cx + Math.cos(a2) * r2;
        const y2 = cy + Math.sin(a2) * r2;

        const dx = x2 - x1;
        const dy = y2 - y1;
        const dist = Math.sqrt(dx * dx + dy * dy);

        if (dist < connectDist) {
          ctx.beginPath();
          ctx.moveTo(x1, y1);
          ctx.lineTo(x2, y2);
          ctx.strokeStyle = this.hsl(this.hueBase, 70, 60, 0.03 * (1 - dist / connectDist));
          ctx.lineWidth = 0.5;
          ctx.stroke();
        }
      }
    }

    // ========== 6. 画布中心的极点标记 ==========
    ctx.beginPath();
    ctx.arc(cx, cy, 2, 0, Math.PI * 2);
    ctx.fillStyle = 'rgba(255,255,255,0.6)';
    ctx.fill();
  }

  // ===================== UI 构建 =====================

  build() {
    Column({ space: 0 }) {
      // ---- 标题 ----
      Text('万花筒旋阵')
        .fontSize(24)
        .fontWeight(FontWeight.Bold)
        .fontColor('#FFFFFF')
        .width('100%')
        .textAlign(TextAlign.Center)
        .padding({ top: 16, bottom: 4 })

      Text(`${this.sideCount}边形 | ${this.layerCount}层叠加`)
        .fontSize(13)
        .fontColor(`hsla(${this.hueBase},80%,70%,1)`)
        .width('100%')
        .textAlign(TextAlign.Center)
        .padding({ bottom: 8 })

      // ---- Canvas 画布 ----
      Canvas(this.canvasContext)
        .width('100%')
        .layoutWeight(1)

      // ---- 控制面板 ----
      Column({ space: 8 }) {
        // 行1: 边数
        Row({ space: 10 }) {
          Text('边数')
            .fontSize(12)
            .fontColor('#999999')
            .width(40)

          Slider({
            value: this.sideCount,
            min: 3, max: 12, step: 1
          })
          .layoutWeight(1)
          .trackThickness(4)
          .blockColor(this.hsl(this.hueBase, 80, 70, 1))
          .trackColor('#FFFFFF30')
          .selectedColor(this.hsl(this.hueBase, 80, 70, 1))
          .onChange((val: number) => { this.sideCount = val; })

          Text(`${this.sideCount}`)
            .fontSize(12)
            .fontColor('#FFFFFF')
            .width(24)
            .textAlign(TextAlign.End)
        }
        .width('100%')

        // 行2: 层数
        Row({ space: 10 }) {
          Text('层数')
            .fontSize(12)
            .fontColor('#999999')
            .width(40)

          Slider({
            value: this.layerCount,
            min: 2, max: 8, step: 1
          })
          .layoutWeight(1)
          .trackThickness(4)
          .blockColor(this.hsl(this.hueBase, 80, 70, 1))
          .trackColor('#FFFFFF30')
          .selectedColor(this.hsl(this.hueBase, 80, 70, 1))
          .onChange((val: number) => { this.layerCount = val; })

          Text(`${this.layerCount}`)
            .fontSize(12)
            .fontColor('#FFFFFF')
            .width(24)
            .textAlign(TextAlign.End)
        }
        .width('100%')

        // 行3: 转速
        Row({ space: 10 }) {
          Text('转速')
            .fontSize(12)
            .fontColor('#999999')
            .width(40)

          Slider({
            value: this.rotationSpeed,
            min: 0.1, max: 3.0, step: 0.1
          })
          .layoutWeight(1)
          .trackThickness(4)
          .blockColor(this.hsl(this.hueBase, 80, 70, 1))
          .trackColor('#FFFFFF30')
          .selectedColor(this.hsl(this.hueBase, 80, 70, 1))
          .onChange((val: number) => { this.rotationSpeed = val; })

          Text(`${this.rotationSpeed.toFixed(1)}x`)
            .fontSize(12)
            .fontColor('#FFFFFF')
            .width(36)
            .textAlign(TextAlign.End)
        }
        .width('100%')

        // 行4: 色相
        Row({ space: 10 }) {
          Text('色调')
            .fontSize(12)
            .fontColor('#999999')
            .width(40)

          Slider({
            value: this.hueBase,
            min: 0, max: 360, step: 5
          })
          .layoutWeight(1)
          .trackThickness(4)
          .blockColor(this.hsl(this.hueBase, 80, 70, 1))
          .trackColor('#FFFFFF30')
          .selectedColor(this.hsl(this.hueBase, 80, 70, 1))
          .onChange((val: number) => { this.hueBase = val; })

          Text(`${this.hueBase}°`)
            .fontSize(12)
            .fontColor('#FFFFFF')
            .width(36)
            .textAlign(TextAlign.End)
        }
        .width('100%')

        // 行5: 开关
        Row({ space: 16 }) {
          Row({ space: 6 }) {
            Text('粒子')
              .fontSize(12)
              .fontColor('#999999')

            Toggle({ type: ToggleType.Switch, isOn: this.showParticles })
              .selectedColor(this.hsl(this.hueBase, 80, 70, 1))
              .switchPointColor('#FFFFFF')
              .onChange((isOn: boolean) => { this.showParticles = isOn; })
          }

          Row({ space: 6 }) {
            Text('轮廓')
              .fontSize(12)
              .fontColor('#999999')

            Toggle({ type: ToggleType.Switch, isOn: this.showOutline })
              .selectedColor(this.hsl(this.hueBase, 80, 70, 1))
              .switchPointColor('#FFFFFF')
              .onChange((isOn: boolean) => { this.showOutline = isOn; })
          }
        }
        .width('100%')
        .justifyContent(FlexAlign.Center)
      }
      .width('100%')
      .padding({ left: 16, right: 16, top: 10, bottom: 14 })
      .backgroundColor('#FFFFFF10')
      .borderRadius({ topLeft: 16, topRight: 16 })
    }
    .width('100%')
    .height('100%')
    .backgroundColor(`hsla(${this.hueBase},50%,5%,1)`)
  }
}

Index.ets 挂载代码仅需 17 行:

import { KaleidoscopePage } from './KaleidoscopePage';

@Entry
@ComponentV2
struct Index {
  build(): void {
    Stack() {
      KaleidoscopePage()
    }
    .width('100%')
    .height('100%')
  }
}

四、架构设计精析

4.1 组件化结构

整个应用遵循了 “独立文件 + 入口挂载” 的模式。KaleidoscopePage.ets 是一个自包含的 @ComponentV2 结构体,内部承载了全部的状态管理、动画循环、绘制逻辑和 UI 布局。Index.ets 仅作挂载入口,不插手任何业务逻辑。

这种架构的优势在于:

  1. 职责单一:每个组件只关注自己的核心功能,便于测试和复用
  2. 独立部署:可以直接在路由表中注册,也可以嵌套在其他页面中
  3. 状态隔离@Local 装饰器确保状态变化局限在当前组件内,不会意外影响父组件

4.2 组件生命周期管理

ArkTS 组件的生命周期方法中,aboutToAppearaboutToDisappear 是我们最关心的两个钩子。

aboutToAppear(): void {
  this.initParticles();
  this.startAnimation();
}

aboutToDisappear(): void {
  this.stopAnimation();
}

为什么不在 build() 中初始化? 因为 build() 方法在组件的每次重渲染时都会被调用(包括参数变化引发的重渲染),如果在 build() 中初始化粒子或启动动画,会导致粒子被反复重置、动画定时器被反复创建,造成严重的内存泄漏和性能问题。aboutToAppear 只在组件实例首次挂载到节点树时执行一次,是初始化的正确场所。

为什么要在 aboutToDisappear 中停止动画? 如果组件被销毁(如页面跳转或路由弹出)而动画循环仍在运行,setTimeout 的回调会继续持有对 this.canvasContext 的引用,既造成内存泄漏,又会在已被卸载的 Canvas 上做无意义的绘制。clearTimeout 确保了动画循环随着组件销毁而终结。

这一对生命周期钩子的配合,是组件化动画开发中的经典模式,在任何框架(React useEffect 的 cleanup、Vue onUnmounted)中都有对应的概念。

4.3 渲染流程

每一帧的渲染管道如下:

setTimeout 触发
  ↓
currentAngle += 0.006 * rotationSpeed
  ↓
drawKaleidoscope()
  ├── clearRect 清空画布
  ├── 绘制深色背景(径向渐变)
  ├── 绘制 3 圈外围虚线光晕(setLineDash + offset 动画)
  ├── 循环 layer 层:
  │   ├── 绘制 N 边形轮廓(双层描边 shadowBlur 发光)
  │   ├── 绘制辐射线(顶点→中心)
  │   ├── 绘制顶点光点(径向渐变呼吸)
  │   └── N≥4 时绘制内接星形虚线
  ├── 绘制中心光芒(3 层径向渐变叠加)
  ├── 绘制粒子系统(80 个 + 距离连线)
  └── 绘制中心极点标记
  ↓
setTimeout(animate, 16) 注册下一帧

4.3 状态管理与响应式更新

@Local 装饰器是 API 24 引入的关键特性。它在 @ComponentV2 中的作用是标记一个可变状态字段,当该字段被赋值时,框架仅重渲染该组件自身,不会冒泡。在我们的代码中:

@Local sideCount: number = 6;
@Local layerCount: number = 5;
@Local rotationSpeed: number = 1.0;
@Local hueBase: number = 200;
@Local showParticles: boolean = true;
@Local showOutline: boolean = true;
@Local currentAngle: number = 0;

其中 currentAngle 是动画帧角度,每 16ms 递增一次;其余六个参数由 Slider / Toggle 控件驱动。由于 @Local 的隔离性,当用户拖动 Slider 改变 sideCounthueBase 时,只有 KaleidoscopePage 被标记为 dirty 并重新调用 build()Index 不受任何影响。


五、数学原理:N 边形与三角函数的协同

万花筒的核心是对称性,而对称性的数学基础是 正 N 边形的顶点坐标计算

5.1 顶点坐标公式

对于任意正 N 边形,第 i 个顶点的坐标可以表示为:

θ_i = 起始偏转角 + i * (2π / n)
x_i = 圆心_x + R * cos(θ_i)
y_i = 圆心_y + R * sin(θ_i)

在代码中体现为:

const a = layerAngle + (i % n) * (Math.PI * 2 / n) + (isEven ? 0 : Math.PI / n);
const x = cx + Math.cos(a) * r;
const y = cy + Math.sin(a) * r;

这里的 isEven ? 0 : Math.PI / n 实现了奇偶层的交错旋转——偶数层顶点对齐,奇数层旋转半个边距,使不同层之间形成相互穿插的视觉效果。

5.2 三角函数在动画中的多维运用

三角函数(Math.sin / Math.cos)在本项目中至少有五处不同的应用场景:

用途 表达式 效果
顶点坐标 cos(a)*R, sin(a)*R N 边形顶点定位
线宽呼吸 1.5 + 0.5 * sin(angle*2 + layer) 轮廓宽度周期性变化
光点脉冲 2 + 2 * sin(angle*2 + i + layer*1.5) 顶点光点放大缩小
粒子轨道波动 20 * sin(angle*1.5 + offset) 粒子沿轨道径向摆动
透明度闪烁 0.5 + 0.5 * sin(angle + i) 光点透明度交替明暗

这种多路并行的三角函数调制,是产生"万花筒"式复杂视觉效果的根本原因。每一路三角函数都可以视为一个独立的振动模态,它们之间没有整数倍频关系,因此叠加产生的图案具有准周期性(quasi-periodic),永远不会严格重复——这也是为什么观看万花筒永远有新鲜感。

5.3 内接星形连线

当 N ≥ 4 时,代码会绘制一个"每隔一个顶点连线"的星形:

const a = layerAngle + (i * 2) % n * (Math.PI * 2 / n);

(i * 2) % n 看似简单,却在数学上有深意。当 n 为奇数时,(i * 2) % n 会遍历所有顶点一次(因为 2 和 n 互质);当 n 为偶数且 n/2 为奇数时,会遍历 n/2 个顶点。这意味着星形本身也会呈现不同的拓扑结构——五边形产生五角星,六边形产生六芒星(大卫之星),八边形产生八芒星。


六、粒子系统设计

粒子系统是增强视觉深度的点睛之笔。我们设计了 80 个轨道粒子,每个粒子都有独立的属性:

6.1 粒子属性定义

interface KDParticle {
  angle: number;       // 初始轨道角度 (0~2π)
  radius: number;      // 轨道半径 (30~230)
  size: number;        // 粒子大小 (1~4)
  speed: number;       // 轨道速度系数 (0.1~0.7)
  alpha: number;       // 基础透明度 (0.15~0.65)
  offset: number;      // 波动相位偏移 (0~2π)
  hueOffset: number;   // 色相偏移 (-30~+30)
}

所有属性在 initParticles() 中通过 Math.random() 随机生成,赋予每个粒子独一无二的运动特征。

6.2 双阶段渲染

每个粒子经历两阶段渲染:先画实心小圆点(核心),再画径向渐变光晕(外发光)。

// 核心
ctx.arc(px, py, p.size, 0, Math.PI * 2);
ctx.fillStyle = this.hsl(pHue, 85, 70, pAlpha);

// 光晕
const glowGrad = ctx.createRadialGradient(px, py, 0, px, py, p.size * 3);
glowGrad.addColorStop(0, this.hsl(pHue, 90, 80, pAlpha * 0.3));
glowGrad.addColorStop(1, this.hsl(pHue, 90, 80, 0));

这种"核+晕"的渲染手法在粒子系统乃至星云模拟中都很常见。核心提供颜色饱和度和不透明度,光晕提供柔和的发光边缘,两者结合产生类似恒星或萤火虫的观感。

6.3 粒子间距离连线

为了模拟"星云网络"效果,代码对粒子进行了配对连线:

if (dist < connectDist) {
  ctx.strokeStyle = this.hsl(this.hueBase, 70, 60, 0.03 * (1 - dist / connectDist));
  ctx.lineWidth = 0.5;
  ctx.stroke();
}

连线透明度随距离衰减(1 - dist / connectDist),形成"近浓远淡"的渐变网。connectDist = 80 是一个经验值,在粒子密度为 80 的情况下,大约会产生 30~50 条可见连线,不至于太密集也不至于太空旷。


七、颜色系统:HSL 模型的优势

整个项目完全采用 HSL 色模型,没有使用任何十六进制或 RGB 字面量(除了 #FFFFFF 纯白)。我们封装了一个辅助方法:

private hsl(h: number, s: number, l: number, a: number): string {
  return `hsla(${((h % 360) + 360) % 360},${s}%,${l}%,${a})`;
}

为什么要用 HSL?有三个理由:

1. 色相循环天然适配动画

hue 参数是一个数值角度,可以随意加减。代码中让色相随动画角度漂移:

const hue = this.hueBase + layer * 25 + angle * 10;

每帧 angle 增加约 0.006,对应色相每帧偏移约 0.06°,大约 6000 帧(约 100 秒)完成一个完整色相循环。这种缓慢的色相漂移让画面始终在变化,不会视觉疲劳。同时值得注意的是,layer * 25 让每层之间的色相相差 25°,这意味着在 5 层情况下,最内层和最外层的色相差可达 100°——从蓝色渐变到青绿色再过渡到绿色,形成了丰富的色彩梯度。

2. 饱和度/明度独立控制

HSL 模型将"颜色本质"(色相)与"颜色表现"(饱和度、明度、透明度)分离。这让代码可以专注于色相计算,而饱和度/明度保持恒定值或简单函数——极大的减少了调色心智负担。

具体来说,代码中不同元素的 HSL 参数分配如下:

元素 饱和度(S) 明度(L) 透明度(A)
多边形轮廓(外发光) 85% 65% alpha * 1.2
多边形轮廓(内描边) 90% 80% alpha * 0.5
辐射线 70% 50% alpha * 0.15
顶点光点(渐变中心) 90% 75% 1.0
粒子核心 85% 70% pAlpha
粒子光晕 90% 80% pAlpha * 0.3
外围虚线环 80% 60% 0.06 ~ 0.03

仔细看这个分配表:轮廓的外发光使用较低明度(65%)和较高透明度,营造"从背后透出的光";内描边则高明度(80%)低透明度,制造"表面的光泽";辐射线刻意压低饱和度和透明度(50%/0.15),作为背景纹理不应喧宾夺主。这种分层级的色彩策略,让画面既有深度又不杂乱。

3. 动态生成颜色渐变

在径向渐变中:

coreGrad.addColorStop(0, '#FFFFFF');
coreGrad.addColorStop(0.2, this.hsl(this.hueBase + c * 30, 90, 75, 1));
coreGrad.addColorStop(0.6, this.hsl(this.hueBase + c * 30, 80, 60, 0.3 - c * 0.08));
coreGrad.addColorStop(1, this.hsl(this.hueBase + c * 30, 80, 60, 0));

从纯白 → 高饱和鲜艳色 → 暗色 → 透明,HSL 让这种过渡可以参数化表达,改一行 hueBase 就能切换整个应用的主题色。

值得注意的一个设计细节是 addColorStop 的位置选择:渐变中心 0% 处用纯白而非纯色,是为了模拟强光光源的"过曝"效果——任何强光中心看起来总是白色的。20% 处才是该层的本色,60% 处开始褪色到 100% 处完全透明,这种"中间宽两边窄"的色标分布,比均匀分布更接近真实的光晕衰减曲线。


八、性能优化实践

在 60fps 的 Canvas 动画中,性能是关键。每帧只有约 16ms 的时间来执行所有绘制操作。以下是我们采用的主要优化策略:

8.1 提前退出检查

if (w <= 0 || h <= 0) return;

drawKaleidoscope 函数最开头,检查 Canvas 的宽度和高度是否有效。当页面尚未 layout(宽高为 0)时,直接跳过绘制,避免无效计算。

8.2 分层裁剪

if (alpha <= 0) continue;

当层的透明度衰减到 0 以下时,跳过整个该层的所有绘制操作。这在 layerCount 为 8 时特别有用,后几层的透明度可能低于 0,无需浪费时间渲染不可见的像素。

8.3 setTimeout 而不是 requestAnimationFrame

这是一个有意思的选择。在 HarmonyOS 的 ArkTS 环境中,requestAnimationFrame 的调用时机取决于 UI 线程的帧调度,而 setTimeout 提供了更确定的时间间隔。使用 setTimeout(animate, 16) 大约对应 60fps(1000/60 ≈ 16.67ms),且不会因为 UI 线程拥堵而丢失帧。

当然,CPU 繁忙时 setTimeout 也可能被延迟,但对于 Canvas 动画而言,“略晚几毫秒"的视觉影响远小于"丢失一帧”。这是我们权衡后的选择。

8.4 减少路径重建

在绘制 N 边形轮廓时,代码尝试在一个 beginPath() ~ closePath() 对中完成所有顶点的连接:

ctx.beginPath();
for (let i = 0; i <= n; i++) {
  const a = layerAngle + (i % n) * (Math.PI * 2 / n) + (isEven ? 0 : Math.PI / n);
  const x = cx + Math.cos(a) * r;
  const y = cy + Math.sin(a) * r;
  if (i === 0) ctx.moveTo(x, y);
  else ctx.lineTo(x, y);
}
ctx.closePath();

避免为每条边创建一个独立的路径,而是用 moveTo + 多条 lineTo 构建闭合路径,大幅降低 Canvas 底层路径管理的开销。

8.5 避免在热路径中创建对象

代码中没有在 drawKaleidoscope 的循环体内创建临时数组或对象。粒子数组 this.particlesaboutToAppear 时一次性创建,之后在动画循环中只读取不重建。颜色字符串使用模板字面量(hsla(...))而非对象封装,减少 GC 压力。

8.6 shadowBlur 的有条件使用

shadowBlur 是 Canvas 中开销较大的操作之一。每设置一次阴影,渲染引擎需要额外生成一个模糊纹理并与目标像素进行卷积混合。代码中对此做了精确的控制:

// 外发光时启用阴影
ctx.shadowBlur = 8;
ctx.stroke();            // 消耗阴影渲染
ctx.shadowBlur = 0;      // 立即关闭

shadowBlur 只在需要外发光效果的多边形轮廓绘制时(步骤 3a 的外描边)启用,且用完即关。在辐射线、顶点光点、粒子等所有其他绘制步骤前,都显式地将 shadowBlur 置为 0,避免不必要的阴影计算扩散到后续路径。

8.7 Canvas 尺寸自适应

Canvas 的宽高由父容器约束决定,代码中使用 ctx.widthctx.height 动态获取当前宽高:

const w = ctx.width;
const h = ctx.height;
if (w <= 0 || h <= 0) return;

这带来了一个重要的好处:屏幕旋转或窗口大小变化时完全不需要额外适配代码。Canvas 的宽高由布局系统自动更新,drawKaleidoscope 每次绘制时读取最新的宽高值,计算出适配的中心点和最大半径,所有元素自动居中。这也是声明式布局框架相比传统命令式 UI 的优势之一。

8.8 帧率与设备适配

setTimeout(animate, 16) 的目标帧率是 60fps(1000/16 ≈ 62.5fps,留有 2.5ms 余量)。在实际设备上,不同性能等级的 HarmonyOS 设备可能表现出不同的帧率稳定性:

  • 旗舰设备(如 Mate 60 系列):稳定 60fps,angle 累加均匀,视觉效果最流畅
  • 中端设备(如 Nova 系列):可能在 50~60fps 之间波动,偶尔出现轻微卡顿
  • 低端设备:可能降至 30fps,此时 angle 累加间隔拉长,画面会感觉"跳帧"

对于需要在低端设备上保持流畅的场景,可以考虑 layerCount 的自动降级——在 aboutToAppear 中通过 getSystemInfo 获取设备等级,动态调整默认层数。


九、可视化参数调优

为了让万花筒达到最佳视觉效果,我们配置了 5 个可交互参数,并通过 Slider 和 Toggle 暴露给用户:

参数 类型 范围 步进 默认值 视觉影响
边数 Slider 3~12 1 6 对称图案的复杂度
层数 Slider 2~8 1 5 视觉纵深与层次感
转速 Slider 0.1~3.0 0.1 1.0 动画快慢节奏
色调 Slider 0~360 5 200 整体色彩倾向
粒子 Switch on/off - on 粒子系统显隐
轮廓 Switch on/off - on 多边形轮廓显隐

每一个参数都经过精心的数值范围设计:

  • 边数 3~12:3 边形(等腰三角形)和 4 边形(正方形)是最简单的对称形式,5~8 边形是最具美感的中间区段,9~12 边形则趋近圆形,适合实验性观察
  • 层数 2~8:2 层太简单,8 层接近性能上限(每层 4 个子绘制区域 × 8 = 32 个循环块)
  • 转速 0.1~3.0:0.1 倍几乎静止,适合观察单帧结构;3.0 倍快速旋转,产生残影和融合效果
  • **色相 0360**:覆盖整个色环,不同色相值带来截然不同的情感体验——蓝色(180240)冷静深邃,红色(030)热情奔放,绿色(90150)自然平和

十、扩展思路

以当前的万花筒旋阵为基础,可以从以下几个方向进一步扩展:

10.1 触摸交互

在 Canvas 上绑定 onTouch 事件,监听手指滑动来实时调整参数。例如,双指捏合改变边数,单指旋转改变色相,让交互更有沉浸感。

Canvas(this.canvasContext)
  .onTouch((event: TouchEvent) => {
    // 根据手势调整参数
  })

10.2 音频驱动

使用 AudioCapturerAudioRenderer 捕获麦克风输入或播放音乐的频谱数据,将音频能量映射到万花筒的参数上——低频控制层数,中频控制半径,高频控制粒子大小。实现"音乐可视化"。

10.3 录制与分享

利用 CanvasRenderingContext2DtoDataURL 方法,将当前帧导出为图片。配合 @ohos.multimedia.media 的屏幕录制能力,可以将一段万花筒动画录制成视频并分享。

10.4 多种对称模式

当前的 N 边形是基于旋转对称(C_n)。可以增加二面体对称(D_n,包含镜像反射)和螺旋对称(每旋转一个角度后半径缩放,类似斐波那契螺旋),让万花筒的图案类型更加丰富。

10.5 GLSL Shader 版本

当 N 边数超过 12 或者层数超过 8 时,CPU 上的 Canvas 绘制会逼近性能瓶颈。此时可以探索使用 CanvasRenderingContext2D 的 WebGL 模式,或者直接使用 @ohos.graphics 来编写自定义 Shader,在 GPU 上并行计算所有顶点和颜色。


十一、总结

本文以一个完整的 N 边形万花筒 Canvas 动画项目为载体,详细阐述了在 HarmonyOS NEXT(API 24)上开发图形动画应用的全流程。从组件架构设计、数学原理、粒子系统、颜色模型到性能优化,涵盖了视觉计算应用开发的多个核心维度。

万花筒的魅力在于:简单的规则 + 持续的迭代 = 复杂的不可预测之美。N 个顶点、N 条连线、N 层叠加——每一个 N 都是一个自由度,这些自由度通过三角函数调制、色相漂移、透明度叠加等方式交织在一起,形成了每帧都在演化的视觉奇观。

希望这篇解析能为你在 HarmonyOS 平台上开发图形密集型应用提供参考和启发。

代码仓库

完整项目结构:

entry/src/main/ets/pages/
├── Index.ets                    # 入口挂载(17 行)
└── KaleidoscopePage.ets         # 全部逻辑(474 行)

API 24 = HarmonyOS NEXT 6.1.1,compatibleSdkVersion 与 targetSdkVersion 均为 24。


本文对应的项目配置信息:

"targetSdkVersion": "6.1.1(24)",
"compatibleSdkVersion": "6.1.1(24)"
Logo

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

更多推荐