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

项目地址:TreeFractalPage.ets — 递归分形树 + 风场摇曳 + 末端开花


一、引言

递归分形树是计算机图形学中最经典也最直观的分形结构之一。它通过一个简单的规则——树干末端分岔为两个子枝——反复迭代,生成出高度自相似的树状结构。这种 L-system(Lindenmayer 系统)的变体在自然界中随处可见:从蕨类植物的叶片到河流的支流网络,再到闪电的路径,都暗含递归分形的规律。

本文从一个实际项目出发,完整展示了在 HarmonyOS Next API 24 环境下,如何使用 @ComponentV2 声明式组件和 CanvasRenderingContext2D 实现一个可交互的递归二叉树分形动画。具体功能包括:

  • 递归二叉树生成:6~14 层深度可调,每层二分岔,偶尔三分岔增加自然感
  • 宽度/颜色随深度渐变:树干深褐粗壮 → 末端嫩绿纤细,HSL 色相逐步偏移
  • 风场摇曳正弦波动画:基于高度 y 和时间 t 的正弦波叠加到每个枝条角度
  • 末端开花粒子:每个枝梢绘制 5 瓣花朵 + 多层光晕 + 花蕊脉动
  • 飘落花瓣系统:30 个独立粒子带旋转和风力漂移
  • 实时交互控制:5 个 Slider + 2 个 Toggle 调节全部参数

本文适合有一定 ArkTS 基础、想在 Canvas 动画方向深入实践的读者。读完你将掌握递归分形的数学建模、确定性伪随机在动画中的运用、HSL 色彩插值、粒子系统设计等核心技能。


二、API 24 特性概览

项目基于 targetSdkVersion: "6.1.1(24)" 构建,使用了以下 API 24 的标志性能力:

2.1 @ComponentV2 与 @Local 装饰器

API 24 引入了 @ComponentV2 作为第二代组件装饰器,相比 @Component 的主要改进包括:

  • 显式响应式状态:使用 @Local 标记需要驱动 UI 重新渲染的成员变量,而非常量的 getter/setter 代理
  • 更轻量的运行时@ComponentV2 编译后生成的代理代码更少,对性能敏感场景(如 Canvas 动画)更友好
  • 更好的 TypeScript 兼容:装饰器语法与标准 TS 更接近,减少特殊语法学习成本

本项目中的核心参数全部使用 @Local 装饰:

@Local recursionDepth: number = 10;
@Local branchAngle: number = 28;
@Local lengthRatio: number = 0.72;
@Local windStrength: number = 0.4;
@Local hueBase: number = 130;
@Local showLeaves: boolean = true;
@Local showWind: boolean = true;

@Local 的最大优势在于:Slider 的 onChang 回调中修改这些变量后,Canvas 不会自动重绘——Canvas 是命令式绘制,不依赖声明式数据绑定驱动的重渲染。这反而给了我们完全控制权:只在 drawTree() 中根据最新值绘制,用 setTimeout 维持稳定的帧率循环。

2.2 CanvasRenderingContext2D

API 24 的 Canvas2D 接口与 W3C 标准高度一致,支持:

API 用途 本文用法
createRadialGradient 径向渐变 背景天空 + 花朵光晕
arc 圆弧路径 花瓣、花蕊、光晕
lineTo / moveTo 线段路径 所有枝条绘制
save / restore 状态栈 飘落花瓣的变换隔离
translate / rotate / scale 坐标变换 花瓣旋转和拉伸
setLineDash 虚线样式 地面参考线
clearRect 清空画布 每帧全量重绘

2.3 Slider 与 Toggle

API 24 的 SliderToggle 在视觉上做了 HarmonyOS Design 风格适配,代码中通过 blockColor / selectedColor / trackColor 实现色彩联动——滑块颜色随 hueBase 动态变化,形成统一的视觉主题。


三、完整代码清单

本节给出 TreeFractalPage.etsIndex.ets 的完整代码。两个文件加起来约 560 行,覆盖整个应用的全部逻辑。建议先通读一遍,后续各章节将针对关键片段做深入拆解。

3.1 TreeFractalPage.ets — 核心组件

/**
 * 递归二叉树分形 - 随机分形树,递归绘制,风场摇曳,末端开花
 * ============================================
 * 功能: 递归二叉树 + 随机分形,树枝宽度/颜色随深度渐变,
 *       末端开花粒子,风场摇曳正弦波动画,Canvas Path 绘制
 *
 * 技术栈: @ComponentV2 + CanvasRenderingContext2D + setTimeout ~60fps
 *
 * Index.ets 挂载方式:
 *   import { TreeFractalPage } from './TreeFractalPage';
 *   Stack() { TreeFractalPage() }
 *
 * @since API 24
 */

// ===== 分形末端记录 =====
interface BranchTip {
  x: number;
  y: number;
  depth: number;
  parentAngle: number;
}

