在 HarmonyOS 模拟器上种出斐波那契螺旋线

前言

松果的鳞片排成螺旋,向日葵的花盘也按螺旋长,鹦鹉螺的壳更是著名的黄金螺旋。这些来自大自然的图案,背后藏着同一个数列:1, 1, 2, 3, 5, 8, 13......斐波那契数列。每两个数的比例逐渐逼近 0.618 那个黄金数,用一个挨一个的正方形外接四分之一圆弧,就能在纸上复现这条优雅的曲线。

我一直想在手机屏幕上亲手画一次这条线。不用手算,不靠纸笔,就用 HarmonyOS 的 Canvas,让代码一块砖一块砖地把螺旋砌出来。打开 DevEco Studio 6.1.1 Beta1,看着 Pura X Max 模拟器的屏幕,花一个下午就做出了下面这个小工具。这篇文章把整个过程------数列怎么来、正方形怎么摆、圆弧往哪边弯------掰开揉碎讲一遍,代码也给全,你复制进去就能跑起来,亲眼看看这条线如何从一堆方块里长出来。

一、兔子问题生出的传奇数列

1202 年,意大利数学家斐波那契在《算盘书》里提了个兔子繁殖问题:假设一对大兔子每月生一对小兔子,小兔子两个月后成熟,那么从一对大兔子开始,每个月有多少对兔子?答案就是:1, 1, 2, 3, 5, 8, 13, 21......后一个数永远是前两个数之和。

这个数列看似简单,却在自然界反复出现。花瓣数量、树枝分枝、贝壳螺纹,许多时候都跟斐波那契数沾边。为什么?因为相邻两数的比值 3/2, 5/3, 8/5... 越来越接近 1.618 那个神秘的黄金比例 φ。而黄金比例恰好是"最优生长"的数学表达:叶子按这个角度排布,能最大程度吸收阳光;螺壳按这个比例扩展,能用最少的材料造出最大的空间。

如果把相邻的斐波那契数作为边长,依次画出正方形,再在每个正方形里画四分之一的圆弧,这些圆弧首尾相连,就形成一条近似黄金螺旋的平滑曲线。这就是我们要在 Canvas 上做的事:用一个滑块控制迭代次数,让螺旋从几个方块扩展到层层叠叠。

二、用代码生出数列和方向

第一步是生出斐波那契数列。用个循环就行:

复制代码
let fib: number[] = [1, 1];
for (let i = 2; i < depth; i++) {
  fib.push(fib[i - 1] + fib[i - 2]);
}

这里 depth 是滑块控制的迭代次数,最小 2,最大我们限制到 10(10 层时最大正方形边长已经到 55 个单位,足够展示,再大屏幕装不下)。

第二步是确定每个正方形的摆放位置和方向。常见的构造方法是从中心往外旋转:第一个正方形边长 1,放在中心偏左下的位置;第二个正方形边长 1,贴在第一个正方形上方;第三个边长 2,贴在右侧;第四个边长 3,贴在下方;第五个边长 5,贴在左侧......方向依次是上、右、下、左,按逆时针循环。

每个正方形的中心点,或者说右下角坐标,要根据之前累积的偏移量计算。更直观的做法:维护当前正方形的左上角坐标 (cx, cy),以及当前朝向(0: 上, 1: 右, 2: 下, 3: 左)。画完当前正方形,根据朝向更新下一个正方形的左上角坐标,然后朝向加 1 取模 4。

画四分之一圆弧时,需要圆心坐标、半径和起止角度。圆弧的圆心位于正方形的内侧顶点,起始角度也与朝向相关:朝上时,圆弧从左下角到右上角,圆心在正方形左下角,起始角度 0 度,逆时针画到 90 度。朝右时圆心在左上角,起止 90 到 180 度;朝下时圆心在右上角,180 到 270;朝左时圆心在右下角,270 到 360。

用 Canvas 的 arc 方法画圆弧,需要传入圆心坐标、半径、起始角度(弧度)、终止角度(弧度)和方向(false 表示逆时针)。这些转换在代码里都封装好了。

三、把螺旋装进屏幕------缩放与居中

斐波那契数列增长极快,深度 10 时,正方形的总跨度可能达到 55 + 34 = 89 个单位。直接画到屏幕上,需要把每个单位映射成合适的像素数。我们取画布宽高的最小值作为可用区域,除以螺旋的总尺寸,得到缩放比例 scale。然后整个螺旋平移到画布中心。

