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

摘要:本文从复平面迭代的数学原理出发,完整实现了一个基于 HarmonyOS API 24 的曼德勃罗集交互式渲染器。涵盖逃逸时间算法、HSV 色轮映射、双指缩放/单指拖拽手势、Canvas 像素缓冲区批量渲染、组件生命周期管理等核心技术。


目录

  1. 分形之美:曼德勃罗集的数学本质
  2. 项目架构与目录结构
  3. 逃逸时间算法详解
  4. HSV 色轮着色系统
  5. Canvas 尺寸管理的陷阱与解决方案
  6. 多指触控手势系统
  7. 全码实现逐段解析
  8. 组件生命周期与渲染时序
  9. 性能优化与调优策略
  10. 总结与扩展方向

1. 分形之美:曼德勃罗集的数学本质

1.1 复平面与迭代

曼德勃罗集(Mandelbrot Set)是数学家 Benoit Mandelbrot 在 1980 年发现的最著名的分形之一。它由一个极其简单的迭代公式定义:

z₀ = 0
zₙ₊₁ = zₙ² + c

其中 zc 都是复数。复数的乘法规则是 (a + bi)² = a² - b² + 2abi。判断一个复平面上的点 c 是否属于曼德勃罗集,就看 zₙ 的模长是否会在有限次迭代后发散(即 |zₙ|² > 4)。

1.2 逃逸时间算法

"逃逸时间"算法的核心思想非常简单:

  1. 对复平面上的每个点 c,从 z₀ = 0 开始迭代
  2. 如果在最大迭代次数内 |zₙ|² > 4,则点 逃逸,记录逃逸时的迭代次数
  3. 如果始终没有逃逸,则该点属于集合 内部

迭代次数本身就是一个天然的"高度场"——越靠近集合边界,逃逸所需的迭代次数越密集,从而形成极其丰富的细部结构。

1.3 平滑着色

原始的逃逸时间算法有一个缺陷:迭代次数是离散整数,导致颜色过渡出现明显的"条带"(banding)。为了解决这个问题,我们使用归一化迭代次数(Normalized Iteration Count)技术:

const mag2 = zx * zx + zy * zy;       // |z|²
const logZn = Math.log(mag2) / 2;      // log|z|
const nu = Math.log(logZn / Math.LN2) / Math.LN2;
const smoothIter = iter + 1 - nu;       // 连续值

这个公式将离散的迭代次数 iter 映射为一个连续实数 smoothIter,再映射到 HSV 色相上,就能得到光滑的彩色过渡。


2. 项目架构与目录结构

2.1 HarmonyOS API 24 项目结构

本应用基于 HarmonyOS API 24(HarmonyOS 5.0),使用 @ComponentV2 装饰器声明组件。核心文件结构如下:

entry/src/main/ets/
├── pages/
│   ├── Index.ets              # 入口页,挂载 MandelbrotPage
│   └── MandelbrotPage.ets     # 曼德勃罗集组件(本文核心)
├── common/
│   ├── AsyncDataPage.ets      # 异步数据加载示例
│   ├── CounterPage.ets        # 计数器示例
│   ├── FormPage.ets           # 表单示例
│   └── ListDataPage.ets       # 列表数据示例
└── resources/                 # 资源文件

2.2 组件树与数据流

Index.ets
└── Stack()
    └── MandelbrotPage         ← 全屏渲染
        ├── Text (标题)
        ├── Text (信息栏)
        ├── Canvas (分形画布)   ← onTouch + onAreaChange
        └── Column (控制面板)
            ├── Slider (迭代次数)
            └── Button (重置视角)

数据流遵循 单向绑定 原则:

  • 用户操作 → TouchEvent → 修改 @Local 属性 → 设置 isRenderDirty → 动画循环检测 → 调用 renderMandelbrot → Canvas 绘制

3. 逃逸时间算法详解

3.1 复数运算的实现

在 TypeScript/ArkTS 中没有原生复数类型,我们直接在循环中展开计算:

// z = z² + c 的实数展开
while (zx * zx + zy * zy < 4 && iter < maxIter) {
  const tmp = zx * zx - zy * zy + cx;   // Re(z²) = x² - y²
  zy = 2 * zx * zy + cy;                // Im(z²) = 2xy
  zx = tmp;
  iter++;
}

注意这里用一个 tmp 暂存新的实部,因为 zx 在计算虚部时还需要原始值。

3.2 快速发散检测

|z|² > 4 时,序列必然发散。这是一个充分条件:如果某次迭代后模长超过 2(平方后超过 4),后续迭代将指数级增长,不可能再收敛。