// ===== 飘落花瓣粒子 =====
interface FallenPetal {
  x: number;
  y: number;
  size: number;
  speedX: number;
  speedY: number;
  rotation: number;
  rotSpeed: number;
  alpha: number;
  hue: number;
}

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

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

  // ---- 可调参数 ----
  @Local recursionDepth: number = 10;     // 递归深度 6~14
  @Local branchAngle: number = 28;        // 分支展开角度 10~50°
  @Local lengthRatio: number = 0.72;      // 每层长度衰减 0.50~0.85
  @Local windStrength: number = 0.4;      // 风力强度 0~1.0
  @Local hueBase: number = 130;           // 色相 (绿:120, 秋:30, 粉:320)
  @Local showLeaves: boolean = true;
  @Local showWind: boolean = true;

  // ---- 数据存储 ----
  private tips: BranchTip[] = [];
  private fallenPetals: FallenPetal[] = [];

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

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

  // ---- 飘落花瓣初始化 ----
  private initPetals(): void {
    this.fallenPetals = [];
    for (let i = 0; i < 30; i++) {
      this.fallenPetals.push(this.createRandomPetal());
    }
  }

  private createRandomPetal(): FallenPetal {
    return {
      x: Math.random() * 600 - 300,
      y: Math.random() * 200 - 100,
      size: 2 + Math.random() * 4,
      speedX: -0.3 + Math.random() * 0.6,
      speedY: 0.2 + Math.random() * 0.5,
      rotation: Math.random() * Math.PI * 2,
      rotSpeed: -0.03 + Math.random() * 0.06,
      alpha: 0.3 + Math.random() * 0.5,
      hue: this.hueBase + 30 + Math.random() * 40
    };
  }

  // ---- 动画循环 ----
  private startAnimation(): void {
    const animate = () => {
      this.time += 0.025;
      this.drawTree();
      this.animationId = setTimeout(animate, 16);
    };
    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 seededRandom(seed: number): number {
    const x = Math.sin(seed * 127.1 + 311.7) * 43758.5453123;
    return x - Math.floor(x);
  }

  // ===================== 主绘制 =====================

  private drawTree(): void {
    const ctx = this.canvasContext;
    const w = ctx.width;
    const h = ctx.height;
    if (w <= 0 || h <= 0) return;

    // ---- 清空 + 背景 ----
    ctx.clearRect(0, 0, w, h);

    const bgGrad = ctx.createRadialGradient(w / 2, h * 0.3, 0, w / 2, h * 0.3, h * 0.8);
    bgGrad.addColorStop(0, this.hsl(this.hueBase, 40, 18, 1));
    bgGrad.addColorStop(0.5, this.hsl(this.hueBase, 30, 10, 1));
    bgGrad.addColorStop(1, this.hsl(this.hueBase, 20, 4, 1));
    ctx.fillStyle = bgGrad;
    ctx.fillRect(0, 0, w, h);

    // ---- 地面参考线 ----
    const groundY = h * 0.92;
    ctx.strokeStyle = this.hsl(this.hueBase, 20, 15, 0.25);
    ctx.lineWidth = 1;
    ctx.setLineDash([4, 8]);
    ctx.beginPath();
    ctx.moveTo(0, groundY);
    ctx.lineTo(w, groundY);
    ctx.stroke();
    ctx.setLineDash([]);

    // ---- 重置末端列表 ----
    this.tips = [];

    // ---- 树干参数 ----
    const trunkLength = h * 0.22;
    const startX = w / 2;
    const startY = groundY;
    const baseWidth = 14;

    // ---- 递归绘制分形树 ----
    this.drawBranch(ctx, startX, startY, trunkLength, -Math.PI / 2, 0, this.recursionDepth, baseWidth);

    // ---- 在末端绘制花朵/粒子 ----
    if (this.showLeaves) {
      for (const tip of this.tips) {
        this.drawFlower(ctx, tip.x, tip.y, tip.depth, tip.parentAngle);
      }
    }

    // ---- 飘落花瓣 ----
    this.drawFallenPetals(ctx, w, h);
  }

  // ---- 递归绘制枝条 ----
  private drawBranch(
    ctx: CanvasRenderingContext2D,
    x: number, y: number,
    length: number, angle: number,
    depth: number, maxDepth: number,
    width: number
  ): void {
    // 终止条件
    if (depth > maxDepth || length < 2 || width < 0.3) {
      this.tips.push({ x, y, depth, parentAngle: angle });
      return;
    }

    const depthRatio = depth / maxDepth;

    // ---- 风场偏移 (正弦波随高度变化) ----
    let windAngle = 0;
    if (this.showWind) {
      const windOffset = this.windStrength * 0.1 * Math.sin(this.time * 1.2 + y * 0.015);
      windAngle = windOffset;
    }

    const finalAngle = angle + windAngle;
    const endX = x + Math.cos(finalAngle) * length;
    const endY = y + Math.sin(finalAngle) * length;

    // ---- 颜色: 树干棕 → 枝叶绿/彩色 ----
    const hue = this.hueBase - 30 + depthRatio * 50;
    const lightness = 18 + depthRatio * 35;
    const alpha = 0.7 + 0.3 * (1 - depthRatio);

    // ---- 宽度随深度递减 ----
    const drawWidth = Math.max(width * (1 - depthRatio * 0.55), 0.5);

    // ---- Canvas Path 绘制枝条 ----
    ctx.beginPath();
    ctx.moveTo(x, y);
    ctx.lineTo(endX, endY);
    ctx.strokeStyle = this.hsl(hue, 55 + depthRatio * 20, lightness, alpha);
    ctx.lineWidth = drawWidth;
    ctx.lineCap = 'round' ;
    ctx.stroke();

    // ---- 分岔: 确定性伪随机 (保证树形每帧稳定) ----
    const seedBase = depth * 1000 + x * 0.5 + y * 0.3;
    const rand1 = this.seededRandom(seedBase);
    const rand2 = this.seededRandom(seedBase + 100);
    const rand3 = this.seededRandom(seedBase + 200);

    const spreadAngle = this.branchAngle * (0.75 + 0.25 * rand1);
    const shrinkFactor = this.lengthRatio * (0.85 + 0.15 * rand2);
    const widthShrink = 0.6 + 0.1 * rand3;

    const nextLength = length * shrinkFactor;
    const nextWidth = Math.max(width * widthShrink, 0.3);

    // 左分支
    this.drawBranch(ctx, endX, endY, nextLength, finalAngle - spreadAngle * Math.PI / 180,
      depth + 1, maxDepth, nextWidth);

    // 右分支
    this.drawBranch(ctx, endX, endY, nextLength, finalAngle + spreadAngle * Math.PI / 180,
      depth + 1, maxDepth, nextWidth);

    // 偶尔额外分支 (增加自然感)
    if (depth < maxDepth - 3 && rand3 > 0.6) {
      const midLen = nextLength * 0.55;
      const midW = nextWidth * 0.5;
      const midAngleOffset = (rand2 - 0.5) * spreadAngle * 0.6 * Math.PI / 180;
      this.drawBranch(ctx, endX, endY, midLen, finalAngle + midAngleOffset,
        depth + 2, maxDepth, midW);
    }
  }

  // ---- 末端开花 ----
  private drawFlower(ctx: CanvasRenderingContext2D, x: number, y: number, depth: number, angle: number): void {
    const depthRatio = depth / this.recursionDepth;
    const size = 2.5 + depthRatio * 5;

    // 花朵基部色相: 偏向粉/红/黄系
    const hue = this.hueBase + 50 + depthRatio * 30 + Math.sin(depth * 2.5) * 15;

    // ---- 外层光晕 (多层径向渐变) ----
    for (let r = 0; r < 3; r++) {
      const glowR = size * (1.5 + r * 1.2);
      const grad = ctx.createRadialGradient(x, y, 0, x, y, glowR);
      grad.addColorStop(0, this.hsl(hue + r * 25, 90, 80, 0.5 - r * 0.12));
      grad.addColorStop(0.5, this.hsl(hue + r * 25, 80, 60, 0.2 - r * 0.06));
      grad.addColorStop(1, this.hsl(hue + r * 25, 70, 40, 0));
      ctx.fillStyle = grad;
      ctx.beginPath();
      ctx.arc(x, y, glowR, 0, Math.PI * 2);
      ctx.fill();
    }

    // ---- 5 片花瓣 (使用偏移圆模拟) ----
    const petalCount = 5;
    for (let i = 0; i < petalCount; i++) {
      const pAngle = angle + (i / petalCount) * Math.PI * 2 + this.time * 0.08;
      const petalR = size * 1.0;
      const px = x + Math.cos(pAngle) * petalR;
      const py = y + Math.sin(pAngle) * petalR;

      ctx.beginPath();
      ctx.arc(px, py, size * 0.45, 0, Math.PI * 2);
      ctx.fillStyle = this.hsl(hue + i * 12, 85, 65 + Math.sin(this.time + i) * 8, 0.65);
      ctx.fill();
    }

    // ---- 花蕊 (中心亮点) ----
    const stamenSize = size * 0.3 + 1 * Math.sin(this.time * 2 + depth);
    ctx.beginPath();
    ctx.arc(x, y, stamenSize, 0, Math.PI * 2);
    ctx.fillStyle = this.hsl(50, 95, 88, 0.9);
    ctx.fill();

    // ---- 花蕊外圈细点 ----
    for (let i = 0; i < 6; i++) {
      const sa = this.time * 0.5 + i * Math.PI / 3;
      const sx = x + Math.cos(sa) * stamenSize * 1.5;
      const sy = y + Math.sin(sa) * stamenSize * 1.5;
      ctx.beginPath();
      ctx.arc(sx, sy, 1, 0, Math.PI * 2);
      ctx.fillStyle = this.hsl(40 + i * 10, 90, 80, 0.6);
      ctx.fill();
    }
  }

  // ---- 飘落花瓣 (独立粒子系统) ----
  private drawFallenPetals(ctx: CanvasRenderingContext2D, w: number, h: number): void {
    if (!this.showLeaves || !this.showWind) return;

    const groundY = h * 0.92;
    const cx = w / 2;

    for (let i = 0; i < this.fallenPetals.length; i++) {
      const p = this.fallenPetals[i];

      // 更新位置 (相对树干中心漂移)
      const windDrift = this.windStrength * 0.3 * Math.sin(this.time * 0.8 + i * 0.5);
      p.x += p.speedX + windDrift;
      p.y += p.speedY;
      p.rotation += p.rotSpeed;

      // 边界循环
      const absX = cx + p.x;
      const absY = groundY - 20 + p.y;

      // 超出画布则重置
      if (p.y > 300 || absX < -50 || absX > w + 50) {
        this.fallenPetals[i] = this.createRandomPetal();
        this.fallenPetals[i].y = -100;
        this.fallenPetals[i].x = (Math.random() - 0.5) * w * 0.8;
        continue;
      }

      const drawX = cx + p.x;
      const drawY = groundY - 20 + p.y;

      // 花瓣旋转效果: 画椭圆或旋转的圆
      ctx.save();
      ctx.translate(drawX, drawY);
      ctx.rotate(p.rotation);

      // 花瓣形状 (拉伸的椭圆)
      ctx.beginPath();
      ctx.arc(0, 0, p.size * 0.4, 0, Math.PI * 2);
      ctx.scale(1.8, 1);
      ctx.beginPath();
      ctx.arc(0, 0, p.size * 0.4, 0, Math.PI * 2);
      ctx.fillStyle = this.hsl(p.hue, 80, 65, p.alpha * (0.5 + 0.5 * Math.sin(this.time + i)));
      ctx.fill();

      ctx.restore();
    }
  }

  // ===================== 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.recursionDepth}层递归 · ${this.branchAngle}°展开`)
        .fontSize(13)
        .fontColor(this.hsl(this.hueBase, 70, 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.recursionDepth,
            min: 6, max: 14, step: 1
          })
          .layoutWeight(1)
          .trackThickness(4)
          .blockColor(this.hsl(this.hueBase, 70, 65, 1))
          .trackColor('#FFFFFF30')
          .selectedColor(this.hsl(this.hueBase, 70, 65, 1))
          .onChange((val: number) => { this.recursionDepth = val; })

          Text(`${this.recursionDepth}`)
            .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.branchAngle,
            min: 10, max: 50, step: 1
          })
          .layoutWeight(1)
          .trackThickness(4)
          .blockColor(this.hsl(this.hueBase, 70, 65, 1))
          .trackColor('#FFFFFF30')
          .selectedColor(this.hsl(this.hueBase, 70, 65, 1))
          .onChange((val: number) => { this.branchAngle = val; })

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

        // 行3: 长度衰减
        Row({ space: 10 }) {
          Text('衰减')
            .fontSize(12)
            .fontColor('#999999')
            .width(40)

          Slider({
            value: this.lengthRatio,
            min: 0.5, max: 0.85, step: 0.01
          })
          .layoutWeight(1)
          .trackThickness(4)
          .blockColor(this.hsl(this.hueBase, 70, 65, 1))
          .trackColor('#FFFFFF30')
          .selectedColor(this.hsl(this.hueBase, 70, 65, 1))
          .onChange((val: number) => { this.lengthRatio = val; })

          Text(`${this.lengthRatio.toFixed(2)}`)
            .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.windStrength,
            min: 0, max: 1.0, step: 0.05
          })
          .layoutWeight(1)
          .trackThickness(4)
          .blockColor(this.hsl(this.hueBase, 70, 65, 1))
          .trackColor('#FFFFFF30')
          .selectedColor(this.hsl(this.hueBase, 70, 65, 1))
          .onChange((val: number) => { this.windStrength = val; })

          Text(`${this.windStrength.toFixed(2)}`)
            .fontSize(12)
            .fontColor('#FFFFFF')
            .width(30)
            .textAlign(TextAlign.End)
        }
        .width('100%')

        // 行5: 色相
        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, 70, 65, 1))
          .trackColor('#FFFFFF30')
          .selectedColor(this.hsl(this.hueBase, 70, 65, 1))
          .onChange((val: number) => { this.hueBase = val; })

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

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

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

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

            Toggle({ type: ToggleType.Switch, isOn: this.showWind })
              .selectedColor(this.hsl(this.hueBase, 70, 65, 1))
              .switchPointColor('#FFFFFF')
              .onChange((isOn: boolean) => { this.showWind = 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(this.hsl(this.hueBase, 30, 4, 1))
  }
}

