HarmonyOS技术精讲-ArkGraphics 2D(方舟2D图形服务)第二篇:路径的艺术——形状绘制与路径操作详解

在这里插入图片描述

路径操作:从“画线”到“画形状”

HarmonyOS NEXT 的 Canvas 绘图能力里,Path 对象是一个容易被低估的 API。不少人初次接触时,习惯用多个 moveTo + lineTo 拼接多边形,或者用多个 arc 拼合圆弧。这种做法虽然能跑,但代码会迅速膨胀,且复用性差——画个五角星要写几十行重复的坐标计算。

Path 解决的正是这类问题:它提供了一套独立的“绘图指令集合”,你可以先定义好路径的轮廓(直线、贝塞尔曲线、弧线),再统一填充或描边。更重要的是,Path 支持路径复用——同一个 Path 对象可以被多次绘制,配合 translate / rotate 能轻松实现复杂图形的组合。

这篇文章通过三个典型图形(星形、心形、平滑曲线)来演示 Path 的核心用法,重点展示 moveTolineToquadTocubicTo 等命令的实际场景。代码全部基于 ArkTS,在 DevEco Studio 6.1.0 及以上版本可完整运行。

背景:什么场景需要 Path?

场景 适合 Path 不适合 Path
多边形(星形、六边形) ✔️ 一条路径搞定 ❌ 单条线段用 lineTo 即可
贝塞尔曲线(心形、平滑线) ✔️ quadTo/cubicTo 原生支持 ❌ 多段直线拼接会出现尖锐折点
重复绘制相同形状 ✔️ 复用 Path 对象,性能更好 ❌ 每次新建 Path 会频繁 GC
动态修改形状(路径动画) ✔️ 修改 Path 的指令后重绘 ❌ 适合 Canvas2D 的更新机制

环境说明

DevEco Studio 版本:DevEco Studio 6.1.0 及以上
HarmonyOS SDK 版本:HarmonyOS 6.1.0(23) 及以上
目标设备:手机 / 平板

核心实现:用 Path 绘制星形、心形与平滑曲线

1. 星形:多边形的路径复用技巧

星形的难点在于顶点的计算。我们用固定的半径和角度步长生成 10 个顶点(5 个外顶点,5 个内顶点),然后通过 moveTolineTo 串联成一条闭合路径。

// StarPath.ets
import { draw2D } from '@kit.ArkGraphics2D';

@Entry
@Component
struct StarPath {
  private canvasWidth: number = 300;
  private canvasHeight: number = 300;

  build() {
    Column() {
      Canvas(this.canvasWidth, this.canvasHeight)
        .width('100%')
        .height(300)
        .onReady((ctx: draw2D.CanvasRenderingContext2D) => {
          this.drawStar(ctx, 150, 150, 5, 80, 40);
        });
    }
    .padding(10);
  }

  /**
   * 绘制星形
   * @param ctx      渲染上下文
   * @param cx       中心点 x
   * @param cy       中心点 y
   * @param spikes   尖角数量(5 就是五角星)
   * @param outerR   外顶点半径
   * @param innerR   内顶点半径
   */
  drawStar(ctx: draw2D.CanvasRenderingContext2D,
            cx: number, cy: number,
            spikes: number, outerR: number, innerR: number) {
    // 创建一个 Path 对象
    // 注意:path 在 onReady 中创建,避免在 build() 中频繁对象
    const path = new draw2D.Path();
    const step = Math.PI / spikes; // 每个尖角占 π/spikes 弧度

    // 从第一个外点开始
    let angle = -Math.PI / 2; // 从正上方开始
    path.moveTo(
      cx + outerR * Math.cos(angle),
      cy + outerR * Math.sin(angle)
    );

    for (let i = 1; i < spikes * 2; i++) {
      // 交替使用外顶点和内顶点
      const r = (i % 2 === 0) ? outerR : innerR;
      angle += step;
      path.lineTo(
        cx + r * Math.cos(angle),
        cy + r * Math.sin(angle)
      );
    }

    path.closePath(); // 从最后一个点连线回到第一个点

    // 描边:用 2px 红色
    ctx.strokeStyle = '#FF0000';
    ctx.lineWidth = 2;
    ctx.stroke(path);

    // 填充:半透明金色
    ctx.fillStyle = 'rgba(255, 215, 0, 0.5)';
    ctx.fill(path);
  }
}