我们使用 zx * zx + zy * zy < 4 作为循环条件,避免了每次计算 Math.sqrt 的开销(模长开方是昂贵的浮点运算)。

3.3 平滑归一化迭代次数

标准化迭代次数公式的核心思想是:将逃逸时的"能量"转换为迭代次数的分数部分。

const mag2 = zx * zx + zy * zy;
const logZn = Math.log(mag2) / 2;          // 相当于 log(sqrt(mag2)) = log(|z|)
const nu = Math.log(logZn / Math.LN2) / Math.LN2;
const smoothIter = iter + 1 - nu;           // 连续化迭代次数

数学上,nu 表示逃逸后的额外"半步数",从 iter 减去后得到平滑的连续值。


4. HSV 色轮着色系统

4.1 为什么选择 HSV

RGB 色彩空间不适合做连续渐变——改变一个颜色分量会破坏色调的感知连续。HSV(色相 Hue、饱和度 Saturation、明度 Value)模型将颜色分解为:

  • Hue(色相):0°~360°,对应色环上的角度(红→橙→黄→绿→蓝→紫→红)
  • Saturation(饱和度):0~1,控制颜色的纯度
  • Value(明度):0~1,控制颜色的亮度

通过将平滑迭代次数线性映射到 Hue,可以得到自然连续的彩虹色过渡。

4.2 HSV → RGB 转换

我们实现了一个标准的 HSV 到 RGB 转换函数:

private hsvToRgb(h: number, s: number, v: number): number[] {
  const hi = Math.floor(h / 60) % 6;         // 色相所在扇区 (0~5)
  const f = h / 60 - Math.floor(h / 60);     // 扇区内偏移
  const p = v * (1 - s);
  const q = v * (1 - f * s);
  const t = v * (1 - (1 - f) * s);

  let r = 0, g = 0, b = 0;
  switch (hi) {
    case 0: r = v; g = t; b = p; break;
    case 1: r = q; g = v; b = p; break;
    case 2: r = p; g = v; b = t; break;
    case 3: r = p; g = q; b = v; break;
    case 4: r = t; g = p; b = v; break;
    case 5: r = v; g = p; b = q; break;
  }
  return [Math.round(r * 255), Math.round(g * 255), Math.round(b * 255)];
}

该算法将色环分为 6 个扇区(每 60° 一个),在每个扇区内做线性插值,确保颜色过渡平滑连续。

4.3 映射策略

实际映射中,我们对色相做了偏移和缩放:

const hue = (smoothIter * 55 + 240) % 360;  // 每轮迭代偏移 55°, 起始色相 240° (蓝)
const sat = 0.85;                             // 固定饱和度
const val = 0.92;                             // 固定明度
  • 55°/iter:控制颜色变化的速率。数值越大,颜色变化越剧烈;数值越小,颜色变化越平缓。
  • 起始 240°:从蓝色开始,覆盖蓝→紫→红→橙→黄→绿→蓝的完整循环。
  • 固定饱和度和明度:使颜色鲜艳明亮,且不引入额外的亮度变化干扰。

5. Canvas 尺寸管理的陷阱与解决方案

5.1 问题的本质

在 HarmonyOS API 24 中,Canvas 的尺寸管理有一个关键特性:CanvasRenderingContext2D.width.height 是只读属性。这与 HTML Canvas 不同——在浏览器中你可以随时设置 canvas.width = 800 来调整缓冲区大小。

这意味着你不能通过 ctx.width = xxx 来设置 Canvas 的缓冲区尺寸。Canvas 的缓冲区尺寸由 HarmonyOS 框架在布局完成后自动设置。

5.2 布局时序问题

一个更容易被忽视的问题是布局时序。当组件首次创建时,aboutToAppear 生命周期方法被调用,但此时 Canvas 组件尚未布局——它的 ctx.width 返回的是默认值(通常为 300,高度为 150,与 HTML Canvas 的默认行为一致)。

如果你在 aboutToAppear 中直接调用渲染函数,就会渲染进一个 300×150 的微型缓冲区,然后在屏幕上被拉伸显示,形成"所有像素堆积在左上角"的现象。

5.3 解决方案:动画循环 + onAreaChange

我们采用两阶段策略解决这个问题:

第一阶段:等待布局完成

aboutToAppear 中只启动一个动画循环,不直接渲染:

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

private startAnimation(): void {
  const animate = () => {
    if (this.isRenderDirty) {
      this.renderMandelbrot();
      // 渲染成功后自行清除 isRenderDirty
      // 若 Canvas 尚未就绪,保留脏标记,下一帧重试
    }
    this.animationId = setTimeout(animate, 48);
  };
  animate();
}