计算总尺寸的方法:遍历所有正方形的边长和偏移,算出 x 和 y 方向的最大最小值,得到整体包围盒。用这个包围盒算出缩放系数,再算出绘制时的全局偏移 dxdy,让螺旋居中。

实际代码里,为了简化,我预先算出深度对应的正方形序列和坐标,一次性算好所有正方形的外接矩形,再倒推缩放。这样做虽然多一些计算,但每一帧重绘时都是纯数字运算,速度很快,深度不超过 10 时完全没有压力。

四、给螺旋上色------用 HSL 画出彩虹渐变

纯黑的螺旋线虽然清晰,但少了点灵气。我想让不同层的正方形和圆弧带上渐变的颜色,越往外的弧线颜色越暖,形成一种从冷蓝到暖橙的过渡。这用 HSL 颜色空间很好办:色相从 200(蓝)变化到 30(橙),饱和度 70%,亮度 50%。每个正方形的色相根据它的索引映射到这段区间即可。

在绘制每个正方形和圆弧之前,动态设置 ctx.strokeStylectx.fillStyle,就能得到一条彩色螺旋。如果还想看清正方形边框,可以用浅灰线条描一下方块边缘,圆弧用粗线突出。

为了清晰,我在代码里选择用半透明填充正方形以示层级,用粗彩线画圆弧。这样螺旋线醒目,方块若隐若现,不干扰视线。

五、完整代码------一个文件画出数学奇迹

以下是能在 DevEco Studio 6.1.1 Beta1 上直接跑的完整代码。新建 Empty Ability 项目,替换 entry/src/main/ets/pages/Index.ets 即可。不需要权限,不用改 module.json5。画布上绘制的是斐波那契正方形与四分之一圆弧组合成的黄金螺旋近似图形,滑块可调节迭代次数(2--10)。

复制代码
/*
 * 斐波那契螺旋线 --- 数学可视化
 * 功能:根据斐波那契数列画正方形与四分之一圆弧,滑块调节迭代次数
 * 环境:DevEco Studio 6.1.1 Beta1,Pura X Max 模拟器
 */
import { CanvasRenderingContext2D } from '@ohos.graphics.canvas';

@Entry
@Component
struct Index {
  @State depth: number = 6;                // 迭代次数,默认6
  private ctx: CanvasRenderingContext2D | null = null;
  private canvasWidth: number = 0;
  private canvasHeight: number = 0;

  // 斐波那契数列
  private fib: number[] = [];
  // 存储每个正方形的参数:左上角坐标(未缩放)、边长、朝向(0上,1右,2下,3左)
  private squares: Array<{ x: number, y: number, size: number, dir: number }> = [];

  private onCanvasReady(ctx: CanvasRenderingContext2D): void {
    this.ctx = ctx;
    this.canvasWidth = ctx.canvas.width;
    this.canvasHeight = ctx.canvas.height;
    this.buildSpiral();
    this.drawSpiral();
  }

  // 根据当前 depth 生成正方形序列
  private buildSpiral(): void {
    const d = this.depth;
    // 生成斐波那契数列
    this.fib = [1];
    if (d >= 2) {
      this.fib.push(1);
      for (let i = 2; i < d; i++) {
        this.fib.push(this.fib[i - 1] + this.fib[i - 2]);
      }
    }

    // 计算正方形序列的原始坐标(未缩放,单位等于第一个正方形边长=1)
    this.squares = [];
    let cx = 0, cy = 0; // 当前正方形左上角坐标(相对)
    let dir = 1; // 0上,1右,2下,3左,从右开始,即第二个正方形在第一个的右侧?
    // 经典构造:起始正方形在左下角,第二个在上方,但为了方向统一,我们采用标准方法:
    // 初始正方形放在 (0,0),边长 fib[0],初始朝向设为右(1)意味着下一个正方形要贴在右边?
    // 实际螺旋顺序:第一块1x1,第二块1x1贴在上方,第三块2x2贴在右侧,第四块3x3贴在下边...
    // 我们来维护当前正方形的右下角坐标用于放置圆弧,并记录左上角与朝向。
    let posX = 0, posY = 0; // 当前正方形的左上角
    let currSize = this.fib[0];
    this.squares.push({ x: posX, y: posY, size: currSize, dir: 0 }); // dir暂不关心,后面会计算圆弧朝向

    if (d === 1) return;

    // 朝向序列:上(0), 右(1), 下(2), 左(3) 循环
    const dirs = [0, 1, 2, 3]; // 对应的圆弧起始角度和移动方向
    let currentDirIndex = 0; // 初始:第二个正方形朝上放置

    for (let i = 1; i < d; i++) {
      let size = this.fib[i];
      let dir = dirs[currentDirIndex % 4];
      // 根据朝向计算新正方形的左上角坐标
      let newX = posX, newY = posY;
      if (dir === 0) { // 上:新正方形在当前正方形上方,左上角x不变,y减去当前正方形边长
        newX = posX;
        newY = posY - size;
      } else if (dir === 1) { // 右:新正方形在当前正方形右侧,左上角x加当前正方形边长,y不变
        newX = posX + currSize;
        newY = posY;
      } else if (dir === 2) { // 下:新正方形在当前正方形下方,左上角x不变,y加当前正方形边长
        newX = posX;
        newY = posY + currSize;
      } else if (dir === 3) { // 左:新正方形在当前正方形左侧,左上角x减当前正方形边长,y不变
        newX = posX - size;
        newY = posY;
      }
      this.squares.push({ x: newX, y: newY, size: size, dir: dir });
      // 更新当前正方形左上角为新的,边长变为当前size
      posX = newX;
      posY = newY;
      currSize = size;
      currentDirIndex++;
    }
  }