注意事项

  • Path 对象创建在 onReady 回调中,而不是 build() 里 —— 在 build() 中创建对象会导致每次组件刷新都重新创建,造成不必要的内存抖动。
  • closePath() 会自动用一条直线连接最后一个点和第一个点,不需要手动调用 lineTo

2. 心形:贝塞尔曲线的典型应用

心形曲线很难用直线段拟合。最佳方案是用两段二次贝塞尔曲线(quadTo)组合:左半心形和右半心形各用一条 quadTo 得到平滑弧线。

// HeartPath.ets
drawHeart(ctx: draw2D.CanvasRenderingContext2D, cx: number, cy: number, scale: number) {
  const path = new draw2D.Path();
  // 心形控制点参数(参考标准心形公式,做适当缩放)
  const s = scale;
  // 起点:心形底部尖点(正下方)
  path.moveTo(cx, cy + 0.8 * s);

  // 右半心形:两个控制点,一个在右上,一个在右中
  // 使用 quadTo (二次贝塞尔):cpx, cpy, x, y
  // 参数含义:控制点坐标,终点坐标
  path.quadTo(
    cx + 0.8 * s, cy - 0.3 * s,  // 控制点1(右外侧)
    cx, cy - 0.6 * s             // 终点(顶部中央)
  );

  // 左半心形:对称的控制点
  path.quadTo(
    cx - 0.8 * s, cy - 0.3 * s,
    cx, cy + 0.8 * s
  );

  path.closePath();

  ctx.strokeStyle = '#E91E63';
  ctx.lineWidth = 3;
  ctx.stroke(path);

  ctx.fillStyle = '#FF4081';
  ctx.fill(path);
}

调用示例:在 onReadythis.drawHeart(ctx, 150, 150, 100);

为什么用 quadTo 而不是 cubicTo:心形只需要一组控制点控制每个半边的弧度,二次贝塞尔足够了。cubicTo(三次贝塞尔)有两个控制点,适合需要更精细调整形状的场景(如下面的平滑曲线)。

3. 平滑曲线:通过三次贝塞尔实现过点曲线

如果只有一组离散点,希望绘制一条经过这些点的平滑曲线,可以用 cubicTo 配合样条插值计算控制点。这里给出一个简化版本:假设点集是波浪形,手动设定控制点来生成平滑过渡。

// SmoothCurve.ets
drawSmoothCurve(ctx: draw2D.CanvasRenderingContext2D) {
  const path = new draw2D.Path();
  // 手动定义一组点:从左到右的波浪
  const points = [
    { x: 20, y: 150 },
    { x: 60, y: 80 },
    { x: 100, y: 200 },
    { x: 140, y: 100 },
    { x: 180, y: 180 },
    { x: 220, y: 120 },
    { x: 260, y: 160 },
  ];

  // 起始点
  path.moveTo(points[0].x, points[0].y);

  // 遍历中间点,用 cubicTo 连接
  for (let i = 1; i < points.length - 2; i++) {
    const p0 = points[i];
    const p1 = points[i + 1];
    // 控制点取前一点和后一点的加权平均,使曲线平滑
    const cp1x = p0.x + (p1.x - points[i - 1].x) / 6;
    const cp1y = p0.y + (p1.y - points[i - 1].y) / 6;
    const cp2x = p1.x - (points[i + 2].x - p0.x) / 6;
    const cp2y = p1.y - (points[i + 2].y - p0.y) / 6;
    path.cubicTo(cp1x, cp1y, cp2x, cp2y, p1.x, p1.y);
  }

  // 处理最后一段
  const last = points[points.length - 2];
  const end = points[points.length - 1];
  const cp1x = last.x + (end.x - points[points.length - 3].x) / 6;
  const cp1y = last.y + (end.y - points[points.length - 3].y) / 6;
  const cp2x = end.x - (end.x - last.x) / 6;
  const cp2y = end.y - (end.y - last.y) / 6;
  path.cubicTo(cp1x, cp1y, cp2x, cp2y, end.x, end.y);

  ctx.strokeStyle = '#2196F3';
  ctx.lineWidth = 2;
  ctx.stroke(path);
}

实际效果:这条曲线会自然穿过每个给定的点,且拐点平滑,没有“折线感”。这是贝塞尔曲线在数据可视化或手写板绘制中的常用技巧。

踩坑记录

坑1:closePath 与 stroke 的“不闭合”现象

现象:绘制多边形后调用 closePath(),但 stroke 描边时最后一个线段没有画出,看起来图形缺了一个边。