第二阶段:检测实际布局尺寸

每次渲染前检查 ctx.width 是否大于阈值(360px),这是区分"默认尺寸"和"实际布局尺寸"的关键:

private renderMandelbrot(): void {
  const w = this.canvasContext.width;
  const h = this.canvasContext.height;

  if (typeof w !== 'number' || typeof h !== 'number' || w < 360 || h < 360) {
    return;  // Canvas 尚未布局,保留 isRenderDirty,下一帧重试
  }
  // ... 正常渲染 ...
  this.isRenderDirty = false;  // 仅成功渲染后清除
}

辅助:onAreaChange 触发重绘

onAreaChange 是 Canvas 组件的回调,当组件布局或尺寸变化时触发。我们用它与动画循环配合,确保布局完成后第一时间开始渲染:

Canvas(this.canvasContext)
  .width('100%')
  .layoutWeight(1)
  .onAreaChange((): void => {
    this.isRenderDirty = true;  // 布局完成或尺寸变化 → 触发重绘
  })

5.4 时序图

aboutToAppear          onAreaChange
    |                      |
    v                      v
startAnimation ──→ animate()
                      ├─ 帧1: ctx.width=300 → w<360 → return
                      ├─ 帧2: ctx.width=300 → w<360 → return
                      │
                      ├─ ←── onAreaChange fires ────→ isRenderDirty=true
                      │
                      ├─ 帧3: ctx.width=1080 → w≥360 → render!
                      │                                     └→ isRenderDirty=false
                      ├─ 帧4: ... (休止)
                      │
                      ├─ 用户缩放 pinch → isRenderDirty=true
                      ├─ 帧5: ctx.width=1080 → render!
                      └─ ... (循环)

6. 多指触控手势系统

6.1 手势状态机

我们通过一个简单的状态机管理触控手势:

const TOUCH_NONE: number = 0;    // 无手势
const TOUCH_PAN: number = 1;     // 单指拖拽
const TOUCH_PINCH: number = 2;   // 双指缩放

private gestureMode: number = TOUCH_NONE;

状态转换规则:

TouchType.Down (1指) ──→ TOUCH_PAN
TouchType.Down (2指) ──→ TOUCH_PINCH
TouchType.Up   (0指) ──→ TOUCH_NONE

当手势进行中(TouchType.Move)时,根据当前状态做不同的处理。

6.2 单指拖拽:复平面平移

拖拽的核心是将屏幕像素位移转换为复平面位移:

const dx = (touches[0].x - panStartX) / w * viewW;
const dy = (touches[0].y - panStartY) / h * viewH;
offsetX = panBaseOffX - dx;
offsetY = panBaseOffY - dy;
  • panStartX/Y:手指按下时的像素坐标(缓存)
  • panBaseOffX/Y:手指按下时复平面的中心点(缓存)
  • w/h:Canvas 的像素宽高
  • viewW/viewH:复平面视口的宽度和高度
  • 比例 dx = 像素偏移 ÷ 总像素 × 复平面宽度 将像素空间映射到复平面空间

为什么减号:手指向右滑动(位移为正)时,我们期望复平面向左移动(相当于视口向右平移),所以取负号。

6.3 双指缩放:焦点保持不变

双指缩放比拖拽复杂,因为我们要保持两个手指之间的焦点在复平面上不动:

// 1. 计算缩放比例
const scaleRatio = pinchBaseDist / curDist;

// 2. 计算焦点在复平面上的坐标
const focalCX = pinchBaseOffX + (pinchFocalX / w - 0.5) * oldViewW;
const focalCY = pinchBaseOffY + (pinchFocalY / h - 0.5) * oldViewH;

// 3. 缩放后重新计算中心点,使焦点保持不变
offsetX = focalCX - (pinchFocalX / w - 0.5) * newViewW;
offsetY = focalCY - (pinchFocalY / h - 0.5) * newViewH;
zoom = pinchBaseZoom * scaleRatio;

关键数学推导:

  1. 像素坐标 (pinchFocalX, pinchFocalY) 到复平面坐标 (focalCX, focalCY) 的映射:

    focalCX = offsetX + (pinchFocalX / w - 0.5) * viewW
    

    因为像素原点在左上角,而复平面原点在视口中心,所以减去 0.5 做偏移。

  2. 缩放后,新的中心和 zoom 要满足:

    focalCX = newOffsetX + (pinchFocalX / w - 0.5) * newViewW
    

    反解出 newOffsetX 即为上述公式。

6.4 灵敏度控制

为了保护用户免受"一碰就飞"的体验,我们对缩放做了极限限制:

const newZoomClamped = Math.max(newZoom, 0.0000001);