3.2 Index.ets — 挂载入口

/**
 * 应用主入口
 * 仅做页面挂载,代码逻辑在各独立文件中
 */

// 切换注释即可换页:
import { TreeFractalPage } from './TreeFractalPage';
// import { KaleidoscopePage } from './KaleidoscopePage';

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

Index.ets 的职责极其简单——仅做路由挂载。将不同页面的切换简化为一行 import 注释的切换,适合多应用 Demo 的项目结构。


四、架构设计精析

4.1 组件化层级

整个页面采用单组件直出模式:IndexTreeFractalPage。TreeFractalPage 内部没有嵌套子组件,因为 Canvas 动画的所有绘制操作都在同一上下文中完成。这种扁平结构避免了组件树深度对 60fps 渲染的干扰。

组件内部的职责划分:

TreeFractalPage
├── 状态层 (@Local)
│   ├── time          — 动画时间 (驱动所有动态行为)
│   ├── recursionDepth — 递归深度
│   ├── branchAngle    — 分支角度
│   ├── lengthRatio    — 长度衰减率
│   ├── windStrength   — 风强度
│   ├── hueBase        — 基色相
│   ├── showLeaves     — 显示花叶
│   └── showWind       — 显示风场
├── 数据层 (private)
│   ├── tips[]         — 枝梢位置列表 (每帧重建)
│   └── fallenPetals[] — 飘落花瓣粒子 (持久)
├── 绘制层
│   ├── drawTree()     — 主绘制入口
│   ├── drawBranch()   — 递归枝条绘制
│   ├── drawFlower()   — 末端开花
│   └── drawFallenPetals() — 飘落花瓣
├── 动画层
│   ├── startAnimation()  — setTimeout 驱动
│   └── stopAnimation()   — clearTimeout 清理
└── 视图层 (build)
    ├── 标题区域
    ├── Canvas 画布
    └── 控制面板 (5×Slider + 2×Toggle)