  // 绘制螺旋
  private drawSpiral(): void {
    if (!this.ctx) return;
    let ctx = this.ctx;
    let w = this.canvasWidth;
    let h = this.canvasHeight;
    ctx.clearRect(0, 0, w, h);

    if (this.squares.length === 0) return;

    // 计算原始坐标包围盒
    let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
    for (let sq of this.squares) {
      if (sq.x < minX) minX = sq.x;
      if (sq.y < minY) minY = sq.y;
      if (sq.x + sq.size > maxX) maxX = sq.x + sq.size;
      if (sq.y + sq.size > maxY) maxY = sq.y + sq.size;
    }
    let totalW = maxX - minX;
    let totalH = maxY - minY;
    // 留边距
    let margin = 20;
    let scale = Math.min((w - margin * 2) / totalW, (h - margin * 2) / totalH);
    // 计算平移量,使螺旋居中
    let offsetX = (w - totalW * scale) / 2 - minX * scale;
    let offsetY = (h - totalH * scale) / 2 - minY * scale;

    // 先画所有正方形(半透明填充)
    for (let i = 0; i < this.squares.length; i++) {
      let sq = this.squares[i];
      let x = offsetX + sq.x * scale;
      let y = offsetY + sq.y * scale;
      let size = sq.size * scale;

      // 正方形填充(淡色,按索引渐变)
      let hue = 200 - i * (170 / (this.squares.length - 1 || 1)); // 蓝到橙
      ctx.fillStyle = `hsla(${hue}, 70%, 60%, 0.2)`;
      ctx.fillRect(x, y, size, size);
      ctx.strokeStyle = '#ccc';
      ctx.lineWidth = 1;
      ctx.strokeRect(x, y, size, size);
    }

    // 再画所有圆弧(粗彩色线)
    for (let i = 0; i < this.squares.length; i++) {
      let sq = this.squares[i];
      let x = offsetX + sq.x * scale;
      let y = offsetY + sq.y * scale;
      let size = sq.size * scale;
      let dir = sq.dir;

      // 确定圆弧圆心、半径、起始角度(弧度)
      let centerX: number, centerY: number, startAngle: number;
      // 圆弧的圆心在正方形的某个顶点
      if (dir === 0) { // 朝上,圆心在正方形左下角,画左上角到右上角? 0到90度
        centerX = x;
        centerY = y + size;
        startAngle = 0;
      } else if (dir === 1) { // 朝右,圆心在正方形左上角,90到180
        centerX = x;
        centerY = y;
        startAngle = Math.PI / 2;
      } else if (dir === 2) { // 朝下,圆心在正方形右上角,180到270
        centerX = x + size;
        centerY = y;
        startAngle = Math.PI;
      } else { // 朝左,圆心在正方形右下角,270到360
        centerX = x + size;
        centerY = y + size;
        startAngle = Math.PI * 1.5;
      }

      let radius = size;
      let endAngle = startAngle + Math.PI / 2;
      let hue = 200 - i * (170 / (this.squares.length - 1 || 1));
      ctx.strokeStyle = `hsl(${hue}, 80%, 50%)`;
      ctx.lineWidth = 3;
      ctx.beginPath();
      ctx.arc(centerX, centerY, radius, startAngle, endAngle, false);
      ctx.stroke();
    }
  }