这个最小值对应大约 3500 万倍的缩放——足够深入曼德勃罗集的细部结构,但防止了除零错误。


7. 全码实现逐段解析

7.1 头部注释与常量定义

/**
 * 曼德勃罗集 - 复平面 z = z² + c 迭代渲染
 * ============================================
 * 功能: 逃逸时间算法着色 + HSV 色轮映射,
 *       双指缩放 + 单指平移拖拽交互
 *
 * 技术栈: @ComponentV2 + CanvasRenderingContext2D + ImageData
 *         onTouch 多指触控手势
 *
 * @since API 24
 */

const TOUCH_NONE: number = 0;
const TOUCH_PAN: number = 1;
const TOUCH_PINCH: number = 2;

设计要点

  • 使用常量枚举替代魔法数字,增强可读性
  • @since API 24 明确 API 版本兼容性

7.2 组件结构与状态声明

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

  // ---- 复平面视口状态 ----
  @Local offsetX: number = -0.5;         // 视口中点 X
  @Local offsetY: number = 0;            // 视口中点 Y
  @Local zoom: number = 3.5;            // 视口宽度 (复平面单位)
  @Local maxIterations: number = 120;    // 最大迭代次数
  @Local isRenderDirty: boolean = true;  // 需要重新渲染
  @Local infoText: string = '';          // 状态信息
  private animationId: number = 0;

  // ---- 触控手势状态 ----
  private gestureMode: number = TOUCH_NONE;
  private pinchBaseZoom: number = 3.5;
  private pinchBaseOffX: number = -0.5;
  private pinchBaseOffY: number = 0;
  private pinchBaseDist: number = 0;
  private pinchFocalX: number = 0;
  private pinchFocalY: number = 0;
  private panBaseOffX: number = -0.5;
  private panBaseOffY: number = 0;
  private panStartX: number = 0;
  private panStartY: number = 0;
}

@ComponentV2@Local

  • @ComponentV2 是 HarmonyOS API 24 引入的新一代组件装饰器,提供更简洁的状态管理和更优的性能
  • @Local 装饰的字段是组件的响应式状态,修改后会自动触发 UI 更新
  • @Local 字段(如手势缓存、Canvas 上下文)是私有成员,不参与响应式追踪,避免不必要的重绘

7.3 生命周期与动画循环

// ============ 生命周期 ============

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

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

// ============ 动画驱动 ============

private startAnimation(): void {
  const animate = () => {
    if (this.isRenderDirty) {
      this.renderMandelbrot();
    }
    this.animationId = setTimeout(animate, 48);
  };
  animate();
}

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

为什么用 setTimeout 而非 setInterval

  • setTimeout 在每次回调结束后才设置下一次定时器,避免了回调执行时间超过间隔导致的重叠
  • 48ms ≈ 20fps,对于计算密集型的分形渲染是合理的帧率——既能保证交互响应,又不会让 CPU 满载

为什么不用 requestAnimationFrame

  • HarmonyOS API 24 的 Canvas 组件不一定支持 rAF 语义
  • setTimeout 更稳定可控,且 20fps 对于静态图像的参数调整足够了

7.4 HSV → RGB 转换

private hsvToRgb(h: number, s: number, v: number): number[] {
  const hi = Math.floor(h / 60) % 6;
  const f = h / 60 - Math.floor(h / 60);
  const p = v * (1 - s);
  const q = v * (1 - f * s);
  const t = v * (1 - (1 - f) * s);

  let r = 0, g = 0, b = 0;
  switch (hi) {
    case 0: r = v; g = t; b = p; break;
    case 1: r = q; g = v; b = p; break;
    case 2: r = p; g = v; b = t; break;
    case 3: r = p; g = q; b = v; break;
    case 4: r = t; g = p; b = v; break;
    case 5: r = v; g = p; b = q; break;
  }
  return [Math.round(r * 255), Math.round(g * 255), Math.round(b * 255)];
}

算法原理

  • HSV 色环分为 6 个扇区(0°~60°、60°~120°、…、300°~360°)
  • 每个扇区内,只有一个主要颜色分量线性变化
  • hi 确定扇区索引,f 确定扇区内位置
  • pqt 分别是递减、递减后混合、递增后再混合的中间值
  • 最终的 switch 分支根据扇区将 (v, t, p) 等分配到 RGB 通道

7.5 核心渲染引擎

