前言

事情是这样的。一个朋友快过生日,我想送点不一样的。贺卡太俗,红包太冷,琢磨半天,忽然想起大学高数课上老师随手在黑板上画过一条心形曲线。那条公式我至今记得:x = 16 sin³ t, y = 13 cos t - 5 cos 2t - 2 cos 3t - cos 4t。当年只觉得神奇,现在想想,这不就是现成的“程序员的浪漫”吗?打开 DevEco Studio 6.1.1 Beta1,在 Pura X Max 模拟器上,我花了一晚上把这条公式从数学课本搬进了手机屏幕。这篇文章就是那个晚上的记录。里面不止有代码,还有参数方程怎么变成像素点、Canvas 怎么连点成线、颜色和大小怎么用滑块实时调,以及为什么爱心的形状偏偏长成这样。

一、这条公式是怎么来的

心形曲线在数学上没有唯一的定义,常见的有笛卡尔的 r = a(1 - cosθ) 极坐标方程,也有我们这次用的参数方程。这个 x = 16 sin³ t, y = 13 cos t - 5 cos 2t - 2 cos 3t - cos 4t 其实是基于傅里叶级数的变体,用几个余弦波的叠加来逼近心形的轮廓。t 从 0 扫到 2π,x 和 y 就在平面上走出一条封闭曲线。

仔细观察公式,x 的部分只有 sin³ t,意味着 x 在 t=0 和 π 时为零,在 π/2 时达到最大 16。这决定了心形的左右宽度。y 的部分复杂得多:主项是 13 cos t,保证了一个上下拉长的基本形状;后面的 -5 cos 2t、-2 cos 3t、-cos 4t 都是高阶修正,让顶部凹陷、底部收尖。每个余弦项的频率不同,叠加起来就产生了心形特有的“双峰”和“尖底”。

在代码里,我们不需要理解每一个系数是怎么调出来的,只需要把 t 从 0 到 2π 分成几百等份,逐个算出 (x, y),再用 Canvas 连起来就行。步长越小曲线越光滑,我取了 600 个点,模拟器上画得又快又顺。

二、从数学坐标到手机屏幕

公式算出来的 x 范围大致在 -16 到 16,y 在 -18 到 14 左右(具体可以粗略估算:x 受 16 sin³ t 约束,在 -16 到 16 之间;y 各项的最大最小值叠加,大约在 -18 到 14 之间)。这些数字直接当像素坐标肯定不行——屏幕才几百像素宽,画出来比指甲盖还小。所以必须做坐标变换。

第一步是缩放。我先算出一个缩放比例 scale,让心形在画布上既不太大撑破,也不太小说不清。基本思路是:用画布宽高分别除以心形的理论跨度,取较小的那个比例,再乘上用户调节的大小系数(比如滑块控制 0.5 到 2 倍)。这样心形永远自适应屏幕。

第二步是平移。Canvas 的坐标原点在左上角,而心形的“中心”大约在 (0, 0) 附近(数学坐标)。所以要把原点移到画布中央。代码里就是:

screenX = canvasCenterX + mathX * scale;
screenY = canvasCenterY - mathY * scale;  // 注意 y 轴要翻转,因为屏幕 y 向下

翻转 y 轴这个细节,第一次做很容易忘。数学坐标 y 向上增长,屏幕坐标 y 向下增长,不翻转心形就倒过来了。

有了坐标变换,剩下的就是循环 t,算出每一个屏幕点,用 ctx.lineTo 连起来,再 fillstroke。我选择了填充+描边双保险:填充淡粉色,描边深红色,看起来更立体。

三、HSL 色相滑块

纯红的心形看久了有点单调,我想让朋友能自己调颜色。Canvas 支持 HSL 颜色字符串,色相 h 从 0 到 360 可以走一遍彩虹。我加了一个色相滑块,数值直接拼成 hsl(${hue}, 80%, 60%) 作为填充色,描边用 hsl(${hue}, 90%, 40%) 更深一点,突出边缘。

除了色相,我还给了一个“大小”滑块,实际控制的是缩放系数的倍数。这样用户可以拖动滑块看到心形放大缩小,颜色从红变紫变蓝,互动感一下就上来了。

滑块的 onChange 里直接调用重绘函数,Canvas 清屏后重新计算所有屏幕点并绘制。因为只有几百个点,计算量极小,拖动时没有任何延迟,跟手得很。

四、Canvas 的填充和描边顺序

画心形的时候,如果先描边再填充,填充色会把描边盖掉一半;先填充再描边,边线就完整清晰。所以我先 fillstroke。但是 fill 需要闭合路径,用 ctx.closePath() 或者在循环最后 lineTo 回到起点。我是用 closePath,简单可靠。

另外,Canvas 的 strokeStylefillStyle 可以分别设置,还可以给描边加宽度 lineWidth。我把线宽设为 3 像素,这样在模拟器上看起来清晰又不显粗糙。

五、完整代码

以下代码适配 DevEco Studio 6.1.1 Beta1、SDK22 语法,Pura X Max 模拟器。新建 Empty Ability 项目,把 entry/src/main/ets/pages/Index.ets 全选替换就行。不需要联网,不需要权限。

/*
 * 心形曲线绘制 — 参数方程 + Canvas
 * 环境:DevEco Studio 6.1.1 Beta1,Pura X Max 模拟器,SDK22
 */
import { CanvasRenderingContext2D } from '@ohos.graphics.canvas';