4.2 生命周期管理

与万花筒类似,生命周期管理是 Canvas 动画的关键:

aboutToAppear(): void {
  this.initPetals();    // 1. 初始化粒子
  this.startAnimation(); // 2. 启动动画
}

aboutToDisappear(): void {
  this.stopAnimation();  // 清理定时器
}

两个要点:

  • aboutToAppear 在组件首次创建时调用,此时 Canvas 上下文已经可用,可以安全地启动绘制循环
  • aboutToDisappear 在组件被销毁时调用,必须清理 setTimeout 句柄,否则组件已销毁但定时器仍在执行,会导致不可预测的错误

4.3 渲染管道

每帧的渲染流程是线性的:

startAnimation → time += 0.025
                    ↓
               drawTree()
                    ↓
          1. clearRect 清空画布
          2. 绘制背景径向渐变
          3. 绘制地面虚线
          4. tips = [] 重置
          5. drawBranch(树干) ← 递归展开
               ├── 风场偏移计算
               ├── lineTo 绘制枝条
               └── 递归左/右/中分支
          6. 遍历 tips[] → drawFlower 每朵
          7. drawFallenPetals 粒子系统
                    ↓
           setTimeout(animate, 16) → 下一帧

这种每帧全量重绘的模式是 Canvas 动画的标配。虽然看起来"每次都在重复工作",但 Canvas 本身是无状态位图缓冲区,只有重绘才能实现动态效果。现代 GPU 硬件加速下,绘制几百条线段和几十个圆形开销极小,完全能维持 60fps。