private renderMandelbrot(): void {
  // 直接从 Canvas 上下文中读取实际像素尺寸
  const w = this.canvasContext.width;
  const h = this.canvasContext.height;

  if (typeof w !== 'number' || typeof h !== 'number' || w < 360 || h < 360) {
    return;  // Canvas 尚未布局,下一帧重试
  }

  const aspect = w / h;
  const viewW = this.zoom;
  const viewH = viewW / aspect;
  const xMin = this.offsetX - viewW / 2;
  const xMax = this.offsetX + viewW / 2;
  const yMin = this.offsetY - viewH / 2;
  const yMax = this.offsetY + viewH / 2;

  const maxIter = this.maxIterations;

  // ---- 创建像素缓冲区 ----
  let imageData: ImageData;
  try {
    imageData = this.canvasContext.createImageData(w, h);
  } catch (e) {
    this.renderPixelByPixel(this.canvasContext, w, h, xMin, xMax, yMin, yMax, maxIter);
    return;
  }
  const data = imageData.data;

  // ---- 逐像素迭代 ----
  for (let py = 0; py < h; py++) {
    const cy = yMin + (py / h) * (yMax - yMin);
    for (let px = 0; px < w; px++) {
      const cx = xMin + (px / w) * (xMax - xMin);

      let zx = 0;
      let zy = 0;
      let iter = 0;

      while (zx * zx + zy * zy < 4 && iter < maxIter) {
        const tmp = zx * zx - zy * zy + cx;
        zy = 2 * zx * zy + cy;
        zx = tmp;
        iter++;
      }

      const idx = (py * w + px) * 4;

      if (iter >= maxIter) {
        data[idx] = 0;
        data[idx + 1] = 0;
        data[idx + 2] = 0;
      } else {
        const mag2 = zx * zx + zy * zy;
        const logZn = Math.log(mag2) / 2;
        const nu = Math.log(logZn / Math.LN2) / Math.LN2;
        const smoothIter = iter + 1 - nu;

        const hue = (smoothIter * 55 + 240) % 360;
        const rgb = this.hsvToRgb(hue, 0.85, 0.92);
        data[idx] = rgb[0];
        data[idx + 1] = rgb[1];
        data[idx + 2] = rgb[2];
      }
      data[idx + 3] = 255;
    }
  }

  this.canvasContext.putImageData(imageData, 0, 0);

  const zoomLevel = 3.5 / this.zoom;
  this.infoText = '迭代 ' + maxIter + ' 层  ·  缩放 '
    + zoomLevel.toFixed(1) + 'x  ·  中心 ('
    + this.offsetX.toFixed(6) + ', '
    + this.offsetY.toFixed(6) + ')  ·  '
    + w + '×' + h;

  this.isRenderDirty = false;
}

核心算法步骤

步骤 代码 说明
1. 尺寸检查 w < 360 确保 Canvas 已布局
2. 视口计算 xMin, xMax, yMin, yMax 复平面范围
3. 创建缓冲区 createImageData(w, h) 分配像素数组
4. 逐像素迭代 双重 for 循环 对每个像素执行逃逸时间算法
5. 着色 hsvToRgb(hue, ...) 迭代次数 → HSV → RGB
6. 批量写入 putImageData 一次性刷新画布
7. 更新信息 this.infoText = ... 显示状态
8. 清除脏标记 isRenderDirty = false 渲染完成

逐像素循环的含义

// 像素坐标转复平面坐标(内插公式)
const cx = xMin + (px / w) * (xMax - xMin);
const cy = yMin + (py / h) * (yMax - yMin);

// 复平面上的曼德勃罗集迭代
// 初始 z = 0,对 c = (cx, cy)
// z ← z² + c
let zx = 0;
let zy = 0;
let iter = 0;
while (zx * zx + zy * zy < 4 && iter < maxIter) {
  const tmp = zx * zx - zy * zy + cx;
  zy = 2 * zx * zy + cy;
  zx = tmp;
  iter++;
}

这里 px / w 将像素列索引归一化到 [0, 1),再映射到 [xMin, xMax) 得到复平面坐标 cx。同理 py / h → cy

7.6 Fallback 逐像素渲染

private renderPixelByPixel(
  ctx: CanvasRenderingContext2D,
  w: number, h: number,
  xMin: number, xMax: number,
  yMin: number, yMax: number,
  maxIter: number
): void {
  const step = 2;
  for (let py = 0; py < h; py += step) {
    const cy = yMin + (py / h) * (yMax - yMin);
    for (let px = 0; px < w; px += step) {
      const cx = xMin + (px / w) * (xMax - xMin);

      let zx = 0, zy = 0, iter = 0;
      while (zx * zx + zy * zy < 4 && iter < maxIter) {
        const tmp = zx * zx - zy * zy + cx;
        zy = 2 * zx * zy + cy;
        zx = tmp;
        iter++;
      }

      ctx.fillStyle = iter >= maxIter
        ? '#000000'
        : this.iterToColor(iter, zx, zy, maxIter);
      ctx.fillRect(px, py, step, step);
    }
  }
}