  // 滑块改变时重建数据并重绘
  private onDepthChange(value: number): void {
    this.depth = value;
    this.buildSpiral();
    this.drawSpiral();
  }

  build() {
    Column() {
      Text('斐波那契螺旋线')
        .fontSize(24)
        .fontWeight(FontWeight.Bold)
        .margin({ top: 20, bottom: 8 })

      Text('近似黄金螺旋 · 每块正方形外接四分之一圆弧')
        .fontSize(14)
        .fontColor('#666')
        .margin({ bottom: 15 })

      Canvas()
        .width('100%')
        .height(380)
        .backgroundColor('#FAFAFA')
        .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: 2,
          max: 10,
          step: 1,
          style: SliderStyle.OutSet
        })
          .layoutWeight(1)
          .onChange((value: number) => {
            this.onDepthChange(value);
          })
      }
      .width('90%')
      .margin({ top: 12, bottom: 8 })

      Text('数列:' + this.fib.join(', '))
        .fontSize(14)
        .fontColor('#555')
        .width('90%')
        .textAlign(TextAlign.Center)
        .margin({ bottom: 10 })

      Text('💡 斐波那契数列:1,1,2,3,5,8... 相邻项比值趋近黄金比例 φ≈1.618')
        .fontSize(12)
        .fontColor('#999')
        .width('90%')
        .textAlign(TextAlign.Center)
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#FFFFFF')
  }
}

代码里清晰分了几个步骤:生成数列、计算正方形坐标和朝向、缩放居中、绘制正方形与圆弧。用 HSL 色相变化给每块正方形和圆弧上了渐变色,从冷到暖。调滑块时重建整个序列并重新绘制。

运行效果

粘贴代码点 Run,Pura X Max 模拟器上出现白底页面,默认迭代 6 次,画布中央铺开半透明的蓝紫色方块,叠上粗粗的彩色圆弧,一条螺旋从中心往外卷,方块一个接一个变大。数列显示栏里写着"1, 1, 2, 3, 5, 8"。拖动滑块减到 2,只留两小方块和一小段圆弧;拖到 10,螺旋骤然扩展开,方块层层包裹,色彩从深蓝渐变到橙红,像一只暖色调的鹦鹉螺。画布随滑块丝滑刷新,没有延迟。

总结

这个螺旋生成器代码量不大,却串起了几个挺有意思的知识点:

  • 斐波那契数列与黄金比例:从递推式到可视化,直观感受数学和大自然的默契。
  • 几何构造思维:把抽象的数列翻译成正方形的摆放规则和圆弧连接方式,这是数值模拟到几何表达的关键一步。
  • Canvas 绘图技巧 :坐标平移与缩放让图形自适应屏幕、HSL 颜色过渡让图形生动、arc 方法画精确圆弧。
  • 状态驱动 UI@State 变量绑定滑块值,改变时触发数据重建和 Canvas 重绘,整个流程干净利落。

把这个底子稍微改改,就可以生成等角螺线、阿基米德螺线,或者做成一个抽象的数学艺术生成器。代码的骨架已搭好,剩下的创造力就交给好奇心去发挥了。

相关推荐
:mnong1 小时前
跟着 Sesame Robot 项目学习
人工智能·robot·esp
AI算法沐枫1 小时前
机器学习到底是什么?
人工智能·python·深度学习·机器学习·数据挖掘·大模型·#ai
ai产品老杨2 小时前
解耦异构算力:基于 Docker 与边缘计算的企业级 AI 视频管理平台架构演进(支持 GB28181/RTSP 与源码交付)
人工智能·docker·边缘计算
无极低码2 小时前
# 认知错位与能力边界:AI在编程与现实落地中的真实困境
人工智能
wordbaby2 小时前
React Native 新架构落地鸿蒙:跨三端政务级应用的工程实践与深度复盘
前端·react native·harmonyos
甲维斯2 小时前
骚操作2 !把DeepSeek接入Claude桌面版!
人工智能
高洁012 小时前
知识图谱:AI的超级大脑
人工智能·python·数据挖掘·知识图谱
Raink老师2 小时前
【AI面试临阵磨枪-62】设计基于 RAG 的内部知识库问答平台(多租户、权限、文件上传、实时更新)
人工智能·面试·职场和发展
小丶舟2 小时前
AI Agent的“localhost终结“:云端开发环境大革命
人工智能·ai编程