@Entry
@Component
struct Index {
  @State hue: number = 0;          // 色相 0~360
  @State scaleFactor: number = 1;  // 大小倍数 0.5~2

  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.drawHeart();
  }

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

    // 背景
    ctx.fillStyle = '#FFF5F5';
    ctx.fillRect(0, 0, w, h);

    // 计算自适应比例 (数学坐标范围大约 x: -16~16, y: -18~14)
    let mathWidth = 32;   // 16 - (-16)
    let mathHeight = 32;  // 14 - (-18) 取宽松
    let baseScale = Math.min(w / mathWidth, h / mathHeight) * 0.85;
    let scale = baseScale * this.scaleFactor;

    let cx = w / 2;
    let cy = h / 2;

    // 构建路径
    let points: { x: number, y: number }[] = [];
    let steps = 600;
    for (let i = 0; i <= steps; i++) {
      let t = (i / steps) * 2 * Math.PI;
      let x = 16 * Math.pow(Math.sin(t), 3);
      let y = 13 * Math.cos(t) - 5 * Math.cos(2 * t) - 2 * Math.cos(3 * t) - Math.cos(4 * t);
      let sx = cx + x * scale;
      let sy = cy - y * scale;  // y轴翻转
      points.push({ x: sx, y: sy });
    }

    // 绘制
    ctx.beginPath();
    ctx.moveTo(points[0].x, points[0].y);
    for (let i = 1; i < points.length; i++) {
      ctx.lineTo(points[i].x, points[i].y);
    }
    ctx.closePath();

    // 填充颜色 (HSL)
    ctx.fillStyle = `hsl(${this.hue}, 80%, 60%)`;
    ctx.fill();

    // 描边
    ctx.strokeStyle = `hsl(${this.hue}, 90%, 40%)`;
    ctx.lineWidth = 3;
    ctx.stroke();
  }

  // 滑块改变色相
  private onHueChange(value: number): void {
    this.hue = value;
    this.drawHeart();
  }

  // 滑块改变大小
  private onScaleChange(value: number): void {
    this.scaleFactor = value;
    this.drawHeart();
  }

  build() {
    Column() {
      Text('心形曲线')
        .fontSize(28)
        .fontWeight(FontWeight.Bold)
        .margin({ top: 20, bottom: 6 })

      Text('x = 16sin³t,  y = 13cost - 5cos2t - 2cos3t - cos4t')
        .fontSize(13)
        .fontColor('#888')
        .margin({ bottom: 12 })

      // 画布
      Canvas()
        .width('100%')
        .height(350)
        .backgroundColor('#FFF5F5')
        .onReady((event) => {
          let ctx = event.context as CanvasRenderingContext2D;
          this.onCanvasReady(ctx);
        })

      // 色相滑块
      Row() {
        Text('颜色').fontSize(15).width(50)
        Slider({
          value: this.hue,
          min: 0,
          max: 360,
          step: 1,
          style: SliderStyle.OutSet
        })
          .layoutWeight(1)
          .onChange((value: number) => { this.onHueChange(value); })
      }
      .width('88%')
      .margin({ top: 10, bottom: 4 })

      // 大小滑块
      Row() {
        Text('大小').fontSize(15).width(50)
        Slider({
          value: this.scaleFactor,
          min: 0.5,
          max: 2,
          step: 0.1,
          style: SliderStyle.OutSet
        })
          .layoutWeight(1)
          .onChange((value: number) => { this.onScaleChange(value); })
      }
      .width('88%')
      .margin({ bottom: 10 })

      Text('拖动滑块改变颜色和大小')
        .fontSize(13)
        .fontColor('#AAA')
        .margin({ bottom: 6 })

      Text('💡 心形曲线是参数方程的完美应用,融合三角函数的叠加之美')
        .fontSize(12)
        .fontColor('#AAA')
        .width('90%')
        .textAlign(TextAlign.Center)
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#FFFFFF')
  }
}

代码非常精简:drawHeart 里面用参数方程生成路径,填充和描边分两次画。两个 Slider 分别控制色相和缩放系数,每次变化都触发重绘。600 个采样点保证曲线光滑,自适应缩放确保不同屏幕都居中显示。

运行效果

代码粘贴到 DevEco Studio,Run 到 Pura X Max 模拟器上。屏幕上浮现一颗粉红色心形,边缘深红,微微发着光。拖动“颜色”滑块,心形从正红变成橙、黄、绿、青、蓝、紫,再回到红,像霓虹灯在变。拖动“大小”滑块,心形呼大呼小,在淡粉背景上自如伸缩。每一个参数变化都实时反映,Canvas 重绘速度快到感觉不到任何延迟。

总结

这个小小的绘制项目,把几个看似不相关的知识点拧到了一起:

  • 参数方程与图形学:用数学函数描述图形轮廓,理解 t 从 0 到 2π 的扫描过程。
  • 三角函数叠加:高阶余弦项如何修正基础形状,体现了傅里叶级数的思想。
  • Canvas 坐标映射:数学坐标到屏幕坐标的缩放、平移、翻转,是数据可视化的基本功。
  • HSL 色彩空间:用色相控制颜色,比 RGB 更直观,适合需要调色的交互场景。
  • 声明式 UI 与实时交互@State 绑定滑块值,驱动 Canvas 即时重绘,整个过程没有一行手动 DOM 操作。

这颗心虽然是用冰冷的公式算出来的,但它能在手机屏幕上变色、缩放,像一颗活的心脏。送给朋友的时候,我附了一句:“这是一颗用三角波叠加的心,比普通的桃心多几个余弦修正项。”她没听懂,但笑得很开心。这大概就是程序员的浪漫吧——把数学变成看得见的温柔。

Logo

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

更多推荐