这段代码是备选方案——当 createImageData 不可用时(极少数旧设备或特殊配置),使用 fillRect 逐个像素绘制。step = 2 表示每 2×2 像素块绘制一次,以牺牲分辨率换取渲染速度。

7.7 触控手势完整实现

private onCanvasTouch(event: TouchEvent): void {
  const touches = event.touches;
  const touchCount: number = touches.length;

  if (event.type === TouchType.Down || event.type === TouchType.Up) {
    // 模式切换逻辑
    if (touchCount >= 2) {
      this.gestureMode = TOUCH_PINCH;
      // 缓存双指状态
      this.pinchBaseOffX = this.offsetX;
      this.pinchBaseOffY = this.offsetY;
      this.pinchBaseZoom = this.zoom;
      this.pinchBaseDist = this.touchDist(touches[0], touches[1]);
      const mid = this.touchMid(touches[0], touches[1]);
      this.pinchFocalX = mid[0];
      this.pinchFocalY = mid[1];
    } else if (touchCount === 1 && this.gestureMode !== TOUCH_PINCH) {
      this.gestureMode = TOUCH_PAN;
      this.panBaseOffX = this.offsetX;
      this.panBaseOffY = this.offsetY;
      this.panStartX = touches[0].x;
      this.panStartY = touches[0].y;
    } else {
      this.gestureMode = TOUCH_NONE;
    }
  } else if (event.type === TouchType.Move) {
    if (touchCount >= 2 && this.gestureMode === TOUCH_PINCH) {
      // 双指缩放 (代码见上文 6.3 节)
      // ...
    } else if (touchCount === 1 && this.gestureMode === TOUCH_PAN) {
      // 单指拖拽 (代码见上文 6.2 节)
      // ...
    }
  }

  if (event.type === TouchType.Up && touchCount === 0) {
    this.gestureMode = TOUCH_NONE;
  }
}

设计要点

  • 所有状态缓存在手势开始时(Down/Up 事件),移动时只读不写缓存
  • 双指模式优先:即使双指后只剩一指,也保持 TOUCH_PINCH 模式直到手指全部抬起
  • TouchType.UptouchCount === 0 才重置——这兼容了"两指先后抬起"的场景

7.8 辅助函数

private touchDist(t1: TouchObject, t2: TouchObject): number {
  const dx = t1.x - t2.x;
  const dy = t1.y - t2.y;
  return Math.sqrt(dx * dx + dy * dy);
}

private touchMid(t1: TouchObject, t2: TouchObject): number[] {
  return [(t1.x + t2.x) / 2, (t1.y + t2.y) / 2];
}

private resetView(): void {
  this.offsetX = -0.5;
  this.offsetY = 0;
  this.zoom = 3.5;
  this.maxIterations = 120;
  this.isRenderDirty = true;
}

7.9 UI 构建

build() {
  Column({ space: 0 }) {
    // ---- 标题 ----
    Text('曼德勃罗集 · Mandelbrot')
      .fontSize(22)
      .fontWeight(FontWeight.Bold)
      .fontColor('#FFFFFF')
      .width('100%')
      .textAlign(TextAlign.Center)
      .padding({ top: 14, bottom: 2 })

    // ---- 信息栏 ----
    Text(this.infoText)
      .fontSize(11)
      .fontColor('#AAAAAA')
      .width('100%')
      .textAlign(TextAlign.Center)
      .padding({ bottom: 6 })
      .lineHeight(16)

    // ---- Canvas 画布 ----
    Canvas(this.canvasContext)
      .width('100%')
      .layoutWeight(1)
      .onTouch((event: TouchEvent) => { this.onCanvasTouch(event); })
      .onAreaChange((): void => {
        this.isRenderDirty = true;
      })

    // ---- 底部控制面板 ----
    Column({ space: 10 }) {
      Row({ space: 10 }) {
        Text('迭代').fontSize(12).fontColor('#999999').width(40)

        Slider({
          value: this.maxIterations,
          min: 20, max: 500, step: 10
        })
        .layoutWeight(1)
        .trackThickness(4)
        .blockColor('#FF6688')
        .trackColor('#FFFFFF30')
        .selectedColor('#FF6688')
        .onChange((val: number) => {
          this.maxIterations = val;
          this.isRenderDirty = true;
        })

        Text('' + this.maxIterations)
          .fontSize(12)
          .fontColor('#FFFFFF')
          .width(36)
          .textAlign(TextAlign.End)
      }
      .width('100%')

      // 重置 + 操作提示
      Row({ space: 16 }) {
        Button('重置视角')
          .fontSize(13)
          .fontColor('#FFFFFF')
          .backgroundColor('#FF668840')
          .borderRadius(8)
          .height(34)
          .onClick(() => { this.resetView(); })

        Text('双指缩放 · 单指拖拽 · 调迭代')
          .fontSize(11)
          .fontColor('#666666')
          .layoutWeight(1)
          .textAlign(TextAlign.End)
      }
      .width('100%')
      .alignItems(VerticalAlign.Center)
    }
    .width('100%')
    .padding({ left: 16, right: 16, top: 10, bottom: 14 })
    .backgroundColor('#151515')
    .borderRadius({ topLeft: 16, topRight: 16 })
  }
  .width('100%')
  .height('100%')
  .backgroundColor('#050505')
}