五、递归二叉树的数学原理

5.1 分形树的定义

一棵递归二叉树可以用如下规则定义:

初始状态:有一个树干,从底部 (cx, groundY) 向上生长到 (cx, groundY - trunkLength)

递归规则:对于每个枝条,在其末端分岔为两个子枝:

  • 左枝:角度 = 父角度 - 展开角 × 随机因子
  • 右枝:角度 = 父角度 + 展开角 × 随机因子
  • 长度 = 父长度 × 衰减率 × 随机因子

终止条件:当递归深度超过最大深度,或长度/宽度小于阈值时停止。

这个规则可以数学表达为:

B₀ = (startPos, -π/2, trunkLength, baseWidth)  // 初始枝

B(d+1) = B(d) 的末端分岔:
  left_child(B)  = ( end(B), angle(B) - θ₁, len(B) × r₁, w(B) × r₂ )
  right_child(B) = ( end(B), angle(B) + θ₁, len(B) × r₁, w(B) × r₂ )

其中 θ₁ = branchAngle × (0.75 + 0.25 × rand₁),r₁ = lengthRatio × (0.85 + 0.15 × rand₂),r₂ = 0.6 + 0.1 × rand₃,每个 rand 值由 seededRandom 从 depth+坐标计算的种子生成。

5.2 端点计算

给定起点 (x, y)、长度 length 和角度 angle,终点坐标由三角函数确定:

const endX = x + Math.cos(angle) * length;
const endY = y + Math.sin(angle) * length;

角度单位为弧度。垂直向上为 -π/2(因为 Canvas Y 轴向下为正)。每次递归时,左右分支的角度在父角度基础上分别加减 spreadAngle。

5.3 递归终止条件的三重保护

if (depth > maxDepth || length < 2 || width < 0.3) {
  this.tips.push({ x, y, depth, parentAngle: angle });
  return;
}
  • depth > maxDepth:主条件,用户设定的递归层级
  • length < 2:当枝条缩短到 2px 以下时停止(物理极限,再短也看不见了)
  • width < 0.3:当宽度收缩到 0.3px 以下时停止(渲染无意义)

5.4 分形复杂度分析

递归二叉树的枝条总数是 O(2^depth) 量级:

深度 枝条数约 视觉效果
6 2^6 = 64 稀疏,每枝清晰可见
8 256 适中,有层次感
10 1024 丰富,产生树冠效果
12 4096 密集,接近真实树形
14 16384 极密,可能影响帧率

实际上由于每条枝在末端可能触发额外分支(三分岔),实际数量比理论值多约 5~10%。在 14 层深度下,单帧需要绘制约 1.7 万条线段,这正好是 Canvas 性能的分水岭——超过 2 万条线段时可能掉帧。


六、随机分形机制

6.1 为什么需要随机性?

严格对称的二叉树看起来像计算机生成的线路图,缺乏自然感。真实树木的枝条并非完全对称,每一对分支的角度和长度都有微小的随机差异。引入随机性后,树形会呈现出"有机感"——有的枝长一些,有的分叉宽一些。

6.2 确定性伪随机 (seededRandom)

但这里有一个关键问题:如果每帧都使用 Math.random(),树形会在每帧剧烈抖动,因为随机值不断变化。解决方案是使用确定性伪随机函数——给定相同的种子,返回相同的"随机"值。

private seededRandom(seed: number): number {
  const x = Math.sin(seed * 127.1 + 311.7) * 43758.5453123;
  return x - Math.floor(x);
}

这个函数的原理:

  • 输入一个种子数(seed)
  • 通过线性变换 seed * 127.1 + 311.7 将种子分散到相位空间
  • Math.sin() 的输出在 [-1, 1] 之间振荡,乘以大质数 43758.5453123 后的小数部分呈现出均匀的伪随机分布
  • x - Math.floor(x) 取小数部分,结果落在 [0, 1)

种子由 depth 和坐标共同决定:

const seedBase = depth * 1000 + x * 0.5 + y * 0.3;

这样只要树的结构不变(depth/x/y 不变),随机值就不变,树形稳定。而风场动画是叠加在角度上的正弦波,不影响种子计算。

6.3 三个随机维度的应用

const rand1 = this.seededRandom(seedBase);
const rand2 = this.seededRandom(seedBase + 100);
const rand3 = this.seededRandom(seedBase + 200);
随机变量 影响对象 范围 效果
rand1 spreadAngle branchAngle × (0.75~1.00) 分岔角不一致
rand2 shrinkFactor lengthRatio × (0.85~1.00) 一枝条偏长,另一偏短
rand3 widthShrink + 额外分支 0.6~0.7 × 宽度 粗细不一 + 偶尔三分岔

三个随机值使用不同的种子偏移(+0, +100, +200),确保它们在统计上彼此独立。

6.4 三分岔的时机

为了进一步增加自然感,代码在深度小于 maxDepth - 3rand3 > 0.6(约 40% 概率)时,会生成一个中间分支:

if (depth < maxDepth - 3 && rand3 > 0.6) {
  const midLen = nextLength * 0.55;
  const midW = nextWidth * 0.5;
  const midAngleOffset = (rand2 - 0.5) * spreadAngle * 0.6 * Math.PI / 180;
  this.drawBranch(ctx, endX, endY, midLen, finalAngle + midAngleOffset,
    depth + 2, maxDepth, midW);
}

这个额外分支的方向在左右之间随机偏移,长度为主枝的 55%,宽度为主枝的 50%。它在末端 2 层后分岔(depth + 2),因此不会破坏整体的递归结构。


七、宽度与颜色渐变系统

