曼德勃罗集渲染器:HarmonyOS API 24 从数学到交互的完整实现


摘要:本文从复平面迭代的数学原理出发,完整实现了一个基于 HarmonyOS API 24 的曼德勃罗集交互式渲染器。涵盖逃逸时间算法、HSV 色轮映射、双指缩放/单指拖拽手势、Canvas 像素缓冲区批量渲染、组件生命周期管理等核心技术。
目录
- 分形之美:曼德勃罗集的数学本质
- 项目架构与目录结构
- 逃逸时间算法详解
- HSV 色轮着色系统
- Canvas 尺寸管理的陷阱与解决方案
- 多指触控手势系统
- 全码实现逐段解析
- 组件生命周期与渲染时序
- 性能优化与调优策略
- 总结与扩展方向
1. 分形之美:曼德勃罗集的数学本质
1.1 复平面与迭代
曼德勃罗集(Mandelbrot Set)是数学家 Benoit Mandelbrot 在 1980 年发现的最著名的分形之一。它由一个极其简单的迭代公式定义:
z₀ = 0
zₙ₊₁ = zₙ² + c
其中 z 和 c 都是复数。复数的乘法规则是 (a + bi)² = a² - b² + 2abi。判断一个复平面上的点 c 是否属于曼德勃罗集,就看 zₙ 的模长是否会在有限次迭代后发散(即 |zₙ|² > 4)。
1.2 逃逸时间算法
"逃逸时间"算法的核心思想非常简单:
- 对复平面上的每个点
c,从z₀ = 0开始迭代 - 如果在最大迭代次数内
|zₙ|² > 4,则点 逃逸,记录逃逸时的迭代次数 - 如果始终没有逃逸,则该点属于集合 内部
迭代次数本身就是一个天然的"高度场"——越靠近集合边界,逃逸所需的迭代次数越密集,从而形成极其丰富的细部结构。
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;
关键数学推导:
-
像素坐标
(pinchFocalX, pinchFocalY)到复平面坐标(focalCX, focalCY)的映射:focalCX = offsetX + (pinchFocalX / w - 0.5) * viewW因为像素原点在左上角,而复平面原点在视口中心,所以减去 0.5 做偏移。
-
缩放后,新的中心和 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确定扇区内位置p、q、t分别是递减、递减后混合、递增后再混合的中间值- 最终的 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.Up且touchCount === 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 触控性能
双指缩放时,每帧触控事件的处理需要:
- 计算两点间距(一次
Math.sqrt) - 计算焦点坐标
- 更新
offsetX/Y和zoom - 设置
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%')
}
}
更多推荐



所有评论(0)