UI 结构总结

Column (全屏)
├── Text (标题: 曼德勃罗集 · Mandelbrot)
├── Text (信息栏: 迭代/缩放/中心/尺寸)
├── Canvas (分形画布: 触控+尺寸监听)
└── Column (底部面板: 深色圆角)
    ├── Row (迭代控制)
    │   ├── Text (标签)
    │   ├── Slider (滑条: 20~500)
    │   └── Text (数值)
    └── Row (操作区)
        ├── Button (重置视角)
        └── Text (操作提示)

8. 组件生命周期与渲染时序

8.1 HarmonyOS API 24 组件生命周期

@ComponentV2 装饰的组件有三个关键生命周期方法:

方法 调用时机 我们的用法
aboutToAppear() 组件实例化后、首次 build 前 启动动画循环
build() 组件首次渲染和每次 @Local 状态变化时 构建 UI 树
aboutToDisappear() 组件销毁前 停止动画循环,清理定时器

8.2 完整渲染时序

时间线 (不按比例)
│
├─ t=0ms:   组件实例化
│            └─ aboutToAppear() → startAnimation()
│            └─ 动画帧 1 调度 (setTimeout 48ms)
│
├─ t=1ms:   build() 执行
│            └─ Canvas 组件创建
│            └─ Canvas 上下文默认尺寸 (300×150)
│
├─ t=5ms:   布局引擎计算组件位置
│            └─ Canvas 获得实际布局尺寸 (1080×1920)
│            └─ Canvas 框架自动更新上下文 buffer
│            └─ onAreaChange 触发 → isRenderDirty = true
│
├─ t=48ms:  动画帧 1 执行
│            └─ renderMandelbrot()
│            └─ ctx.width = 1080, ctx.height = 1920
│            └─ w=1080 ≥ 360 → 通过检查!
│            └─ 渲染整个分形 → infoText 更新 → isRenderDirty = false
│
├─ t=50ms:  @Local 变化 (infoText) → UI 更新
│            └─ Text 组件更新显示
│
├─ t=96ms:  动画帧 2 执行
│            └─ isRenderDirty = false → 跳过渲染
│
├─ t=2000ms: 用户双指缩放
│            └─ onTouch → 修改 offsetX/Y, zoom
│            └─ 设置 isRenderDirty = true
│            └─ @Local 更新 → build 不会重新创建 Canvas
│
├─ t=2001ms: 动画帧执行 (48ms 循环)
│            └─ isRenderDirty = true → renderMandelbrot()
│            └─ 新参数下重新渲染
│            └─ isRenderDirty = false
│
└─ 用户离开页面
              └─ aboutToDisappear() → stopAnimation() → clearTimeout

8.3 常见的时序陷阱

陷阱 现象 原因 解决方案
在 aboutToAppear 中渲染 分形缩在左上角 Canvas 尚未布局,ctx.width 为默认值 延迟到 onAreaChange 后渲染
忘记清除脏标记 持续重绘,CPU 满载 isRenderDirty 一直为 true 渲染成功末尾设为 false
在 build 中调用渲染 触发无限循环 build 修改状态 → 触发 build → … 渲染逻辑放在动画循环中
动画循环用 setInterval 回调可能重叠 setInterval 不等待回调结束 用 setTimeout 链式调用

9. 性能优化与调优策略

9.1 批量渲染 vs 逐像素绘制

方案 A:ImageData 批量渲染

const imageData = ctx.createImageData(w, h);
const data = imageData.data;
// 修改 data 数组
ctx.putImageData(imageData, 0, 0);
  • 优点:一次写入整个画布,开销最小;内存布局连续,CPU 缓存友好
  • 缺点:需要分配大块内存(1080×1920×4 ≈ 8MB)
  • 适合:完整重绘场景

