前言
不知道你有没有留意过一个现象:现在的手机备忘录里几乎都自带涂鸦功能,开会时随手画个示意图,比打字快得多。但你有没有想过,这个功能背后到底是怎么实现的?手指在玻璃上划过的轨迹,是如何被"翻译"成屏幕上一条条彩色线条的?
这篇文章打算用 HarmonyOS 原生的 Canvas 组件,从零开始搭一个能跑在华为手机模拟器上的简易画板。代码不复杂,但每一步的原理我都会拆开来讲清楚。四个核心功能------画线、换色、调粗细、清空------写完之后,你对 ArkTS 里自定义绘制这件事会有一个非常具体的认知。

开发环境是 DevEco Studio 6.0.2 Release,模拟器用的是华为手机镜像。你可以跟着一步步来,最后一定会得到一个能真正在模拟器里用手指画画的 App。
一、画画之前,先认识两个"角色"
在做任何绘制操作之前,我们需要先搞清楚 HarmonyOS 里 Canvas 的运作模式。它和 Web 端的 Canvas 很像,但封装方式略有不同。鸿蒙里的绘制链条由两个关键对象组成:
第一个是 RenderingContextSettings。
它负责配置绘制的"全局参数",其中最重要的一个是 antialias,也就是抗锯齿。这个值设为 true 之后,你画出来的线条边缘会被自动柔化,不会出现那种像素感很强的锯齿边。对于手绘涂鸦来说,这个开关一定要打开。
第二个是 CanvasRenderingContext2D。
这个对象就是你的"画笔"。所有的绘图动作------画线、填充、清空、变换------都是通过它来调用的。你可以把它想象成一个握在手里的工具箱,需要什么操作就从里面拿什么工具。

创建这两个对象的代码非常简洁:
private settings: RenderingContextSettings = new RenderingContextSettings(true);
private context: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.settings);
之后,我们只需要把这个 context 传给页面上的 Canvas 组件,画板就有了"被绘制"的能力。需要注意的是:Canvas 组件本身只是一个容器,它不知道自己该画什么,所有绘制逻辑都在 context****的方法调用中完成。 这种设计把 UI 布局和绘图逻辑分开了,代码结构会清爽很多。
二、手指滑过屏幕,我们得到了什么
画板的核心交互逻辑很简单:手指按下去的时候开始记录起点,手指滑动的时候不断追加新的坐标点,然后把前后两个点用线连起来。这套流程对应三个触摸事件:
onTouch里的TouchType.Down:手指按下TouchType.Move:手指移动TouchType.Up:手指抬起
但在鸿蒙的 Canvas 组件上监听触摸事件,和我们平时在 Button 或 Text 上监听不太一样。Canvas 提供了专门的 onReady 回调,你需要在回调里拿到 CanvasRenderingContext2D 之后,才能进一步绑定触摸逻辑。
这里有一个容易踩坑的地方:触摸事件给出的坐标是相对整个画布左上角的,而 Canvas 的绘制坐标系原点也在左上角,所以坐标值可以直接拿来用,不需要额外转换。这一点比某些其他框架要友好很多。
三、路径(Path)是怎么把点连成线的
当你在屏幕上从 A 点划到 B 点,程序不是简单地在这两点之间画一条直线就完事了。如果那样做,画出来的线条会是一段一段的折线,转折处会有明显的断点。
正确的做法是利用 路径(Path) 的概念。你可以把路径想象成一支笔在纸上连续移动留下的轨迹,而不是一段一段的线段拼接。
在 Canvas API 里,画一条完整的手绘线分为这样几个步骤:
- beginPath():告诉画布"我要开始画一条新的路径了"
- moveTo(x, y):把笔尖移动到起点位置
- lineTo(x, y):从当前位置画一条直线到目标位置
- stroke():真正把路径渲染出来
手指按下时,我们调用 beginPath() 并用 moveTo() 记录起点。手指移动时,每一次收到新的坐标,就调用 lineTo() 连接上一个点,然后立即 stroke()。手指抬起时,可以再调用一次 stroke() 确保最后一段被绘制完成。
这里有一个性能上的细节:stroke() 这个操作是比较重的,如果每一次移动事件都完整执行一遍 beginPath → moveTo → lineTo → stroke 的流程,当手指快速滑动、事件频率很高的时候,可能会有卡顿感。优化方式是:在 Move 事件里只调用 lineTo() 和 stroke(),不重复调用 beginPath(),因为路径本身是连续追加的。
代码里大概是这样的结构:

case TouchType.Down:
this.context.beginPath();
this.context.moveTo(touchInfo.x, touchInfo.y);
break;
case TouchType.Move:
this.context.lineTo(touchInfo.x, touchInfo.y);
this.context.stroke();
break;
case TouchType.Up:
this.context.lineTo(touchInfo.x, touchInfo.y);
this.context.stroke();
this.context.closePath();
break;
注意最后用了一个 closePath(),这是可选的,它的作用是把路径的终点和起点连接起来形成一个闭合图形。在手绘场景下,我们其实不需要闭合,所以不调用也可以,但习惯上加上也没坏处。
四、画笔的"外观"是怎样被改变的
画板如果只能画黑色的细线,那和备忘录的默认签名功能没什么区别。一个能称得上"小画板"的工具,至少得支持换颜色和调粗细。这两个功能在 Canvas 里是通过设置 context 的属性来实现的。
换颜色 非常简单,只需要在调用 stroke() 之前设置:
this.context.strokeStyle = '#FF5733'; // 橙红色
strokeStyle 接受 CSS 颜色字符串,十六进制、RGB、颜色名称都可以。这样一来,后续所有路径的描边都会使用这个颜色。
调粗细 则对应 lineWidth 属性,单位是像素:
this.context.lineWidth = 5; // 5 像素粗
这两个属性是"状态型"的------你设置一次之后,后续的绘制都会沿用这个值,直到你再次修改它。这也就意味着,当我们通过滑块或者颜色选择器调整参数时,只需要更新 context 对应的属性值即可,不需要在每次绘制时重复设置。
从用户交互的角度看,颜色选择可以用鸿蒙自带的 ColorPicker 组件,粗细调节用 Slider。这两者都是声明式 UI 里非常成熟的控件,监听它们的 onChange 事件,把新值同步到 context 上就行了。
Slider({
value: this.brushSize,
min: 1,
max: 20,
step: 1
})
.onChange((value: number) => {
this.brushSize = value;
this.context.lineWidth = value;
});
颜色选择稍微复杂一点,因为 ColorPicker 返回的是 Color 对象,需要转成字符串:
ColorPicker({
color: Color.Black
})
.onChange((color: Color) => {
this.context.strokeStyle = colorToString(color);
});
五、"清空画布"这件事为什么不是简单的涂白
很多初学者会觉得清空画布很简单------用一个大白色矩形把整个画布盖住不就行了?这个思路在逻辑上没错,但在实现上有更优雅的做法。
Canvas 提供了专门的方法 clearRect(x, y, width, height),它的作用是清除指定矩形区域内的所有像素,让这部分区域恢复成完全透明的初始状态。这个方法的性能远高于用白色矩形去覆盖,因为它直接操作像素缓冲区,不涉及绘制管线的渲染流程。
对于清空整个画布的需求,我们只需要获取画布的宽高,然后调用:
this.context.clearRect(0, 0, this.canvasWidth, this.canvasHeight);
这里有一个小细节需要留意:画布的宽高不是我们随手写的数值,而是需要从 Canvas 组件的尺寸中获取。在鸿蒙里,可以通过 Canvas 组件的 onAreaChange 事件拿到实际渲染区域的宽高,保存下来供后续使用。
另外,清空画布之后,之前设置的 strokeStyle 和 lineWidth 这些状态并不会丢失,它们依然保留在 context 上。这意味着清空之后你继续画,颜色和粗细还是之前设置的样子,符合用户预期。
六、把零件组装起来------完整代码
前面已经把每个功能模块的原理讲清楚了,下面给出完整的页面代码。这份代码可以直接复制到 DevEco Studio 里新建的 ArkTS 页面中运行,记得在 main_pages.json 里把入口页面指向它。