7.1 depthRatio — 归一化深度

深度比率 depthRatio = depth / maxDepth 是渐变系统的核心控制变量:

  • depth = 0 时,depthRatio = 0(树干)
  • depth = maxDepth 时,depthRatio = 1(末端)

所有渐变参数都以 depthRatio 为自变量进行线性或非线性插值。

7.2 宽度衰减

const drawWidth = Math.max(width * (1 - depthRatio * 0.55), 0.5);

树干基宽度 baseWidth = 14px,每递归一层,宽度乘以 widthShrink = 0.6 + 0.1 * rand3(约 0.6~0.7 倍),并额外应用 depthRatio 的全局衰减系数 (1 - depthRatio * 0.55)

这个全局系数确保从树干到末端的宽度平滑递减,而不会因为随机收缩因子产生"树干上的某个枝突然变细"的不自然感。

7.3 HSL 色彩插值

颜色渐变使用 HSL 色彩空间的三分量插值:

// 色相: 树干偏棕(hue-30) → 枝叶偏绿/彩色(hue+20)
const hue = this.hueBase - 30 + depthRatio * 50;

// 明度: 树干暗(18%) → 枝叶亮(53%)
const lightness = 18 + depthRatio * 35;

// 饱和度: 树干稍低(55%) → 枝叶鲜艳(75%)
// 在 lineWidth 调用处: 55 + depthRatio * 20

// 透明度: 树干近处(1.0) → 枝叶稍淡(0.7)
// 在 lineWidth 调用处: 0.7 + 0.3 * (1 - depthRatio)

色彩渐变路线示意(hueBase = 120 绿色主题):

深度 depthRatio 色相 明度 饱和度 视觉效果
0 (树干) 0.0 90 (黄绿) 18% 55% 深褐绿色树干
3 0.3 105 28% 61% 转绿
6 0.6 120 (纯绿) 39% 67% 健康绿叶
10 1.0 140 (青绿) 53% 75% 嫩绿新芽

当 hueBase 设为 30(秋色)时,渐变路线变为:

depthRatio 色相 视觉效果
0.0 0 (红棕) 深秋树干
0.5 55 (黄) 金黄色枝叶
1.0 80 (黄绿) 枯黄末梢

HSL 相比于 RGB 的优势在此凸显——只需调整 H 和 L 两个分量,就能产生自然且和谐的渐变色带。


八、末端开花粒子

8.1 花朵的结构

每朵花由三个层次组成:

  1. 外层光晕:3 层半径递增的径向渐变圆,从半透明渐变到全透明
  2. 5 片花瓣:均匀分布在 360° 上,每片是一个偏移的小圆
  3. 花蕊:中心脉动的亮圆点 + 6 个围绕的蕊丝细点

8.2 光晕绘制

for (let r = 0; r < 3; r++) {
  const glowR = size * (1.5 + r * 1.2);
  const grad = ctx.createRadialGradient(x, y, 0, x, y, glowR);
  grad.addColorStop(0, this.hsl(hue + r * 25, 90, 80, 0.5 - r * 0.12));
  grad.addColorStop(0.5, this.hsl(hue + r * 25, 80, 60, 0.2 - r * 0.06));
  grad.addColorStop(1, this.hsl(hue + r * 25, 70, 40, 0));
  ctx.fillStyle = grad;
  ctx.beginPath();
  ctx.arc(x, y, glowR, 0, Math.PI * 2);
  ctx.fill();
}

三层光晕的参数关系:

层数 r 半径倍率 中心透明度 边缘透明度
0 1.5x 0.50 0
1 2.7x 0.38 0
2 3.9x 0.26 0

每层色相偏移 +25°,产生从中心到外围的色散效果——类似真实花朵中花瓣基部和尖端的颜色差异。

8.3 花瓣布局

花瓣使用"偏移圆"技术:不画复杂的贝塞尔曲线花瓣形状,而是画 5 个小圆均匀分布在枝梢周围的圆周上:

const petalCount = 5;
for (let i = 0; i < petalCount; i++) {
  const pAngle = angle + (i / petalCount) * Math.PI * 2 + this.time * 0.08;
  const petalR = size * 1.0;
  const px = x + Math.cos(pAngle) * petalR;
  const py = y + Math.sin(pAngle) * petalR;
  ctx.beginPath();
  ctx.arc(px, py, size * 0.45, 0, Math.PI * 2);
  ctx.fillStyle = this.hsl(hue + i * 12, 85, 65 + Math.sin(this.time + i) * 8, 0.65);
  ctx.fill();
}

花瓣的明度随时间正弦脉动 Math.sin(this.time + i) * 8,每片花的脉动相位不同,形成此起彼伏的"呼吸"效果。this.time * 0.08 让整朵花缓慢旋转。

8.4 花朵大小与深度关系

const size = 2.5 + depthRatio * 5;
const hue = this.hueBase + 50 + depthRatio * 30 + Math.sin(depth * 2.5) * 15;

末端越深(越接近树冠外层),花朵越大、色相偏移越多。Math.sin(depth * 2.5) * 15 为不同深度的花增加 15° 的色相波动,使树冠呈现色彩渐变带而非单一色调。

8.5 花蕊

花蕊有两个部分:

  • 中心亮点:半径 size * 0.3 + Math.sin(this.time * 2 + depth),用固定黄色高亮 (hsl 50/95/88)
  • 蕊丝细点:6 个细小圆点以 60° 间隔围绕中心,颜色渐变 hsl(40 + i*10, 90, 80)

蕊丝围绕中心缓慢旋转 this.time * 0.5,为静态花朵增加微妙的动态细节。


九、风场摇曳动画

9.1 正弦波叠加原理

风场效果的核心公式:

const windOffset = this.windStrength * 0.1 * Math.sin(this.time * 1.2 + y * 0.015);
windAngle = windOffset;

解释:

  • this.time * 1.2:时间维度的正弦振荡,频率 1.2 rad/帧 ≈ 19 个完整周期/秒
  • y * 0.015:空间维度的相位偏移,高度越高(y 值越小),相位偏移的累积量不同
  • this.windStrength * 0.1:幅度控制,最大偏移约 0.1 rad ≈ 5.7°

9.2 为什么风对树梢影响更大?

这个公式巧妙之处在于 y * 0.015 一项。树梢的 y 值更小(更靠近画布顶部),y * 0.015 的值也较小,导致树梢的相位与树干不同。同时因为树梢的枝条更细更长,同样角度的偏移在树梢末端产生的空间位移更大。

真实物理中,风对树梢的影响确实大于树干——树干粗壮抗弯刚度大,而细枝容易随风摆动。看似简单的正弦公式,在视觉上很好地模拟了这种物理差异。

9.3 飘落花瓣的风力漂移

飘落花瓣受风的影响更大:

const windDrift = this.windStrength * 0.3 * Math.sin(this.time * 0.8 + i * 0.5);
p.x += p.speedX + windDrift;

这里的风力系数 0.3 是枝条的 3 倍,且频率 0.8 更慢,产生更柔和的漂移轨迹。i * 0.5 为每个花瓣分配不同相位,使它们不会同时朝同一方向飘动。

9.4 风场关断的完整回退

当用户关闭风场(showWind = false)时:

  • drawBranch 中的 windAngle 保持为 0,枝条按静态角度绘制
  • drawFallenPetals 直接 return,飘落花瓣完全不绘制
  • 但花瓣的 speedX/speedY 仍在累加——下次打开风场时,花瓣已经移动到了新的位置

十、飘落花瓣粒子系统

10.1 粒子属性

每个 FallenPetal 接口定义了 9 个属性:

interface FallenPetal {
  x: number;        // 水平偏移 (相对树干中心)
  y: number;        // 垂直偏移 (相对地面)
  size: number;     // 花瓣大小 2~6px
  speedX: number;   // 水平速度 -0.3~0.3
  speedY: number;   // 下落速度 0.2~0.7
  rotation: number; // 当前旋转角
  rotSpeed: number; // 旋转速度 -0.03~0.03
  alpha: number;    // 透明度 0.3~0.8
  hue: number;      // 色相
}

10.2 粒子更新循环

每帧对每个花瓣做:

// 1. 风力漂移叠加到速度上
const windDrift = this.windStrength * 0.3 * Math.sin(this.time * 0.8 + i * 0.5);
p.x += p.speedX + windDrift;
p.y += p.speedY;

// 2. 旋转累积
p.rotation += p.rotSpeed;

10.3 边界重置

if (p.y > 300 || absX < -50 || absX > w + 50) {
  this.fallenPetals[i] = this.createRandomPetal();
  this.fallenPetals[i].y = -100;  // 从顶部重新入场
  this.fallenPetals[i].x = (Math.random() - 0.5) * w * 0.8;
  continue;
}

花瓣移出画布后,在画布上方重新生成新的花瓣,形成循环飘落的视觉效果。这里使用 Math.random() 重置位置是可以的,因为重置后花瓣从顶部进入,每帧的初始位置不同,不会产生"所有花瓣同步重置"的突兀感。

10.4 旋转拉伸绘制

ctx.save();
ctx.translate(drawX, drawY);
ctx.rotate(p.rotation);
ctx.beginPath();
ctx.arc(0, 0, p.size * 0.4, 0, Math.PI * 2);
ctx.scale(1.8, 1);           // X 方向拉伸 1.8 倍 → 椭圆
ctx.beginPath();             // 缩放后重新画弧
ctx.arc(0, 0, p.size * 0.4, 0, Math.PI * 2);
ctx.fillStyle = this.hsl(p.hue, 80, 65, p.alpha * (0.5 + 0.5 * Math.sin(this.time + i)));
ctx.fill();
ctx.restore();

这里有个小技巧:ctx.save() 保存当前变换矩阵,translate + rotate 将坐标系移至花瓣位置并旋转,scale(1.8, 1) 将圆在 X 方向拉伸 1.8 倍形成椭圆——模拟花瓣的扁平形状。save/restore 确保变换不影响后续绘制。


十一、性能优化

11.1 提前退出检查

Canvas 绘制的第一行代码是边界检查:

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

这在组件初始化和布局未完成时避免了无效绘制。当 Canvas 可见尺寸为 0 时,任何绘制操作都是无意义的。

11.2 确定性伪随机摊销

seededRandom 使用 Math.sin 和乘法取小数,单次调用耗时不高于 0.1μs。在最坏情况(14 层深度,约 1.7 万次递归调用)下,总共约 1.7ms——完全在 16ms 的帧预算内。

11.3 setTimeout vs requestAnimationFrame

HarmonyOS Canvas 目前没有标准化的 requestAnimationFrame 接口,因此选择 setTimeout 驱动:

setTimeout(animate, 16);  // 目标 ~60fps

setTimeout 的精度在浏览器中约为 4ms,在 HarmonyOS 设备上实测约 4~8ms。16ms 的超时时间预留了 8~12ms 的绘制窗口,确保即使单帧绘制耗时 8ms,下一帧仍能在 24ms 内完成,帧率维持在 40~60fps。

11.4 深度限制

recursionDepth 的 Slider 范围限制在 6~14,防止用户误拉到 20+ 层导致 O(2^depth) 的爆炸增长。14 层已经是视觉和性能的合理平衡点。

11.5 路径合并

