在 HarmonyOS 模拟器上用递归种出科赫分形
前言小时候冬天在窗边呵气玻璃上的霜花总是长成对称的树状图案——一片叶子分出更小的叶子小叶子再分出更小的无穷无尽。后来才知道那种“自己像自己”的结构在数学里叫分形。分形里最出名的一张脸就是科赫雪花。它的画法简单到三句话就能说清却能在有限面积里挤出无限长度的边界线像一桩完美的数学魔术。我一直想在手机屏幕上亲手把这片雪花“种”出来——不是贴一张静态图片而是用滑块一层层控制递归深度看它从三角形变成六角星再慢慢长出越来越细的毛刺。打开 DevEco Studio 6.1.1 Beta1对着 Pura X Max 模拟器的屏幕用 Canvas 递归绘制一片蓝色的科赫雪花就这么在模拟器里飘起来了。这篇文章会把整个过程掰开讲科赫曲线怎么从一条线段长出来、递归函数怎么写、坐标和旋转怎么算、Canvas 怎么把几千条线段一帧画完。代码也给全复制进去就能跑。一、分形和那条“太长的海岸线”1967 年数学家曼德博在《科学》杂志上发了篇论文标题叫《英国海岸线有多长》。他说你拿一把一公里长的尺子去量海岸线得一个数换一把一百米长的尺子弯弯绕绕的小海湾就被测出来了长度飙升。尺子越短测出的海岸线越长最后趋向无穷。这条反直觉的结论开启了分形几何的大门。分形的核心特征就是自相似——整体和局部长得一样。一棵树的主干分出几根枝每根枝又像一棵小树山脉的轮廓放大后和远观的轮廓差不多。科赫曲线就是这种自相似的教科书级案例。1904 年瑞典数学家科赫发明了这条曲线。画法很简单画一条线段。把线段等分成三段。中间那段用一个等边三角形的另外两条边替代形成一个凸起。对得到的四条线段每条都重复这个过程。无限重复下去就得到一条处处连续但处处不可导的曲线——它填不满平面却在有限区域内无限长。把三条科赫曲线头尾相接围成一个等边三角形就是科赫雪花。每增加一层递归雪花的边长就乘以 4/3面积却始终小于初始三角形外接圆最终周长趋向无穷面积却是收敛的。这就是分形最迷人的地方有限面积里装着无限周长。二、把线段变成雪花的公式——三等分、旋转、拼接计算机画科赫曲线就是把上面的几何步骤翻译成坐标计算。假设有两个点 A 和 B我们要在它们之间生成深度为 N 的科赫曲线。先看最基本的一步变换给定线段 AB算出它的两个三等分点 C 和 D再算出凸起顶点 E。用向量来算最省事。设 A (x1, y1)B (x2, y2)。向量 AB (dx, dy) (x2 - x1, y2 - y1)。三等分点C A (1/3) * AB (x1 dx/3, y1 dy/3) D A (2/3) * AB (x1 2*dx/3, y1 2*dy/3)接下来是 E 点。E 是以 CD 为底边的等边三角形的第三个顶点凸起方向朝外。从 C 出发向量 CD AB/3。将 CD 逆时针旋转 60 度或者顺时针取决于想让雪花往外凸还是往里凹。对于逆时针画出的等边三角形每条边向外凸需要逆时针旋转 60 度。旋转公式(x, y) (x * cos60° - y * sin60°, x * sin60° y * cos60°)其中 cos60° 0.5sin60° √3/2 ≈ 0.8660254。所以 E C 旋转后的向量。把这些公式写成 TypeScript 就是function getKochPoints(ax: number, ay: number, bx: number, by: number): [number, number][] { const dx bx - ax; const dy by - ay; const cx ax dx / 3; const cy ay dy / 3; const dx cx (dx / 3) * 0.5 - (dy / 3) * Math.sqrt(3) / 2; const ey cy (dx / 3) * Math.sqrt(3) / 2 (dy / 3) * 0.5; const dx2 ax 2 * dx / 3; const dy2 ay 2 * dy / 3; return [[cx, cy], [ex, ey], [dx2, dy2]]; }单层变换后原来的线段 AB 被替换为四段A→C、C→E、E→D、D→B。递归地每一段再做同样的分裂直到深度降到 0就直接连接两点。这就是递归算法的基础。代码实现时我们可以用递归函数直接把最终的所有顶点按顺序收集到一个数组里然后一次性画出来。三、用递归函数收割所有顶点递归函数的结构可以这样设计给定起点 A、终点 B 和当前深度如果深度为 0就把 A 和 B 加入结果列表否则计算 C、E、D然后递归调用处理 A→C、C→E、E→D、D→B每段深度减一。为了避免重复点我们约定处理一段线段时只把起点加入列表终点由下一段或最后的闭合来处理。具体做法function kochCurve(ax: number, ay: number, bx: number, by: number, depth: number, points: number[][]): void { if (depth 0) { points.push([ax, ay], [bx, by]); return; } // 计算 C, E, D // ... kochCurve(ax, ay, cx, cy, depth - 1, points); kochCurve(cx, cy, ex, ey, depth - 1, points); kochCurve(ex, ey, dx, dy, depth - 1, points); kochCurve(dx, dy, bx, by, depth - 1, points); }这样产生的点列表会有重复的内部点比如 C 既是第一段的终点又是第二段的起点。画图时这些重复点不影响结果Canvas 的lineTo重叠一下毫无问题。如果想精简可以在递归里稍作处理但为了代码清晰保持简单即可。初始雪花由等边三角形的三条边组成。先算好三角形的三个顶点 P1顶、P2左下、P3右下。然后对每条边调用kochCurve把所有点拼成一个闭合多边形。注意第一条边要把 P1 加入起点最后一条边结束时要回到 P1 闭合。得到的点数组可能有几千个点深度 6 时约 3×4^6 12288 个点对手机的 Canvas 来说完全不是问题模拟器上也能瞬间画完。四、在 HarmonyOS 的 Canvas 上把点连成雪花HarmonyOS 的 Canvas 组件通过onReady回调给我们一个CanvasRenderingContext2D对象。在 SDK22 下导入路径是import { CanvasRenderingContext2D } from ohos.graphics.canvas;拿到上下文后我们可以用ctx.canvas.width和ctx.canvas.height获取画布的实际像素尺寸然后根据画布大小计算等边三角形的边长和中心坐标让雪花自适应居中。绘制步骤clearRect清空画布。可选画一个浅色背景或网格增加视觉舒适度。调用递归函数生成点列表。用ctx.beginPath()开始一条新路径moveTo到第一个点然后循环lineTo连接所有点最后closePath()闭合。设置strokeStyle比如冰蓝色、lineWidth然后stroke。另外为了提高辨识度我加了一个填充色给雪花内部填充一层很淡的蓝色边框用深一点的蓝线描边。这样雪花看起来更立体像一块冰晶。为了让雪花更好看还可以在递归前判断雪花的方向确保凸起朝外。三角形顶点顺序为逆时针向量旋转 60 度正好让凸起指向外侧。这点在数学上是自然的逆时针多边形每边向左侧旋转 60 度就朝外。所以我们无需额外调整。五、用滑块控制深度——从三角到雪花在界面上放一个Slider绑定State depth: number最小值 0最大值 6步长 1。每次滑块值变化就重新计算点列表并重绘画布。深度 0 时就是原始的等边三角形。深度 1 时三角形每条边中间凸起一个小三角形整个图形变成六角星形大卫之星。深度 2 时星星的每条边上又长出更小的凸起以此类推。深度 6 时雪花的轮廓已经非常复杂充满了细密的锯齿像是霜花在显微镜下的模样。给用户一些提示显示当前深度、总共生成的点数等于 3×4^深度 1让用户直观感受分形的爆炸增长。整个界面从上到下标题、画布、深度显示和滑块、底部一句小知识周长无限面积有限。配色选清凉的蓝白色系贴合雪花主题。六、完整代码——整片雪花都在一个文件里以下是能在 DevEco Studio 6.1.1 Beta1 上直接跑的代码。新建 Empty Ability 项目把entry/src/main/ets/pages/Index.ets全选替换即可。不需要权限不用改module.json5。/* * 科赫雪花分形绘制 — 递归生成 * 功能滑块调节递归深度Canvas 绘制科赫雪花曲线 * 环境DevEco Studio 6.1.1 Beta1Pura X Max 模拟器SDK22 */ import { CanvasRenderingContext2D } from ohos.graphics.canvas; Entry Component struct Index { State depth: number 3; // 递归深度默认3 State pointCount: number 0; // 生成的点数量 private ctx: CanvasRenderingContext2D | null null; private canvasWidth: number 0; private canvasHeight: number 0; // Canvas 就绪 private onCanvasReady(ctx: CanvasRenderingContext2D): void { this.ctx ctx; this.canvasWidth ctx.canvas.width; this.canvasHeight ctx.canvas.height; this.drawSnowflake(); } // 生成科赫曲线点集递归 private kochCurve( ax: number, ay: number, bx: number, by: number, depth: number, points: number[][] ): void { if (depth 0) { points.push([ax, ay], [bx, by]); return; } const dx bx - ax; const dy by - ay; const cx ax dx / 3; const cy ay dy / 3; const dx2 ax 2 * dx / 3; const dy2 ay 2 * dy / 3; // 计算凸起顶点 E const midX dx / 3; const midY dy / 3; const cos60 0.5; const sin60 Math.sqrt(3) / 2; const ex cx midX * cos60 - midY * sin60; const ey cy midX * sin60 midY * cos60; // 递归四段 this.kochCurve(ax, ay, cx, cy, depth - 1, points); this.kochCurve(cx, cy, ex, ey, depth - 1, points); this.kochCurve(ex, ey, dx2, dy2, depth - 1, points); this.kochCurve(dx2, dy2, bx, by, depth - 1, points); } // 生成雪花完整路径点 private generateSnowflakePoints(): number[][] { const w this.canvasWidth; const h this.canvasHeight; // 等边三角形中心 const cx w / 2; const cy h / 2; const side Math.min(w, h) * 0.75; const height side * Math.sqrt(3) / 2; // 三个顶点逆时针 const p1x cx; const p1y cy - height / 2; // 上 const p2x cx - side / 2; const p2y cy height / 2; // 左下 const p3x cx side / 2; const p3y cy height / 2; // 右下 const points: number[][] []; // 三边递归 this.kochCurve(p1x, p1y, p2x, p2y, this.depth, points); this.kochCurve(p2x, p2y, p3x, p3y, this.depth, points); this.kochCurve(p3x, p3y, p1x, p1y, this.depth, points); return points; } // 绘制雪花 private drawSnowflake(): void { if (!this.ctx) return; const ctx this.ctx; const w this.canvasWidth; const h this.canvasHeight; ctx.clearRect(0, 0, w, h); // 背景淡蓝 ctx.fillStyle #F0F5FA; ctx.fillRect(0, 0, w, h); const points this.generateSnowflakePoints(); this.pointCount points.length; if (points.length 2) return; // 绘制路径 ctx.beginPath(); ctx.moveTo(points[0][0], points[0][1]); for (let i 1; i points.length; i) { ctx.lineTo(points[i][0], points[i][1]); } ctx.closePath(); // 填充半透明冰蓝 ctx.fillStyle rgba(173, 216, 230, 0.3); ctx.fill(); // 描边 ctx.strokeStyle #4682B4; ctx.lineWidth 1.8; ctx.stroke(); } // 滑块变更深度 private onDepthChange(value: number): void { this.depth value; this.drawSnowflake(); } build() { Column() { Text(科赫雪花) .fontSize(28) .fontWeight(FontWeight.Bold) .margin({ top: 20, bottom: 4 }) Text(递归分形 · 有限面积无限周长) .fontSize(15) .fontColor(#666) .margin({ bottom: 12 }) Canvas() .width(100%) .height(360) .backgroundColor(#F0F5FA) .onReady((event) { let ctx event.context as CanvasRenderingContext2D; this.onCanvasReady(ctx); }) Row() { Text(深度${this.depth}) .fontSize(16) .fontWeight(FontWeight.Medium) .width(100) Slider({ value: this.depth, min: 0, max: 6, step: 1, style: SliderStyle.OutSet }) .layoutWeight(1) .onChange((value: number) { this.onDepthChange(value); }) } .width(90%) .margin({ top: 14, bottom: 6 }) Text(顶点数${this.pointCount}) .fontSize(14) .fontColor(#888) .margin({ bottom: 10 }) Column() { Text(❄️ 小知识) .fontSize(15) .fontWeight(FontWeight.Medium) .alignSelf(ItemAlign.Start) .margin({ bottom: 6 }) Text(科赫雪花是分形几何的经典图形。每增加一层递归周长变为原来的 4/3 倍无限递归后周长趋于无穷但所围面积却是有限的。) .fontSize(13) .fontColor(#666) .lineHeight(20) .alignSelf(ItemAlign.Start) } .width(88%) .padding(14) .backgroundColor(#EEF2F8) .borderRadius(12) } .width(100%) .height(100%) .backgroundColor(#FFFFFF) } }代码里把递归和 Canvas 绘图完全分开kochCurve专心生成点generateSnowflakePoints拼出整个雪花drawSnowflake负责清屏、填充和描边。滑块变化时只用调用drawSnowflake整个流程简洁清晰。运行效果把代码粘贴进项目点 RunPura X Max 模拟器上出现一片淡蓝背景正中央画着一朵冰蓝色的科赫雪花默认深度 3轮廓已经相当精细能清晰看到每一级凸起。上方显示“深度3”下方标注顶点数 192 左右。拖动滑块调到 0雪花变成光秃秃的等边三角形调到 1三角形每条边鼓起一个尖变成六角星调到 6雪花的边缘已经布满密密麻麻的小锯齿线条依旧流畅没有任何卡顿。整个绘制过程在手指拖动滑块的一瞬间完成Canvas 的响应丝滑利落。总结这个科赫雪花小项目用不到 200 行代码把好几个有趣的知识点揉到了一起分形与递归思想用最简单的“替换规则”反复迭代生成无限复杂的图形是最优雅的递归教学案例。向量计算与旋转矩阵三等分、旋转 60 度用到的只是高中数学却能画出精密的几何图形。Canvas 路径绘制从点数组到moveTo/lineTo加上填充和描边一次性渲染数千条线段展示了 HarmonyOS Canvas 的性能。声明式 UI 驱动图形State绑定滑块深度一变立即重绘数据驱动视图的模式让交互和绘图解耦得干干净净。如果想继续玩可以给雪花加颜色渐变或者把科赫曲线用到其他形状上——比如把正方形的每一边也做科赫变换会得到“科赫岛”。分形的世界里一个简单的递归就能翻出无数花样。这个小工具给你开了一扇门剩下的就看你的好奇心往哪走了。