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

路径操作:从“画线”到“画形状”
HarmonyOS NEXT 的 Canvas 绘图能力里,Path 对象是一个容易被低估的 API。不少人初次接触时,习惯用多个 moveTo + lineTo 拼接多边形,或者用多个 arc 拼合圆弧。这种做法虽然能跑,但代码会迅速膨胀,且复用性差——画个五角星要写几十行重复的坐标计算。
Path 解决的正是这类问题:它提供了一套独立的“绘图指令集合”,你可以先定义好路径的轮廓(直线、贝塞尔曲线、弧线),再统一填充或描边。更重要的是,Path 支持路径复用——同一个 Path 对象可以被多次绘制,配合 translate / rotate 能轻松实现复杂图形的组合。
这篇文章通过三个典型图形(星形、心形、平滑曲线)来演示 Path 的核心用法,重点展示 moveTo、lineTo、quadTo、cubicTo 等命令的实际场景。代码全部基于 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 个内顶点),然后通过 moveTo 和 lineTo 串联成一条闭合路径。
// 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);
}
调用示例:在 onReady 中 this.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() 创建后,所有 moveTo、lineTo 等指令都会追加到该路径对象中。复用同一个 Path 对象时,如果不先清空,它保留上次的所有指令。
解决:两种方案:
- 每次重新创建
Path对象(简单,但 GC 压力大) - 使用
path.reset()清空路径(推荐,减少对象创建)
// 清空路径后复用
path.reset();
path.moveTo(...);
// ... 添加新指令
特别注意:reset() 是 API 11 新增方法,如果在 API 10 及之前版本,需要每次 new Path()。
最佳实践
-
将 Path 创建与指令定义分离到独立函数:不要在
onReady中写大段路径代码。抽取成createStarPath(cx, cy, ...): Path这样的纯函数,方便测试和复用。原因:路径生成逻辑常常与绘图上下文无关,分离后更容易在不同 Canvas 间复用。 -
填充和描边使用相同的 Path 对象:先调
stroke(path)再调fill(path)可以共用同一个路径对象,避免重复计算。官方示例经常先 fill 后 stroke,但实际项目里先 stroke 再 fill 效果无差异,建议统一顺序以减少混淆。 -
性能敏感时使用
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 的 width 和 height 属性不为 0。
Q:绘制出来的路径锯齿感明显,如何抗锯齿?
A:ArkGraphics 2D 默认开启了抗锯齿。如果仍然出现锯齿,检查是否设置了过大的 lineWidth(比如小于 0.5px),或者 Canvas 的物理分辨率较高(需要适配 pixelRatio)。常见做法是在绘制前缩放上下文:ctx.scale(pixelRatio, pixelRatio)。
Q:多个 Path 对象一起绘制时,为什么最后一个路径会覆盖前面的?
A:这是正常的绘制顺序。stroke 和 fill 会按照调用顺序叠加。如果需要先绘制下层路径,再上层,请调整代码调用顺序。
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 抗锯齿算法略有差异,建议以真机最终效果为准。
更多推荐

所有评论(0)