方案 B:fillRect 逐像素绘制

ctx.fillStyle = '#XXXXXX';
ctx.fillRect(px, py, 1, 1);
  • 优点:不需要额外缓冲区,随时可以绘制
  • 缺点:每次 fillRect 都是独立的绘制调用,GPU 开销大
  • 适合:增量更新或低分辨率预览

9.2 迭代精度与性能的平衡

迭代次数 maxIterations 是最关键的性能参数:

迭代次数 每像素操作数 渲染时间 (1080p) 效果
20 约 10^7 ~0.3s 只有主要轮廓,细节很少
120 约 6×10^7 ~1.5s 细节丰富,大多数场景够用
500 约 2.5×10^8 ~6s 深度缩放时需要,边缘细节极致
2000 约 10^9 ~24s 仅用于极小区域(深度 10⁶ 倍)

我们提供了 20~500 的滑条调节范围,用户可以根据缩放深度动态调整。

9.3 运算优化技巧

避免重复计算

// 优化前:每次迭代计算 zx * zx 和 zy * zy 两次
while (zx * zx + zy * zy < 4) {    // 第一次计算
  zy = 2 * zx * zy + cy;
  zx = zx * zx - zy_old * zy_old + cx;  // 隐含第二次
  // 编译器不一定会 CSE (Common Subexpression Elimination)
}

// 优化后:用变量缓存
while (zx * zx + zy * zy < 4) {
  const zx2 = zx * zx;
  const zy2 = zy * zy;
  zy = 2 * zx * zy + cy;
  zx = zx2 - zy2 + cx;
}

减少类型转换

在 ArkTS 中,number 在运行时可能涉及自动装箱。虽然编译器会做优化,但避免在热循环中做函数调用和类型转换仍然有益。

9.4 内存管理

ImageData 对象大约占用 w × h × 4 字节。对于 1080×1920 的 canvas,这是 8MB 的内存分配。需要注意:

  • 不要在动画循环中每次都 new 新的 ImageData 而不释放旧对象(可能 GC 压力大)
  • 我们的实现中每次渲染都 createImageData 分配新缓冲区——这是合理的,因为 Canvas 尺寸在多数情况下不变,但 HarmonyOS 的 GC 足够高效,不需要手动管理

9.5 触控性能

双指缩放时,每帧触控事件的处理需要:

  1. 计算两点间距(一次 Math.sqrt
  2. 计算焦点坐标
  3. 更新 offsetX/Yzoom
  4. 设置 isRenderDirty = true

触控本身开销极小(微秒级),但后续触发的渲染较重。我们通过 isRenderDirty 标记合并了"多次触控移动 → 一次重绘",避免每次触控移动都触发完整渲染。


10. 总结与扩展方向

10.1 本文总结

我们完成了一个完整的曼德勃罗集合交互式渲染器,覆盖了:

技术领域 实现内容
数学 逃逸时间算法、平滑归一化迭代、复平面坐标映射
图形学 HSV→RGB 转换、色轮映射、ImageData 批量渲染
UI 框架 @ComponentV2 装饰器、@Local 响应式状态、build 函数
交互 onTouch 多指手势、双指焦点缩放、单指拖拽平移
生命周期 aboutToAppear/Disappear、onAreaChange 布局回调
性能 动画循环驱动、脏标记合并、阈值保护性检查

10.2 扩展方向

Julia 集渲染:只需修改迭代公式为 z ← z² + c 其中 c 固定、z 从像素对应的复平面初始值开始,即可生成 Julia 集。核心渲染函数可复用 90% 以上。

缩放动画:添加平滑缩放过渡动画(从当前 zoom 动画过渡到目标 zoom),使用 ease-out 缓动函数。

颜色方案预设:提供多组 HSL 映射参数(如火焰色、海洋色、霓虹色),让用户切换不同视觉效果。

多线程渲染:使用 HarmonyOS 的 Worker API,将逐像素迭代计算分配到子线程,避免阻塞主线程 UI。

深色模式适配:当前已采用深色主题,可进一步跟随系统深色/浅色模式自动切换。

保存/分享:将当前 Canvas 内容保存为图片,支持分享到社交媒体。

10.3 完整项目代码

本文全部代码已在 MandelbrotPage.ets 中完整包含。将文件放入 entry/src/main/ets/pages/ 目录,在 Index.ets 中导入并挂载即可运行:

// Index.ets
import { MandelbrotPage } from './MandelbrotPage';

@Entry
@ComponentV2
struct Index {
  build() {
    Stack() {
      MandelbrotPage()
    }
    .width('100%')
    .height('100%')
  }
}
Logo

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

更多推荐