// pages/CanvasBoard.ets
import { drawing } from '@kit.ArkGraphics2D';
@Entry
@Component
struct CanvasBoard {
private settings: RenderingContextSettings = new RenderingContextSettings(true);
private context: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.settings);
@State private brushColor: string = '#000000';
@State private brushSize: number = 5;
@State private canvasWidth: number = 0;
@State private canvasHeight: number = 0;
private colorToHex(color: Color): string {
// 将 Color 对象转换为 #RRGGBB 格式
const r = Math.round(color.red * 255).toString(16).padStart(2, '0');
const g = Math.round(color.green * 255).toString(16).padStart(2, '0');
const b = Math.round(color.blue * 255).toString(16).padStart(2, '0');
return `#${r}${g}${b}`;
}
build() {
Column() {
// 工具栏
Row() {
// 颜色选择
ColorPicker({ color: Color.Black })
.width(40)
.height(40)
.onChange((color: Color) => {
this.brushColor = this.colorToHex(color);
this.context.strokeStyle = this.brushColor;
})
// 粗细滑块
Slider({
value: this.brushSize,
min: 1,
max: 30,
step: 1
})
.width('50%')
.margin({ left: 12, right: 12 })
.onChange((value: number) => {
this.brushSize = value;
this.context.lineWidth = value;
})
// 粗细显示
Text(`${this.brushSize}px`)
.fontSize(14)
.fontColor('#666')
.width(40)
// 清空按钮
Button('清空')
.fontSize(14)
.margin({ left: 12 })
.onClick(() => {
this.context.clearRect(0, 0, this.canvasWidth, this.canvasHeight);
})
}
.width('100%')
.padding(12)
.justifyContent(FlexAlign.Start)
.alignItems(VerticalAlign.Center)
// 画布区域
Canvas(this.context)
.width('100%')
.height('80%')
.backgroundColor('#F5F5F5')
.onReady(() => {
// 初始化画布样式
this.context.strokeStyle = this.brushColor;
this.context.lineWidth = this.brushSize;
this.context.lineCap = 'round';
this.context.lineJoin = 'round';
})
.onAreaChange((oldValue: Area, newValue: Area) => {
// 记录画布实际尺寸
this.canvasWidth = vp2px(newValue.width as number);
this.canvasHeight = vp2px(newValue.height as number);
})
.onTouch((event: TouchEvent) => {
const touchInfo = event.touches[0];
const x = vp2px(touchInfo.x);
const y = vp2px(touchInfo.y);
switch (event.type) {
case TouchType.Down:
this.context.beginPath();
this.context.moveTo(x, y);
break;
case TouchType.Move:
this.context.lineTo(x, y);
this.context.stroke();
break;
case TouchType.Up:
this.context.lineTo(x, y);
this.context.stroke();
this.context.closePath();
break;
}
})
}
.width('100%')
.height('100%')
.backgroundColor('#FFFFFF')
}
}
代码里用到了 vp2px 这个转换函数,因为触摸事件返回的坐标单位是 vp(虚拟像素),而 Canvas 绘制方法接受的坐标单位是 px(物理像素)。在 onTouch 中如果不做转换,在部分设备上会出现偏移。同样地,onAreaChange 里获取的宽高也需要转成 px 再存入 canvasWidth 和 canvasHeight,这样清空操作才能覆盖整块画布。
另外,初始化时还设置了两个容易被忽略但体验影响很大的属性:
lineCap = 'round':让线条的端点变成圆头,这样点一下屏幕的时候会留下一个小圆点,而不是一个难看的方头。lineJoin = 'round':让线条转折处变得平滑,画折线时不会出现尖刺。
运行效果
把代码贴进项目,编译运行,华为手机模拟器上会显示一个白底画板,顶部有一排工具栏。点击颜色圆圈可以调出色盘选色,拖动滑块能看到当前粗细值的变化。手指在画布区域滑动时,会留下对应颜色和粗细的线条,支持连续绘制,中间不会断线。点击"清空"按钮,画布恢复空白,但颜色和粗细设定保持不变。
整个过程没有卡顿,线条边缘平滑,颜色切换即时响应。虽然功能简单,但作为 Canvas 入门的学习案例已经足够完整。

总结
写这个小画板的过程中,我们实际踩了一遍 HarmonyOS 里 Canvas 绘制的完整链路:从创建上下文、监听触摸事件,到路径绘制、属性配置,再到区域清空。这些知识点看似基础,但任何一个做过复杂图形应用的人都知道,图形绘制的底层逻辑万变不离其宗,掌握了路径、状态和事件这三板斧,后面再去碰图表库、动画或者游戏引擎,都不会觉得陌生。
如果你有兴趣继续深入,下一步可以试试给画板加上"撤销"功能------那会涉及到快照保存和状态栈的管理,对理解 Canvas 的性能优化和内存管理会有新的启发。