每条枝条只用一个 beginPath + lineTo 绘制,不创建额外的路径对象。HarmonyOS Canvas 的路径状态机是轻量级实现,频繁 beginPath 的开销可以接受。

对于花瓣中的多个圆形,也是每个圆独立 beginPath → arc → fill,因为弧形路径不能批量合并。

11.6 Canvas 尺寸自适应

const w = ctx.width;
const h = ctx.height;

ctx.widthctx.height 由父容器的布局约束决定,自适应屏幕旋转和窗口尺寸变化。所有坐标计算都基于 wh,因此无需额外的适配代码。


十二、可视化参数调优

可调参数表

参数 范围 默认值 视觉效果变化
深度 6~14 10 树冠密度的主控——6 层稀疏分明,14 层密如云团
展开 10°~50° 28° 树冠宽度——小角度呈柱状,大角度呈伞状
衰减 0.50~0.85 0.72 枝条长度——高衰减分形深、树冠大;低衰减分形浅、树冠小
风力 0~1.0 0.4 摇曳幅度——0 静止如画,1.0 剧烈摇摆
色调 0°~360° 130° (青绿) 季节主题——30° 秋色,120° 夏绿,320° 粉色梦幻

设计原则

深度 + 衰减联动调整:衰减大(接近 0.85)时,枝条长度缩短慢,树冠层数更多。此时建议将深度调低 1~2 层,否则枝条末端会非常密集。反之,衰减低(0.5)时,枝条缩短快,深度可调高至 12~14 层。

展开 + 风力互补:展开角度大(>40°)时树冠宽,建议风力调低(<0.3),否则树冠左右摇摆幅度过大,画面失衡。展开角度小(<20°)时树冠窄,风力可适当调高。

色调切换:将 hueBase 从 130 逐步滑向 30(秋季色),可以实时看到树从青绿变为金黄再到棕红,地面的背景色也同步变化——因为背景渐变也使用了 this.hsl(this.hueBase, ...)


十三、扩展思路

13.1 季节自动变化

可以在 drawTree 中让 hueBase 随时间缓慢周期性变化:

this.hueBase = 30 + 100 * (0.5 + 0.5 * Math.sin(this.time * 0.02));

这样树色会在 30(秋)→ 130(青绿)→ 30(秋)之间循环,模拟四季更替。

13.2 触摸交互

可以添加 onTouch 事件,让手指在画布上移动时触发"吹风"效果——手指位置影响风场的强度和方向:

.onTouch((event: TouchEvent) => {
  const touchX = event.touches[0].x;
  const touchRatio = (touchX / w) * 2 - 1;  // -1 ~ 1
  this.windStrength = Math.abs(touchRatio);
  // 风向: touchRatio 正负决定风从左吹还是右吹
  this.windDirection = touchRatio;
})

然后在风场公式中引入 windDirection 偏移角的正负。

13.3 树木生长动画

初始时 recursionDepth = 0,然后随时间缓慢增加:

if (this.recursionDepth < this.targetDepth) {
  this.recursionDepth += 0.05;  // 每帧增加 0.05
}

由于 depth 是整数比较,0.05 的累加意味着约 20 帧(0.3 秒)增加一层,14 层树约 4.2 秒生长完毕。视觉效果上,树从地面"长"到完整的树冠。

13.4 添加果实/萤火虫

在树冠区域内随机生成一些发光的圆点(萤火虫),使用 Canvas 的 shadowBlur 产生发光效果:

ctx.shadowBlur = 15;
ctx.shadowColor = this.hsl(hue, 90, 80, 1);
ctx.beginPath();
ctx.arc(fx, fy, 2, 0, Math.PI * 2);
ctx.fillStyle = this.hsl(hue, 90, 85, 0.8);
ctx.fill();
ctx.shadowBlur = 0;

13.5 多棵树森林

drawTree 中循环调用多次 drawBranch,每次改变起始位置和缩放:

const trees = [
  { x: w * 0.2, scale: 0.6, depth: 7 },
  { x: w * 0.5, scale: 1.0, depth: 10 },
  { x: w * 0.8, scale: 0.7, depth: 8 }
];
for (const t of trees) {
  this.drawBranch(ctx, t.x, groundY, trunkLength * t.scale, -π/2, 0, t.depth, baseWidth * t.scale);
}

注意每棵树使用不同的深度和缩放,形成近大远小、疏密有致的森林效果。


十四、总结

14.1 项目结构

entry/src/main/ets/pages/
├── Index.ets              # 入口,挂载 TreeFractalPage
├── TreeFractalPage.ets    # 递归二叉树分形组件 (核心)
├── KaleidoscopePage.ets    # 万花筒 (可切换)
└── SixStarPage.ets        # 六芒星 (可切换)

14.2 API 24 配置确认

build-profile.json5 中的关键配置:

{
  "products": [{
    "name": "default",
    "targetSdkVersion": "6.1.1(24)",
    "compatibleSdkVersion": "6.1.1(24)",
    "runtimeOS": "HarmonyOS"
  }]
}

14.3 从本项目中可以学到的

  1. 递归分形的数学建模:用极少的代码(一个递归函数)生成高度复杂的自相似结构
  2. 确定性伪随机:seededRandom 的编写和使用场景
  3. HSL 色彩插值:在 Canvas 动画中实现平滑渐变的正确方式
  4. 粒子系统设计:属性定义、更新循环、边界重置、变换绘制
  5. 正弦波动画:时间+空间的二维正弦波叠加,模拟自然风场
  6. 组件生命周期:aboutToAppear/aboutToDisappear 的正确使用模式
  7. Canvas 性能:限制递归深度、提前退出、状态栈管理

实现了从数学到渲染的完整管线,是学习 HarmonyOS Canvas 动画的优质实战案例。


Logo

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

更多推荐