原因closePath() 的作用是从当前点绘制一条直线到起点,但这条直线是否被描边取决于是否在 stroke 之前调用了 closePath。如果在 stroke 之后才 closePath,描边已经执行,不会自动补充那条连线。此外,closePath 会改变路径的当前点,但不会清除路径本身。

解决始终在 stroke 之前完成所有路径指令并调用 closePath。如果需要绘制开放路径(如 U 形),不要调用 closePath

坑2:Path 对象复用导致绘制残留

现象:用一个 Path 对象在不同位置绘制多个相同形状时,第二次绘制的结果包含了第一次的路径,导致重叠混乱。

原因Path 对象是一个可变的指令序列。调用 draw2D.Path() 创建后,所有 moveTolineTo 等指令都会追加到该路径对象中。复用同一个 Path 对象时,如果不先清空,它保留上次的所有指令。

解决:两种方案:

  • 每次重新创建 Path 对象(简单,但 GC 压力大)
  • 使用 path.reset() 清空路径(推荐,减少对象创建)
// 清空路径后复用
path.reset();
path.moveTo(...);
// ... 添加新指令

特别注意reset() 是 API 11 新增方法,如果在 API 10 及之前版本,需要每次 new Path()

最佳实践

  1. 将 Path 创建与指令定义分离到独立函数:不要在 onReady 中写大段路径代码。抽取成 createStarPath(cx, cy, ...): Path 这样的纯函数,方便测试和复用。原因:路径生成逻辑常常与绘图上下文无关,分离后更容易在不同 Canvas 间复用。

  2. 填充和描边使用相同的 Path 对象:先调 stroke(path) 再调 fill(path) 可以共用同一个路径对象,避免重复计算。官方示例经常先 fill 后 stroke,但实际项目里先 stroke 再 fill 效果无差异,建议统一顺序以减少混淆。

  3. 性能敏感时使用 beginPath + closePath 模式:ArkGraphics 2D 中,Path 对象本身不依赖 beginPath 概念(beginPath 是 Canvas2D 标准接口,但在 ArkUI 中通过 ctx.beginPath() 开启新路径,然后使用 ctx.moveTo 等指令)。这里需要明确:本文使用的 draw2D.Path 是独立对象,与 ctx.beginPath 是两套机制。混合使用时容易造成混乱——要么全部用 Path 对象,要么全部用 ctx 的指令流。推荐统一用 Path 对象,因为可以离线缓存路径,且不会被其他绘制操作干扰。

FAQ

Q:为什么模拟器上显示正常,真机不显示?
A:检查 onReady 是否被正确触发。真机上 Canvas 组件可能因为布局延迟而未就绪。建议在 onReady 中添加日志确认,并确保 Canvas 的 widthheight 属性不为 0。

Q:绘制出来的路径锯齿感明显,如何抗锯齿?
A:ArkGraphics 2D 默认开启了抗锯齿。如果仍然出现锯齿,检查是否设置了过大的 lineWidth(比如小于 0.5px),或者 Canvas 的物理分辨率较高(需要适配 pixelRatio)。常见做法是在绘制前缩放上下文:ctx.scale(pixelRatio, pixelRatio)

Q:多个 Path 对象一起绘制时,为什么最后一个路径会覆盖前面的?
A:这是正常的绘制顺序。strokefill 会按照调用顺序叠加。如果需要先绘制下层路径,再上层,请调整代码调用顺序。

Demo 入口

// entry/src/main/ets/pages/Index.ets
import { draw2D } from '@kit.ArkGraphics2D';
import { StarPath } from './StarPath';
import { HeartPath } from './HeartPath';
import { SmoothCurve } from './SmoothCurve';

@Entry
@Component
struct Index {
  build() {
    Scroll() {
      Column({ space: 20 }) {
        Text('星形')
        StarPath()

        Text('心形')
        HeartPath()

        Text('平滑曲线')
        SmoothCurve()
      }
      .width('100%')
      .padding(10)
    }
  }
}

示例代码地址:项目地址


如果你也遇到路径绘制中的“不闭合”或“绘制残留”问题,可以重点检查 closePath 的调用时机和 Path 对象复用前的 reset()。官方文档对 Path 的复用行为描述得比较简略,建议结合实际运行效果测试确认。不同设备上的绘制效果可能因 GPU 抗锯齿算法略有差异,建议以真机最终效果为准。

Logo

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

更多推荐