鸿蒙手写签名板:HarmonyOS Canvas 自定义绘制全攻略




本文由原 5 篇连载(应用设计与架构 / 页面 UI 与画布搭建 / 核心绘制逻辑 / 进阶功能与交互 / 完整代码与运行效果)合并整理为 1 篇完整教程,按以下 5 个部分组织:
- 第 1 部分:Canvas 初识:HarmonyOS 自定义绘制怎么玩——签名板应用架构设计
- 第 2 部分:签名画布搭建:Canvas 组件、RenderingContext 与触摸事件的正确姿势
- 第 3 部分:贝塞尔曲线平滑签名:让手写笔迹告别锯齿折线
- 第 4 部分:签名导出实战:getPixelMap + ImagePacker 把画布变成图片
- 第 5 部分:手写签名板完整代码与运行效果
对应代码:
Canvas_2d/1_手写签名板/SignaturePad.ets(已按 API 24 编写)
Canvas 初识:HarmonyOS 自定义绘制怎么玩——签名板应用架构设计
一、为什么自定义绘制要学 Canvas
ArkUI 提供了丰富的现成组件(Button、Text、List、Image……),这些组件解决的是**「结构化内容」的展示**——文本有文字属性、图片有图片属性、列表有行与列。但遇到自由形状的内容时,预置组件就无能为力了:手写签名、涂鸦、图表、仪表盘、粒子特效,这些画面的「长什么样」完全由数据与算法决定,无法用几个属性拼出来。这时就需要自定义绘制。
可以这样理解 ArkUI 的两层能力:
| 能力层 | 代表组件 | 适用场景 | 特点 |
|---|---|---|---|
| 声明式 UI | Button / Text / List | 表单、列表、详情页 | 声明「有什么」,系统负责渲染 |
| 命令式绘制 | Canvas + CanvasRenderingContext2D | 图形、图表、手绘、特效 | 声明「怎么画」,逐条指令执行 |
HarmonyOS 的自定义绘制有两条路:
- Canvas 组件 + CanvasRenderingContext2D:命令式绘制,像在纸上画画——
moveTo落笔、lineTo走线、fillRect涂色、arc画弧。灵活、性能好,是绘制型应用(本系列 5 个应用)的主角; - 自定义组件 onDraw:在自定义组件里拿到 Canvas 绘制上下文直接画,适合组件级定制,把绘制逻辑封装进一个可复用的组件。
本系列全部使用方案 1,目标是把 CanvasRenderingContext2D 的核心 API 讲透。从最贴近用户直觉的「手写签名」开始,逐步升级到涂鸦画板、数据图表、时钟仪表盘、粒子特效,覆盖路径绘制、合成模式、动画循环、像素导出等全部核心知识点。
为什么第一个应用选签名板
选签名板作为系列开篇有三个理由:
- API 覆盖面恰到好处:只用到了路径绘制(moveTo / quadraticCurveTo / stroke)与触摸事件,没有多余的复杂度,适合建立「数据 → 绘制」的心智模型;
- 交互闭环完整:采集(触摸)→ 绘制(贝塞尔)→ 操作(撤销/清空)→ 输出(导出图片),一条链路走完一个绘制应用的全部环节;
- 数据模型典型:签名板是典型的「图形 = 数据的投影」场景,这种模型是后续 4 个应用的基础——涂鸦画板是它的多色升级版,图表是它的数据可视化版,粒子特效是它的动画版。
二、签名板的需求分析
手写签名板要解决什么问题?把「用手指写字」变成「可保存的图片」。需求拆解:
| 需求 | 说明 | 技术落点 |
|---|---|---|
| 触摸书写 | 手指按下 → 拖动 → 抬起,形成一笔笔迹 | onTouch 事件三态采集 |
| 笔迹平滑 | 手指采样是离散点,直接连线会有折角 | 二次贝塞尔曲线(中点法) |
| 撤销 | 写错了,回退最后一笔 | 数据栈 pop + 重绘 |
| 清空 | 全部重写 | 数据数组置空 + 重绘 |
| 导出图片 | 把画布内容变成 PNG 文件,可发送给他人 | getPixelMap + ImagePacker + fileIo |
技术选型:全部落在 CanvasRenderingContext2D 上——触摸事件采集坐标点、路径 API 绘制、像素 API 导出,不引入任何第三方库。这也是刻意为之:自定义绘制的核心能力都内置于框架,学会它就能做出大量应用,不需要依赖外部图形库。
为什么不用预置组件模拟? 有人会说「签名不就是一条线吗,用 Image 显示一张预先生成的图不就行了」——但那是静态图,无法响应实时书写。签名板的核心是实时性:手指画到哪里,笔迹必须立刻出现在哪里。只有 Canvas 命令式绘制能在触摸事件的回调里同步完成「取点 → 画线」这一闭环。
需求里的边界情况
需求分析时最容易遗漏的是「不画线的时候怎么办」:
- 轻点一下:按下立即抬起,只产生 1~2 个采样点,没有「笔画」可言,但要画出一个点(或极短线段),不能留白;
- 快速连写:手指快速划动时 Move 采样可能稀疏,两点间距拉大,直接连线会「跳」,需要贝塞尔平滑兜底;
- 画布未就绪:onReady 之前触摸事件理论上不会来,但严谨起见 redraw() 里要有 canvasW === 0 守卫;
- 反复撤销到空:撤销不能越界(数组越界或弹出空气),要在入口判断 length === 0;
- 导出时恰好无内容:空画布导出没有意义,要在导出入口提示用户。
这些边界情况会在后续各篇的实现代码里逐一体现,现在先在需求层面把它们「登记」下来。
三、数据模型:一笔 = 一串点
绘制型应用的关键是把「画面」抽象成数据。签名板的画面就是若干「笔画」的叠加:
/** 笔迹点 */
interface Point {
x: number;
y: number;
}
/** 一笔笔画(点序列 + 颜色 + 粗细) */
interface Stroke {
points: Point[];
color: string;
width: number;
}
模型推导:手指每一帧移动都会产生一个采样点(x, y);从按下到抬起的连续采样点组成一笔 Stroke;画布是 Stroke[] 的叠加。有了这个模型:
- 撤销 = 弹出最后一个 Stroke 再重绘;
- 清空 = 数组置空再重绘;
- 导出 = 把数组绘制到像素上再抓取。
一切操作都变成对数据的操作——这正是自定义绘制的设计精髓:画面只是数据的「投影」,操作数据就等于操作画面,天然正确,不需要记录任何「画布反操作」。
这个模型还有一个隐藏好处:数据与渲染解耦。笔迹数据可以序列化保存到本地(存 JSON),下次打开应用再恢复;可以发给服务器做签名比对;可以导出成矢量描述。画面只是数据的一种「视图」而已。
为什么 Stroke 要带颜色和宽度
虽然签名板只用了黑色 3vp 一种笔迹,但把颜色和宽度放进数据模型,是为了让模型「一次设计、处处可用」:
- 第 2 个应用(涂鸦画板)直接复用这个模型,加上调色盘就能画彩色画;
- 后续若要做「笔锋效果」(起笔细、收笔粗),也只需要在 Stroke 里扩展 width 数组即可。
设计数据模型时的一个经验法则:把「绘制时的视觉属性」和「图形的几何数据」放在一起。这样重绘时一笔就是一个自包含的单元,绘制函数 drawStroke(s) 只依赖 s 自身,不依赖任何外部状态。
补充:坐标系的三个关键约定
签名板的一切绘制都建立在同一个坐标系上,这里提前把三个约定讲清楚,后续所有应用都遵守它们:
- 原点在左上角:Canvas 坐标原点 (0, 0) 位于组件左上角,x 轴向右增大,y 轴向下增大——这与数学坐标系(y 向上)相反,初学时容易画反,务必先建立「屏幕坐标」的直觉;
- 单位是 vp(虚拟像素):触摸坐标
t.x / t.y、绘制坐标ctx.width / ctx.height都是 vp,与设备物理像素无关。vp 的好处是不同分辨率设备上画面比例一致,无需关心屏幕密度换算; - 绘制坐标 = 触摸坐标:触摸事件返回的坐标就是组件内的 vp 坐标,可以直接喂给绘制 API,中间不需要任何换算。这三者的统一是本系列所有应用「从采集到绘制零转换」的根基。
四、架构分层:数据 → 重绘 → 交互
签名板页面分成三层:
┌─────────────────────────────┐
│ UI 层:标题栏 / 画布 / 操作栏 │
│ @State 只管文案(笔数、保存数)│
├─────────────────────────────┤
│ 数据层:strokes: Stroke[] │
│ 非状态量,主动驱动重绘 │
├─────────────────────────────┤
│ 绘制层:redraw() 全量重绘 │
│ drawStroke() 单笔贝塞尔绘制 │
└─────────────────────────────┘
这个分层的核心思想是单向数据流:
触摸事件 → 修改数据(strokes) → 调用绘制(redraw) → 更新 UI 文案(@State)
一个容易踩的坑:Canvas 的重绘不等价于状态变更
@State 更新会触发 build 重跑,但 Canvas 的内容不会自动重画——Canvas 组件是「命令式绘制」的容器,它的像素内容由你的绘制代码决定,build 重跑只会重新应用组件属性(宽高、背景色),不会重放你的 ctx.xxx() 调用。
所以必须手动调用绘制逻辑。这里把 strokes 设计成普通成员变量(不挂 @State),每次数据变化后主动调用 redraw()——数据与绘制同在一个同步流程里,不会出现「UI 文案已变、画面还差一帧」的撕裂感。
@State 只保留 UI 文案:strokeCount(笔数)、savedCount(保存数)、savedTip(保存路径提示),它们随重绘同步更新,文案永远和画面一致。
为什么要这样分?因为两类数据有本质区别:
| 数据 | 变化源 | 消费方 | 更新方式 |
|---|---|---|---|
| strokes(笔迹) | 触摸事件 | Canvas 绘制 | 主动调 redraw() |
| strokeCount(文案) | strokes 变化 | Text 组件 | @State 自动刷新 |
strokes 变了,如果只更新文案而不重绘,画面就「撒谎」;如果只重绘而不更新文案,文字就「撒谎」。把两者绑定在同一个函数里调用,就永远一致。
关于状态设计的常见误区
有些初学者会把 strokes 也挂上 @State,然后期待 build 重跑时自动重绘——这是行不通的,原因有二:
- @State 是浅观察:对数组的
push、pop操作,ArkUI 的状态管理默认不触发刷新(需要 @Observed / 数组整体赋值等高级用法); - 即使触发了 build 重跑,Canvas 也不会重绘:绘制是命令式的,需要显式调用。
所以签名板采用「普通成员变量 + 手动 redraw」是最直接、最可控的方案。等到涂鸦画板引入撤销重做时,你会发现这个模型让「操作栈」的实现也变得非常简单。
补充:状态管理的边界在哪里
有的开发者会走向另一个极端——把所有数据都塞进 @State。这里给出一个简单可操作的判断标准:
- 需要驱动 UI 组件(Text/Button)变化的数据 → @State;
- 只需要驱动 Canvas 画面变化的数据 → 普通成员 + 手动 redraw()。
签名板里 strokes 只驱动 Canvas,所以走后者;strokeCount 要显示在标题栏,所以走前者。按这个标准划分,每个数据都只有一个「消费方」,状态设计就不会乱。
五、绘制管线:redraw() 的职责
private redraw(): void {
if (this.canvasW === 0) {
return;
}
this.ctx.clearRect(0, 0, this.canvasW, this.canvasH);
this.ctx.globalCompositeOperation = 'source-over';
for (const s of this.strokes) {
this.drawStroke(s);
}
}
管线三步:清屏(clearRect)→ 复位合成模式(source-over)→ 逐笔重绘。每一步都有讲究:
- clearRect:画布是「一次性画布」,不先擦除就直接画会叠加残影,所以每次重绘必须先清空。
clearRect(0, 0, w, h)清空的是绘制内容层,不影响组件背景色; - globalCompositeOperation:先用
source-over兜底复位——本应用只用普通覆盖,但后续涂鸦画板的橡皮擦会把它换成destination-out,这里先养成「每次重绘前显式复位」的习惯。如果不复位,一旦某次绘制改了合成模式,后续所有重绘都会受影响; - 逐笔重绘:每笔独立
beginPath,互不污染。beginPath的作用是「开新路径」——把当前累积的路径命令清空,重新开始记录。如果漏掉它,所有笔画会连成一条路径,lineWidth 等设置也会互相干扰。
为什么全量重绘而不是增量画
签名场景笔迹量小(几十笔 × 每笔几十个点),全量重绘一帧在毫秒级;换来的是实现简单、逻辑纯粹——「画面永远等于数据的投影」,重绘是幂等的,任何时刻调用 redraw() 结果都一致,这对后续撤销/清空/页面返回重绘非常友好。
增量绘制的诱惑与陷阱:有些人会想「新点来了,只画新的一段,何必全部重画?」——这在签名场景是可行的(性能更好),但代价是状态管理复杂化:撤销时必须知道「哪些像素是最后一笔画的」;清空时必须整张清;换颜色时旧笔迹不能受影响。全量重绘把这些复杂性全部消灭:每次画面 = 数据的完整投影,逻辑上无懈可击。
什么时候该放弃全量重绘
全量重绘的瓶颈在对象数量:每帧要遍历所有对象并逐个执行绘制命令。签名板几十笔完全无压力;但当对象数以万计(粒子特效)、且每帧都在变化时,全量重绘可能超过 16ms 的帧预算,就需要增量策略(如只更新变化区域、分层缓存)。第 5 个应用会专门讨论性能优化。
绘制调用的频率问题
签名书写时每帧(约 16ms)都会触发一次 redraw()。全量重绘意味着每帧要执行「清屏 + 数十次路径构建 + 数十次 stroke 调用」,这在现代设备上 CPU 开销极小(几十笔 × 每笔几十点,路径光栅化总量不过几千条线段)。但有一个容易被忽视的细节:每次 stroke() 调用都会触发一次 GPU 提交,笔数越多提交次数越多。签名场景提交次数 <100 次/帧,完全在正常范围;如果你发现真机上有轻微卡顿,第一排查点就是「是否有隐藏的重绘被触发」(例如误把 strokes 挂上 @State 导致每次 push 都触发 build)。
六、页面骨架
@Entry
@Component
struct SignaturePad {
private settings: RenderingContextSettings = new RenderingContextSettings(true);
private ctx: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.settings);
build() {
Column() {
// 标题栏:签名板 + 笔数/保存数
// 画布:Canvas(this.ctx).onReady(...).onTouch(...)
// 操作栏:撤销 / 清空 / 保存图片
// 保存提示:savedTip
}
.width('100%').height('100%').backgroundColor('#F8FAFC')
}
}
两个初始化关键点(后续每篇都会用到,先记牢):
- RenderingContextSettings(true):开启抗锯齿,否则斜线、圆弧会有明显锯齿。true 表示「允许抗锯齿渲染」,这是绘制型应用的基本配置;
- Canvas(this.ctx):把上下文绑定到组件,绘制命令直接作用在组件上;真正的绘制时机是
onReady回调——此时组件已布局完成,ctx.width / ctx.height才拿得到真实画布尺寸。
Canvas 组件属性 vs 绘制上下文属性
很多新手会混淆两类属性,这里明确区分:
| 类别 | 例子 | 作用对象 | 生命周期 |
|---|---|---|---|
| 组件属性 | width / height / backgroundColor / borderRadius | 组件本身(容器) | build 时应用 |
| 上下文属性 | fillStyle / strokeStyle / lineWidth | 绘制内容(像素) | 绘制时生效 |
白底是组件背景(backgroundColor),笔迹是上下文绘制的内容,两者位于不同的层。这一点在第 1-4 篇导出图片时会再次遇到——getPixelMap 抓取的是绘制内容层,组件背景不在其中。
补充:本应用用到的 CanvasRenderingContext2D API 全景
给读者一张「本应用 API 地图」,后面每用到一个新 API 都可以回到这张表对号入座:
| API | 作用 | 本应用使用位置 |
|---|---|---|
| moveTo(x, y) | 移动画笔到指定点(不画线) | 每笔起点 |
| lineTo(x, y) | 从当前点到指定点画直线 | 点数少于 3 时的退化分支 |
| quadraticCurveTo(cpx, cpy, x, y) | 二次贝塞尔曲线 | 中点法平滑的核心 |
| beginPath() | 开始新路径 | 每笔绘制前 |
| stroke() | 描边当前路径 | 每笔绘制末尾 |
| clearRect(x, y, w, h) | 清除矩形区域 | redraw 清屏 |
| fillRect(x, y, w, h) | 填充矩形 | 导出白底铺垫 |
| strokeStyle / lineWidth | 描边颜色 / 粗细 | 笔迹样式 |
| lineCap / lineJoin | 线帽 / 线角形状 | 笔迹质感 |
| globalCompositeOperation | 合成模式 | 橡皮擦的基础 |
| getPixelMap(x, y, w, h) | 抓取像素快照 | 导出第一步 |
这张表也是本系列 5 个应用的「公共字典」:涂鸦画板新增橡皮擦(destination-out)与更多颜色;图表新增 fill / arc / rotate / translate;时钟新增 save / restore;粒子新增阴影与更多变换。熟悉这张表,后续每篇只需关注「增量 API」。
七、运行方式
SignaturePad.ets 是自包含的 @Entry 页面,复制到 entry/src/main/ets/pages/ 并在 main_pages.json 注册后即可跳转运行(详见 Canvas_2d/README.md)。
运行前提:
- DevEco Studio 5.0+,API 24(HarmonyOS NEXT)或更高;
- 模拟器或真机(真机触摸体验更真实,模拟器用鼠标拖拽也能模拟触摸);
- 无需申请任何权限——签名文件写到应用沙箱的 cacheDir,属于应用私有空间。
八、文章小结
本节完成了签名板的设计阶段:需求分析 → 数据模型(Stroke 点序列)→ 分层架构(UI/@State 文案 + 数据层 + 绘制层)→ 绘制管线(clearRect → 复位 → 逐笔重绘)。核心思想是「画面 = 数据的投影」:笔画是可撤销、可重绘的纯数据,Canvas 只是把数据画出来的输出设备。
回顾设计阶段的关键决策:
| 决策点 | 选择 | 理由 |
|---|---|---|
| 绘制方案 | CanvasRenderingContext2D | 命令式、实时、灵活 |
| 数据模型 | Stroke[](点序列) | 一切操作 = 数据操作 |
| strokes 是否 @State | 否,普通成员 | @State 不触发 Canvas 重绘 |
| 重绘策略 | 全量重绘 | 幂等、简单、量小无压力 |
| UI 文案 | @State | 随重绘同步更新 |
下一节搭建画布 UI:Canvas 组件属性、onReady 时机、onTouch 触摸事件坐标——解决「手指按下去,点在哪儿」。
九、动手练习
学完本节,建议动手验证以下三个点,每个都能加深对「数据驱动绘制」的理解:
- 给 strokes 加 @State 试试:把
private strokes改成@State strokes,运行后书写,观察笔迹是否「时有时无」或出现奇怪的重绘行为,从而亲手验证「@State 不驱动 Canvas 重绘」这一结论; - 去掉 redraw 里的 clearRect:注释掉清屏代码,连续书写几笔,观察笔迹叠加残影的现象,理解「一次性画布」的含义;
- 去掉 globalCompositeOperation 复位:本应用没有改合成模式所以看不出区别,但你可以预先把第 2 篇的橡皮擦代码粘进来,体验「不复位 → 重绘被污染」的现场。
十、FAQ 扩展
Q1:为什么 strokes 不挂 @State?
因为 @State 变更走的是 build 渲染管线,而 Canvas 内容由绘制代码决定,两者不同步会造成「画面滞后于状态」。把 strokes 设为普通成员 + 手动 redraw(),数据和画面在同一同步调用栈内更新,逻辑更直接。
Q2:全量重绘会不会卡?
签名板笔迹总量有限(<100 笔),且每笔只做路径描边,现代设备一帧远小于 16ms。真正的瓶颈是「每帧全量重绘」在粒子场景(数万对象)才需要考虑增量优化,那是第 5 个应用的主题。
Q3:clearRect 擦得干净吗?
能。clearRect 清空的是「绘制内容」层,Canvas 组件的 backgroundColor(白底)属于组件属性,不受影响,所以白底画布清屏后依然是白的——这也保证了导出 PNG 时背景是纯白。
Q4:为什么还要显式复位 globalCompositeOperation?
本应用从不修改合成模式,复位看起来多余,但这是「防御性编程」:drawStroke 里如果未来引入特殊合成(如橡皮擦 destination-out),忘记复位会导致所有重绘被污染。在每次 redraw 开头复位,保证「管线入口状态永远已知」。
Q5:签名数据可以保存到本地吗?
可以。Stroke 是纯数据(数组 + 字符串 + 数字),用 JSON.stringify 序列化后存到应用沙箱即可,下次启动反序列化恢复。这也是数据驱动模型的额外红利——「画面」可以完整地变成「数据」保存。
Q6:为什么要用 @Entry 页面而不是子组件?
本系列 5 个应用都是独立 Demo,用 @Entry 可以直接在 main_pages.json 注册路由、单独运行,方便对照学习。实际项目中更常见的做法是把绘制逻辑抽成 @Component 子组件(传数据、收事件),架构上是一样的。
Q7:坐标为什么 y 轴向下?
这是屏幕坐标系的通用约定,源自图形学与光栅扫描的顺序(从上到下逐行扫描)。只要统一使用,绘制结果就不会出错;唯一的风险点是「把数学坐标习惯带进来」——比如画「向上」的图形时忘了 y 要减小。
Q8:vp 和 px 有什么区别?
vp(virtual pixel)是逻辑尺寸,与屏幕密度无关;px 是物理像素。一个 vp 在不同密度的设备上对应的物理像素数不同(2x / 3x 屏)。Canvas 绘制与触摸坐标默认都是 vp,开发者一般不需要手动换算;只有做像素级操作(如 getPixelMap 导出大图)时才需要关注 vp 与 px 的关系。
Q9:多应用间如何复用这套架构?
把「数据层 + 绘制层」抽成一个独立的 @Component(如 SignatureCanvas),暴露 clearAll()、undo()、getPixelMap() 等接口,UI 层只管调用。后续涂鸦画板、图表的画布部分都可以按这个模式组织,只是绘制函数和数据类型不同。
签名画布搭建:Canvas 组件、RenderingContext 与触摸事件的正确姿势
一、设计理念:画布是「白纸」,一切围绕它转
签名板的 UI 目标是白纸 + 工具条:一大块白底画布让人「想写字」,下方三个按钮处理「撤销 / 清空 / 保存」。页面结构:
- 标题栏:✍️ 手写签名板 + 笔数·保存数 + 刷新;
- 签名画布:白色圆角卡片,占主视觉;
- 操作栏:撤销(灰)/ 清空(红)/ 保存(蓝);
- 保存提示:显示导出文件路径。
为什么 UI 要这么设计
从用户视角看,签名板的使用流程极其简单:拿起来就写。因此 UI 必须做到零学习成本:
- 白底:强烈的「纸」的隐喻,用户不需要任何说明就知道这里可以写字;
- 操作按钮放底部:单手拇指可及的区域,符合手机握持习惯;
- 保存提示实时可见:导出后立刻给出路径反馈,用户知道文件去哪了,这是「可分发」信任感的关键。
UI 设计服务于交互流程:写(画布)→ 改(撤销/清空)→ 带走(保存)。按钮顺序也按这个心智模型排列:撤销在最左(「刚才写错了」是最频繁的操作),清空居中(「全部重来」),保存最右(「完成,输出」)。
页面代码骨架
build() {
Column() {
// 1. 标题栏
Row() {
Text('✍️ 手写签名板').fontSize(18).fontWeight(FontWeight.Bold)
Blank()
Text(`笔数 ${this.strokeCount} · 保存 ${this.savedCount}`).fontSize(12).fontColor('#6B7280')
Text('↻').fontSize(16).onClick(() => this.redraw())
}
.width('94%').padding({ top: 12, bottom: 12 })
// 2. 签名画布
Canvas(this.ctx)
.width('94%').height(360)
.backgroundColor('#FFFFFF')
.borderRadius(14)
.border({ width: 1, color: '#E5E7EB' })
.onReady(() => { this.canvasW = this.ctx.width; this.canvasH = this.ctx.height; this.redraw(); })
.onTouch((e: TouchEvent) => this.handleTouch(e))
// 3. 操作栏
Row({ space: 10 }) {
Button('🔄 撤销').backgroundColor('#9CA3AF').onClick(() => this.undo())
Button('🧹 清空').backgroundColor('#EF4444').onClick(() => this.clearAll())
Button('💾 保存图片').backgroundColor('#3B82F6').onClick(() => this.saveImage())
}
.width('94%').padding({ top: 16 })
// 4. 保存提示
if (this.savedTip !== '') {
Text(this.savedTip).fontSize(11).fontColor('#6B7280').width('94%')
.margin({ top: 10 }).textAlign(TextAlign.Center)
}
}
.width('100%').height('100%').backgroundColor('#F8FAFC')
}
这个骨架把「装饰性布局」和「功能性回调」分离得清清楚楚:Canvas 只负责两件事——onReady 初始化尺寸并首绘、onTouch 转发触摸事件。其他一切由业务方法处理。
二、画布上下文:RenderingContextSettings 与 CanvasRenderingContext2D
HarmonyOS 的 Canvas 是「上下文驱动」的:组件本身不持有绘制能力,真正干活的是绑定的 CanvasRenderingContext2D。
private settings: RenderingContextSettings = new RenderingContextSettings(true);
private ctx: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.settings);
两个构造器的分工:
RenderingContextSettings(true):绘制设置,true = 开启抗锯齿。不开的话,斜线和圆弧边缘会出现明显的锯齿「毛刺」,签名这种曲线场景必须开;CanvasRenderingContext2D(settings):绘制上下文,后续ctx.moveTo / ctx.stroke / ctx.fillRect全是它。
绑定到组件:
Canvas(this.ctx)
.width('94%')
.height(360)
.backgroundColor('#FFFFFF')
.borderRadius(14)
.border({ width: 1, color: '#E5E7EB' })
布局属性(width/height/backgroundColor…)都是组件级样式,绘制属性(fillStyle/strokeStyle…)都是上下文级设置,两层互不混淆——白底是组件背景,笔迹是上下文画上去的内容。
RenderingContextSettings 还有什么选项
RenderingContextSettings 构造器签名是 new RenderingContextSettings(antialias?: boolean),目前主要就是抗锯齿开关。它是 CanvasRenderingContext2D 的「出生配置」,一旦创建不可修改,所以必须在创建时就定好。签名板场景永远传 true。
为什么 context 要作为成员变量
ctx 是 @Component 的成员变量,在整个页面生命周期内复用同一个实例。原因:
- Canvas 组件依赖它:
Canvas(this.ctx)绑定的是这个实例,换一个实例就要重新绑定; - 绘制状态有记忆:ctx 内部保存了 fillStyle、lineWidth 等状态,跨函数调用共享——redraw 里设置的样式,drawStroke 里可以直接用(只要逻辑上一致);
- 性能:重复创建上下文有开销,而组件渲染会频繁触发。
常见错误:ctx 为 null / 未绑定
ArkTS 里如果忘记 new CanvasRenderingContext2D(...) 或者写错绑定方式,运行时会报「Cannot read properties of undefined (reading ‘moveTo’)」之类的错误。排查顺序:settings 是否创建 → ctx 是否用 settings 创建 → Canvas(this.ctx) 是否绑定的是同一个实例。
三、onReady:绘制真正的起点
组件布局完成、上下文可用时触发 onReady,这是第一次绘制的最佳时机:
.onReady(() => {
this.canvasW = this.ctx.width;
this.canvasH = this.ctx.height;
this.redraw();
})
两个关键点:
- 尺寸只能在这里拿:
ctx.width / ctx.height返回画布逻辑尺寸(vp),必须等组件布局完成后才有值。若在 build 之前读,拿到的是 0——所以redraw()里加了if (this.canvasW === 0) return;守卫,防止在画布未就绪时执行绘制; - onReady 只触发一次:之后画面变化全靠手动调用 redraw()。这也是前文「数据主动驱动重绘」架构的落点——onReady 负责「开画」,之后每个交互负责「重画」。
onReady 与 onAppear 的区别
很多从其他框架转来的开发者会把 onReady 与 onAppear 混淆:
| 回调 | 触发时机 | 此时能否安全绘制 |
|---|---|---|
| onAppear | 组件即将出现在屏幕上 | 布局未完成,ctx 尺寸可能为 0 |
| onReady | 组件完成布局、上下文可用 | ✅ 是唯一可靠时机 |
经验法则:所有与 Canvas 尺寸相关的初始化只写在 onReady 里。如果在 onAppear 里尝试绘制,最常见的现象是「第一帧空白,之后才出现内容」或直接报错。
屏幕旋转 / 尺寸变化怎么办
本应用固定竖屏,不处理尺寸变化。如果将来要做横竖屏自适应,可以在 onAreaChange 回调里重新读取 ctx 尺寸并重绘——这也是「数据驱动」的体现:尺寸变化只是触发一次「重新投影」。
四、onTouch:触摸坐标从哪来
.onTouch((e: TouchEvent) => this.handleTouch(e))
private handleTouch(e: TouchEvent): void {
const t = e.touches[0];
if (!t) {
return;
}
const x = t.x;
const y = t.y;
if (e.type === TouchType.Down) {
// 落笔
} else if (e.type === TouchType.Move) {
// 移动
} else if (e.type === TouchType.Up) {
// 抬笔
}
}
坐标语义(最容易踩坑的地方):
event.touches[0].x / .y:相对当前组件左上角的坐标,单位与画布布局单位一致(vp)——签名画布直接用它,无需换算;- 触摸事件类型
TouchType.Down / Move / Up:Down 开始一笔,Move 连续采样,Up 结束一笔; touches是多指数组,单指场景永远取[0];同时做if (!t) return守卫,避免空数组越界。
采样频率:Move 事件按屏幕刷新率触发(约 60~120Hz),一秒钟可能采集上百个点。点越多笔迹越细腻,但绘制压力也越大——平滑算法(下一篇)就是处理「用更少的点画出更顺的线」。
TouchEvent 还有哪些字段值得关注
签名板只用到了 touches/type,但 TouchEvent 还有其他字段,理解它们对后续应用有用:
| 字段 | 含义 | 本系列用到的场景 |
|---|---|---|
| touches | 当前所有触点数组 | 单指取 [0];双指缩放可监听长度变化 |
| changedTouches | 本次事件变化的触点 | 用于精确判断哪根手指抬起 |
| type | 事件类型(Down/Move/Up/Cancel) | 三态采集的核心 |
| timestamp | 事件时间戳 | 可用于计算笔速(后续笔锋效果) |
| pressure | 触点压力(部分设备支持) | 可做压感笔迹粗细变化 |
事件被吞掉怎么办
签名板中,如果画布上方有其他组件(比如透明遮罩)拦截了触摸,onTouch 不会触发。排查方法:
- 确认画布没有被其他组件覆盖(检查 z 序,Column 中子组件后写的在上层);
- 如果确实有覆盖,给画布加
.hitTestBehavior(HitTestMode.Default)或调整布局层级。
五、画布尺寸与坐标的一致性
签名板里有个隐式约定:绘制坐标 = 触摸坐标 = ctx.width/height 坐标,三者都是 vp、都以画布左上角为原点。
// 触摸点直接用作绘制坐标
this.current.points.push({ x: t.x, y: t.y });
// 导出时用同一套尺寸
this.ctx.getPixelMap(0, 0, this.canvasW, this.canvasH);
这个一致性是绘制型应用的「地基」:只要三者单位统一,从采集到绘制再到导出就完全不用换算。如果将来画布加了偏移/缩放(比如居中留白),就统一在「采集 → 绘制」之间做一次坐标映射,保持这一层单一。
如果不一致会发生什么
假设你犯了个错误:在 onReady 里用 this.ctx.width 拿尺寸,但触摸坐标误用了 t.windowX / t.windowY(窗口坐标,相对整个窗口而非组件)——那么笔迹会整体偏移,且偏移量与画布在页面中的位置有关。这类 bug 的典型特征是「字写出来整体偏了」但形状正常。定位方法:在 Down 事件里打印坐标,对比组件左上角在窗口中的位置,差值就是偏移量。
六、UI 风格要素一览
| 风格项 | 取值 | 说明 |
|---|---|---|
| 页面背景 | #F8FAFC 浅灰蓝 |
工具型页面底色 |
| 画布底色 | #FFFFFF 白 |
「白纸」语义 |
| 画布描边 | #E5E7EB 1vp |
纸的边界感 |
| 撤销/清空 | 灰 / 淡红底红字 | 破坏性操作用警示色 |
| 保存 | #3B82F6 蓝 |
主操作突出 |
为什么按钮颜色要区分主次
交互设计上,破坏性操作(清空)用红色、危险程度低的次要操作(撤销)用灰色、核心操作(保存)用蓝色——这是移动端通用的视觉语言。用户一眼就能分辨「点了会不会有损失」,误触风险降到最低。如果你做的是儿童涂鸦应用,这个配色可以换成更活泼的色板,但「区分主次」的原则不变。
响应式布局的小细节
- 画布用
.width('94%')而非固定宽度,适配不同屏幕; - 高度固定 360vp:签名场景竖屏高度足够,横屏则可能不够,需要根据屏幕方向调整;
- 按钮用
Row({ space: 10 })自动间距,避免手工 margin 造成的不齐。
七、文章小结
本节完成了签名板的画布层:RenderingContextSettings 开抗锯齿 → CanvasRenderingContext2D 绑定组件 → onReady 拿尺寸并首绘 → onTouch 采集触摸坐标。技术核心是两件事:onReady 是唯一能安全拿到画布尺寸的时机;touches[0].x/y 是相对组件的 vp 坐标,可直接用于绘制。
一句话总结本节的关键要点:
| 知识点 | 要点 |
|---|---|
| 上下文创建 | settings(true) 开抗锯齿,ctx 绑定 Canvas |
| onReady | 唯一安全读尺寸 + 首绘时机,只触发一次 |
| onTouch | touches[0] 相对组件,vp 单位,三态处理 |
| 坐标一致性 | 绘制坐标 = 触摸坐标 = ctx 尺寸,零换算 |
下一节进入绘制核心:怎么把一坨采样点变成平滑的签名笔迹——中点贝塞尔曲线的完整推导与实现。
八、动手练习
- 抗锯齿对比:把
RenderingContextSettings(true)改成false,写一个带斜线的「之」字,对比边缘毛刺,直观感受抗锯齿的价值; - 坐标偏移实验:故意用
t.windowX / t.windowY代替t.x / t.y,观察笔迹偏移现象,并测量偏移量与画布位置的关系; - 尺寸守卫实验:删掉 onReady 里对 canvasW/canvasH 的赋值(保持 0),运行后书写,观察 redraw 的守卫如何阻止绘制;
- 多指实验:用两个手指同时在画布上划,观察 touches[0] 的选取行为(始终是第一根手指)。
九、FAQ 扩展
Q1:抗锯齿必须开吗?
强烈建议开。RenderingContextSettings(true) 几乎零成本,但能让斜线、圆弧、贝塞尔曲线边缘平滑。不开时签名笔迹的斜向笔画会有明显锯齿,观感大打折扣。
Q2:为什么 onReady 里读不到尺寸?
onReady 回调触发时组件已完成布局,理论上一定能读到;读不到 0 的情况通常发生在更早的时机(比如 constructor、build 期间)。所以规范做法是:尺寸只在 onReady 里读取,其余绘制统一走 redraw(),并用 canvasW === 0 守卫兜底。
Q3:多指同时画会怎样?
本应用只取 touches[0],多指时以第一根手指为准。若要支持双指(比如双指缩放画布),需要监听 touches 数组长度变化并单独处理,超出本应用范围。
Q4:为什么触摸坐标是 vp 而不是 px?
触摸事件、布局、绘制统一使用 vp 逻辑像素,这是 ArkUI 的设计约定:开发者无需关心屏幕密度。若确实需要物理像素(如像素级图像处理),可用 px2vp / vp2px 换算。
Q5:onReady 里能调用异步方法吗?
可以,但注意时序。比如在 onReady 里发起一个网络请求加载背景图,回调里再绘制,需自行保证「数据到达时画布仍有效」。签名板无此需求,所有绘制都是同步数据。
Q6:Canvas 可以放在 Scroll 里吗?
可以,但触摸事件会与滚动手势冲突(画布上的滑动可能被 Scroll 消费)。签名板不需要滚动;如果需要,用 .hitTestBehavior 或手势优先级控制。本系列全部应用都是固定尺寸画布,无滚动。
贝塞尔曲线平滑签名:让手写笔迹告别锯齿折线
一、问题:为什么直接连线会「丑」
手指移动的采样点永远不可能恰好落在一条光滑曲线上——屏幕刷新率再高,点之间也有间隔;手再稳,轨迹也有抖动。如果直接把相邻采样点用 lineTo 连起来:
ctx.moveTo(p0.x, p0.y);
ctx.lineTo(p1.x, p1.y);
ctx.lineTo(p2.x, p2.y); // 折角!
得到的是折线:每两个点之间的拐角都是「硬转折」,写出来的字像锯齿拼的,完全不像手写。
折线丑在哪里:三个层面的问题
- 几何层面:折线的顶点处存在「方向突变」,即一阶导数不连续。数学上,曲线的平滑度由导数连续性定义——一阶连续(切线连续)是「视觉平滑」的最低要求,折线在一阶就不连续;
- 渲染层面:即使开了抗锯齿,折角处仍会产生明显的「毛刺感」,因为像素覆盖率在转角处剧烈变化;
- 心理层面:手写笔迹在人的认知里是「连续流动的笔画」,折线破坏了这种流动感,观感立刻「出戏」。
解决思路:不直接连线,而是让曲线「平滑地滑过」采样点。标准做法是贝塞尔曲线——用相邻点之间的中点做端点、原采样点做控制点,曲线就不会「扎到」控制点里,而是被它「牵引」着平滑过渡。
二、原理:二次贝塞尔曲线 quadraticCurveTo
Canvas 提供 quadraticCurveTo(cpx, cpy, x, y):从当前点到 (x, y) 画一条二次贝塞尔曲线,中间被控制点 (cpx, cpy) 牵引。
曲线公式(t 从 0→1):
B(t) = (1-t)²·P0 + 2t(1-t)·C + t²·P1
- P0 = 当前点(起点)
- C = 控制点(牵引方向)
- P1 = 终点
曲线不经过控制点 C,只被它「拉弯」——这正是我们要的平滑。
公式的直觉理解
把公式拆成三部分看:
(1-t)²·P0:起点的影响,在 t=0 时权重为 1,随 t 增大快速衰减;2t(1-t)·C:控制点的影响,在 t=0 和 t=1 时权重为 0,在 t=0.5 时权重最大(0.5)——控制点只在中段「发力」;t²·P1:终点的影响,在 t=1 时权重为 1。
三条权重曲线合起来恒等于 1(这是一个「凸组合」),所以曲线一定落在 P0、C、P1 三点围成的三角形内。这就是为什么控制点只能「拉弯」曲线而不能「拽飞」它——几何上曲线被约束在三角形内部。
三次贝塞尔 bezierCurveTo 的对比
Canvas 还提供三次贝塞尔 bezierCurveTo(c1x, c1y, c2x, c2y, x, y),有两个控制点。两者对比:
| 特性 | 二次贝塞尔 | 三次贝塞尔 |
|---|---|---|
| 控制点 | 1 个 | 2 个 |
| 公式 | B(t)=(1-t)²P0+2t(1-t)C+t²P1 | B(t)=(1-t)³P0+3t(1-t)²C1+3t²(1-t)C2+t³P1 |
| 可表达的曲线族 | 抛物线 | 任意弯曲(含拐点、尖点近似) |
| 计算量 | 小 | 中 |
| 拼接连续 | 需中点法保证 C1 连续 | 可保证 C2 连续(曲率连续) |
签名场景用二次贝塞尔 + 中点法已足够(见下节),三次贝塞尔的曲率连续在视觉上几乎无差别,但计算和调参都更复杂——够用就好是绘制工程的第一原则。
三、核心技巧:中点贝塞尔
经典陷阱:如果用 quadraticCurveTo(p1, p2)(即控制点 = 采样点、终点 = 下一个采样点),曲线虽然光滑,但每个采样点附近都会被拉出一个小鼓包,因为曲线在采样点处发生了「方向突变」。
为什么会鼓包?看公式:以 P1 为控制点、P2 为终点时,曲线在 P1 处「经过」但方向由 P0→P1 决定;下一段以 P2 为控制点、P3 为终点,曲线在 P2 处的方向由 P1→P2 决定。两段曲线在 P1、P2 处的切线方向都不连续,视觉上就是每个采样点处「咯噔」一下、出现小包。
正确做法——中点贝塞尔:
- 对相邻采样点
Pi、Pi+1,取中点Mi = (Pi + Pi+1) / 2; - 以
Pi为控制点、Mi为中点(曲线端点),画quadraticCurveTo(Pi, Mi); - 这样相邻两段曲线在
Mi处斜率连续,整个笔迹一路平滑。
为什么中点法能保证连续
数学上的关键在于「相邻两段共享中点切线」:
- 第 i 段:控制点 Pi,终点 Mi = (Pi + Pi+1)/2;
- 第 i+1 段:控制点 Pi+1,起点 Mi(自动衔接),终点 Mi+1 = (Pi+1 + Pi+2)/2。
二次贝塞尔在终点的切线方向 = 终点 - 控制点。第 i 段在 Mi 处的切线 = Mi - Pi = (Pi+1 - Pi)/2,方向指向 Pi+1;第 i+1 段在 Mi 处(作为起点)的切线方向 = Pi+1 - Mi = (Pi+1 - Pi)/2,方向同样指向 Pi+1。两段在 Mi 处切线方向完全一致,所以 C1 连续,视觉平滑。
代码实现
private drawStroke(s: Stroke): void {
const pts = s.points;
if (pts.length === 0) {
return;
}
this.ctx.beginPath();
this.ctx.strokeStyle = s.color;
this.ctx.lineWidth = s.width;
this.ctx.lineCap = 'round';
this.ctx.lineJoin = 'round';
if (pts.length < 3) {
// 点太少(按下即抬起),只能画直线
this.ctx.moveTo(pts[0].x, pts[0].y);
for (let i = 1; i < pts.length; i++) {
this.ctx.lineTo(pts[i].x, pts[i].y);
}
} else {
// 中点贝塞尔:相邻两点的中点作为曲线端点,原采样点为控制点
this.ctx.moveTo(pts[0].x, pts[0].y);
for (let i = 1; i < pts.length - 1; i++) {
const mx = (pts[i].x + pts[i + 1].x) / 2;
const my = (pts[i].y + pts[i + 1].y) / 2;
this.ctx.quadraticCurveTo(pts[i].x, pts[i].y, mx, my);
}
// 收尾:画到最后一个采样点
const last = pts[pts.length - 1];
this.ctx.lineTo(last.x, last.y);
}
this.ctx.stroke();
}
逐行解读:
pts.length < 3分支:一笔只有 1~2 个点(比如点一下即抬起),没有中点可言,退化为直线——边界必须处理,否则空路径 stroke 会画出奇怪的线;- 主循环
i从 1 到len-2:每一轮以Pi为控制点、Pi+1的中点为终点; - 收尾
lineTo(last):最后一个采样点作为整笔的结尾,保证笔迹延伸到手指最后停留的位置。
效果对比:直连折线在拐角处「咯噔」一下;中点贝塞尔让每段曲线共享中点切线,整笔圆润连贯——这就是「手写感」的来源。
起点与终点的处理细节
- 起点:直接
moveTo(pts[0])——第一段曲线以 M1 为端点,所以从 P0 画到 M1 自然包含 P0 到 P1 前一半的路径; - 终点:循环只画到最后一个中点(M_{n-2}),最后用
lineTo(pts[last])补上从 M_{n-2} 到 P_{n-1} 的收尾段。如果不补,笔迹会「断在最后一个中点」,看起来少一截。
四、笔迹质感:lineCap 与 lineJoin
this.ctx.lineCap = 'round'; // 笔迹两端画成半圆帽
this.ctx.lineJoin = 'round'; // 线段衔接处圆角
lineCap = 'round':笔画起止端是圆头,否则是方头——签名起笔收笔才自然;lineJoin = 'round':虽然主路径已是贝塞尔,但收尾的lineTo处仍可能产生接缝,圆角 join 让接缝处不突兀。
这两行是「笔迹像笔写的」的最后一环,很多新手画出来线条生硬,往往就是漏了 lineJoin。
lineCap 三种取值对比
| 取值 | 效果 | 适用 |
|---|---|---|
| butt(默认) | 平头,线段在终点截断 | 工程线条、轴刻度 |
| round | 半圆帽,向外延伸半径 | 手写、涂鸦、笔触 |
| square | 方头,向外延伸半径 | 等宽线条、强调 |
round 与 square 都会向外延伸半个线宽,区别只是帽沿是圆是方。签名用 round,视觉最柔和。
五、采样的边际处理
触摸采样有个现实问题:Down 和 Move 之间可能有间隙。快速书写时 Move 事件可能间隔几像素,导致首段曲线从 p0 直接跳到 p1 的直线——不过因为首段很短,视觉上可接受。
更细腻的工程做法是插值(把大间隔补成小间隔),但对签名场景没必要:中点贝塞尔 + 60Hz 采样已经足够顺滑。过度优化反而增加每帧计算量。
什么情况下需要插值
- 设备采样率低(部分模拟器只有 30Hz),两点间距可达 10px+,曲线会「拉丝」;
- 极慢速书写时两点间距极小,贝塞尔会「原地画圈」产生微小抖动点——可通过最小距离过滤(小于 1vp 的采样点丢弃)缓解。
本应用不做这两类优化,保持代码聚焦核心知识;涂鸦画板会提到最小采样间隔的过滤思路。
六、数据与绘制的循环
} else if (e.type === TouchType.Move && this.drawing && this.current) {
this.current.points.push({ x, y }); // 追加采样点(数据)
this.redraw(); // 全量重绘(绘制)
}
每一帧的流程:Move 事件 → 追加点 → redraw() → clearRect → 逐笔 drawStroke。数据永远领先绘制一拍,但因为是同步调用,用户感知不到任何延迟。
同步绘制的意义
Move 事件回调里同步完成「改数据 + 重绘」,意味着绘制结果在事件返回前就已上屏,触摸采样与画面呈现之间的延迟只有一个事件循环,这是手写应用「跟手」的关键。如果改用异步(如 setTimeout 批量重绘),会引入明显延迟,笔迹「追着手指跑」,体验大打折扣。
七、文章小结
本节实现了签名的核心绘制:问题(折线锯齿)→ 原理(二次贝塞尔)→ 方案(中点贝塞尔)→ 质感(lineCap/lineJoin round)。核心结论是:用相邻采样点的中点做端点、采样点做控制点,两两画 quadraticCurveTo,笔迹在每段相接处斜率连续,配合圆头 lineCap 与圆角 lineJoin,得到手写质感。
下一节实现操作与导出:撤销、清空,以及把画布变成 PNG 文件的 getPixelMap 全流程。
八、动手练习
- 直连 vs 贝塞尔对比:把 drawStroke 的贝塞尔分支改成纯 lineTo 折线,写「永」字对比效果;
- 鼓包实验:故意用
quadraticCurveTo(pts[i+1], pts[i+2])(控制点 = 采样点),观察每个采样点处的鼓包; - 线帽实验:把 lineCap 依次改为 butt / square / round,对比笔迹端点形状;
- 点数退化实验:写一个「点一下」的轻触,观察 ❤️ 点分支是否画出一个圆点。
九、FAQ 扩展
Q1:为什么不用三次贝塞尔 bezierCurveTo?
二次贝塞尔 + 中点法已能保证一阶连续(斜率连续),视觉上完全平滑;三次贝塞尔(两个控制点)能额外保证二阶连续(曲率连续),但计算量和调参复杂度更高,签名场景收益趋近于零。
Q2:曲线会「穿过」采样点吗?
不会完全穿过(除首尾)。中点贝塞尔故意让曲线在采样点之间的中点处过渡,采样点退化为控制点——这正是平滑的来源:曲线被「拉」向手指轨迹而不是「砸」在每个点上。
Q3:点特别多会卡吗?
一笔几百个点、几十笔总共几千个点,逐笔 beginPath + stroke 在现代设备上是毫秒级。真正的性能问题出现在「每帧全量重绘 + 海量对象」的粒子场景,第 5 个应用会专门讨论。
Q4:中点贝塞尔会改变笔迹形状吗?
会轻微「圆润化」:采样点变成控制点后,曲线不再经过它们,而是被它们牵引。对于手写场景,这种「轻微外扩」几乎不可察觉,且换来了平滑——这是绘制工程中常见的「以微小形变换视觉质量」取舍。
Q5:能否用平滑滤波代替贝塞尔?
可以。另一种思路是先用均值滤波/高斯滤波对采样点做平滑(把抖动磨平),再直线连接。但滤波会整体缩短笔迹(尤其拐角处),且需要维护窗口缓冲;贝塞尔方案无状态、逐点增量处理,更适合实时场景。
十、进阶:从「够用」到「好用」的绘制打磨
前面实现的是「基础版平滑」,能保证笔迹连续。但真正的手写应用还有几个打磨点,本节把它们讲透,帮助你理解绘制工程里「细节决定体验」的含义。
10.1 最小采样间距过滤
快速书写时 Move 事件每帧都触发,但手指可能只移动了 0.3vp——这种「原地抖动」的采样点会让贝塞尔曲线产生微小的波浪,尤其在起笔、收笔处最明显。
过滤策略:只有与上一个采样点距离 ≥ 阈值(如 1vp)时才入栈。
const lastPt = this.current.points[this.current.points.length - 1];
const dx = x - lastPt.x;
const dy = y - lastPt.y;
if (dx * dx + dy * dy < 1.0) {
return; // 距离过近,丢弃该采样点
}
好处:减少无效点、笔迹更稳、绘制压力更小。代价:需要维护「上一个点」的引用,且阈值不能太大(太大会丢失细腻的转折)。
10.2 笔锋效果(宽度渐变)
真实毛笔的笔画有「起笔细 → 行笔粗 → 收笔细」的粗细变化。签名板用的是固定线宽,如果想做笔锋,需要把 Stroke 的 width 从「数字」升级为「数组」——每个点对应一个宽度,绘制时按点切换 lineWidth。
实现思路(伪代码):
// 依据速度计算宽度:速度越快越细(类似真实书写的手感)
for (let i = 0; i < pts.length; i++) {
const speed = 计算相邻点速度(pts, i);
widths[i] = clamp(基础宽度 - speed * 系数, 最小宽度, 最大宽度);
}
// 绘制时分段:每段用该点宽度 stroke
这不是本应用的必选功能,但它是理解「数据驱动绘制」威力的好例子——画面效果完全由数据(宽度数组)决定,绘制层不需要任何状态。
10.3 与「图表折线」的对比
中点贝塞尔在图表应用(第 3 个应用)里也会用到,但目的不同:
| 场景 | 平滑目的 | 是否保留采样点 |
|---|---|---|
| 签名板 | 让笔迹「像手写」 | 否,曲线被中点牵引 |
| 折线图 | 让趋势「直观」 | 是,数据点必须可见 |
图表里如果过度平滑,会把真实的数据波动抹平,误导读者。所以图表通常保留直线连接(或仅在数据点间做轻量平滑),这提醒我们:绘制算法没有「最好」,只有「适合场景」。
10.4 贝塞尔曲线的手工调参经验
- 线宽 2~4vp:签名最舒服的区间,太细缺乏存在感,太粗遮挡笔迹细节;
- 颜色用深灰而非纯黑:
#1F2937(深灰蓝黑)比#000000更有「墨水」质感,且与白底对比更柔和; - lineJoin 必须 round:即使贝塞尔已连续,收尾 lineTo 处的接缝仍然需要圆角兜底。
这些参数都可以作为常量提取到文件顶部,方便统一调整:
const INK_COLOR: string = '#1F2937';
const PEN_WIDTH: number = 3;
const MIN_SAMPLE_DIST: number = 1.0;
十一、本篇总结清单
| 知识点 | 结论 |
|---|---|
| 折线丑的根源 | 一阶导数不连续(方向突变) |
| 平滑方案 | 二次贝塞尔 + 中点法 |
| 连续性的保证 | 相邻段在中点处切线方向一致(C1 连续) |
| 边界处理 | ❤️ 点退化为直线;收尾 lineTo 补最后一截 |
| 质感三件套 | lineCap=round、lineJoin=round、抗锯齿开 |
| 实时性 | Move 回调里同步「改数据 + 重绘」 |
动手实践建议:把本章练习 1~4 都做一遍,再尝试 10.1 的最小距离过滤,感受「同样的采样点,不同的绘制策略」带来的巨大差异。
十二、补充阅读:贝塞尔曲线的数学细节
对于想深入理解的读者,这里补充贝塞尔曲线公式的完整推导脉络,不需要记住每个符号,重点是理解「权重函数」的结构。
12.1 从线性插值到二次贝塞尔
先看最简单的线性插值:两点 P0、P1 之间的点可以写成 (1-t)·P0 + t·P1,t 从 0 到 1 扫过整条线段。这就是一次贝塞尔(直线)。
二次贝塞尔引入控制点 C,本质是「对两条线段的插值结果再插值」:
B1(t) = (1-t)·P0 + t·C // P0→C 的线段
B2(t) = (1-t)·C + t·P1 // C→P1 的线段
B(t) = (1-t)·B1(t) + t·B2(t) // 两条线段端点间再插值
展开后得到 (1-t)²·P0 + 2t(1-t)·C + t²·P1——正是二次贝塞尔的公式。三次贝塞尔同理:四个点两两做三次插值。这种「插值的插值」构造法(de Casteljau 算法)是理解任意阶贝塞尔的钥匙。
12.2 为什么叫「控制点」而不是「经过点」
从公式看,C 的权重 2t(1-t) 在 t=0 和 t=1 处都是 0,只有在 t=0.5 时达到峰值 0.5——控制点「永远不真正到达」,只是在中段施加最大的「牵引力」。这是贝塞尔曲线最反直觉也最优雅的地方:你想让曲线往哪偏,就往哪放控制点,曲线会被平滑地拉过去,但永远不会撞上控制点。
12.3 中点法的另一种视角:Catmull-Rom 样条
中点贝塞尔其实是 Catmull-Rom 样条的一种特例。Catmull-Rom 样条要求曲线经过所有采样点(插值而非逼近),同时保证一阶连续。它的构造也是用相邻点切线:点 Pi 处的切线方向 = Pi+1 - Pi-1(前后两点连线方向)。中点法的「中点作端点、采样点作控制点」在数学上与 Catmull-Rom 的切向约束是等价的——只是用二次贝塞尔近似了三次样条。了解这个联系,将来做「必须经过数据点」的曲线(如趋势线)时就有两条路可走。
12.4 线段长度与步长的直觉
贝塞尔曲线上的点分布并不均匀:t 均匀变化时,曲线上的点靠近控制点处走得慢、中段走得快。这对绘制没有影响(Canvas 内部已按曲线弧长自适应细分),但在「沿曲线放置文本/图标」的场景就需要按弧长重参数化。签名场景完全不需要关心这点,知道「t 不等于弧长」这个事实即可。
签名导出实战:getPixelMap + ImagePacker 把画布变成图片
一、需求:签名要能「带走」
签完名的价值在于可分发——发微信、存证、打印。所以除了画出来,还必须能变成图片文件。HarmonyOS 的导出链路:
画布像素 PixelMap(getPixelMap)
→ 编码 Packing(image.ImagePacker → PNG 字节 ArrayBuffer)
→ 落盘(fileIo 写入应用缓存目录)
→ 提示用户路径
这条链路可以分为三个阶段,各自对应一个 API 家族:
| 阶段 | API | 类型 | 产出 |
|---|---|---|---|
| 抓像素 | ctx.getPixelMap | 同步 | PixelMap(内存位图) |
| 编码 | image.createImagePacker + packing | 异步 | ArrayBuffer(PNG/JPEG 字节) |
| 落盘 | fileIo.openSync/writeSync/closeSync | 同步 | 沙箱文件 |
三个阶段的输入输出首尾相连:PixelMap → ArrayBuffer → 文件。理解这个「数据流」比背 API 更重要——它告诉我们每一环的产出是什么类型、下一环消费什么类型,任何一个环节类型对不上都会编译报错。
二、三步导出:像素 → 编码 → 落盘
第一步:getPixelMap 抓取画布像素
const pixelMap = this.ctx.getPixelMap(0, 0, this.canvasW, this.canvasH);
getPixelMap(sx, sy, sw, sh) 把画布指定矩形区域的像素同步拷贝成 PixelMap 对象——相当于给画布拍了一张快照。参数与画布坐标系一致(vp),这里正好截取整张画布。
注意两点:
- 同步行为:这个 API 是同步的,调用返回时像素已经拷贝完成,可以直接用于编码。如果画布很大(如长图),同步拷贝可能耗时较长,但签名板尺寸固定且不大,毫秒级完成;
- 坐标系:四个参数 (sx, sy, sw, sh) 中前两个是起点坐标、后两个是宽高,全部是 vp。起点 (0,0) + 画布宽高 = 整张画布,这是最常见用法。
第二步:ImagePacker 编码成 PNG 字节
import { image } from '@kit.ImageKit';
const packer = image.createImagePacker();
const options: image.PackingOption = { format: 'image/png', quality: 100 };
const buffer = await packer.packing(pixelMap, options);
image.createImagePacker():创建编码器;PackingOption:format支持image/png/image/jpeg;PNG 无损适合线条图,JPEG 可调quality压缩体积;packing(pixelMap, options):异步返回ArrayBuffer字节流,即编码后的图片文件内容。
format 与 quality 的取舍:
| 格式 | 是否无损 | quality 作用 | 适用 |
|---|---|---|---|
| image/png | 无损 | 忽略(PNG 无质量参数) | 线条、文字、图表、透明底 |
| image/jpeg | 有损 | 0~100,越高越清晰体积越大 | 照片、渐变、大图 |
签名是线条图,必须无损,所以用 PNG;即便 quality 传了 100,PNG 也不会用它。如果想省存储,可以考虑 JPEG,但线条边缘会有压缩伪影。
第三步:fileIo 写入磁盘
import { fileIo as fs } from '@kit.CoreFileKit';
const filePath = `${getContext(this).cacheDir}/signature_${Date.now()}.png`;
const file = fs.openSync(filePath, fs.OpenMode.READ_WRITE | fs.OpenMode.CREATE | fs.OpenMode.TRUNC);
fs.writeSync(file.fd, buffer);
fs.closeSync(file);
- 文件目录:应用沙箱的
cacheDir(getContext(this).cacheDir),应用私有、无需申请权限——签名文件存这里最省事; - OpenMode 组合:
READ_WRITE(读写)+CREATE(不存在则创建)+TRUNC(存在则清空重写),标准「写入新文件」三件套; - 写盘:
writeSync(fd, buffer)一次写入,closeSync关闭句柄。
为什么用时间戳命名:signature_${Date.now()}.png 保证每次导出的文件名不同,重复导出不会覆盖旧文件。如果固定文件名,第二次导出会静默覆盖第一次的签名——在「存证」场景这是不可接受的。
cacheDir vs filesDir:两者都是应用沙箱目录,区别是 cacheDir 可能被系统在存储紧张时清理(系统有缓存清理机制),适合临时文件;filesDir 持久保存,适合需要长期保留的数据。签名文件如果想长期保留,建议用 filesDir。
三、资源释放:编码器的职业素养
packer.release();
pixelMap.release();
release() 是 HarmonyOS 资源类(PixelMap / ImagePacker)的通用约定:用完后必须释放底层资源,否则内存持续累积(尤其是反复导出签名时)。签名导出虽小,但养成「编码完即释放」的习惯,是第 5 个应用粒子场景不内存暴涨的前提。
释放的时机与顺序:在 packing 完成后、方法返回前释放。顺序上先 release 谁都可以,但必须在所有使用完毕后。如果提前 release pixelMap,packing 可能读不到数据——因为 PixelMap 的底层缓冲已被释放。
遗漏 release 的后果:短时间多次导出时内存占用节节攀升(每次导出都有一份 PixelMap + 一份编码缓冲),严重时 OOM 崩溃。真机调试时可以用 DevEco 的 Profiler 观察内存曲线验证。
四、完整导出方法
private async saveImage(): Promise<void> {
if (this.strokes.length === 0) {
promptAction.showToast({ message: '先写一个签名吧' });
return;
}
try {
const pixelMap = this.ctx.getPixelMap(0, 0, this.canvasW, this.canvasH);
const packer = image.createImagePacker();
const options: image.PackingOption = { format: 'image/png', quality: 100 };
const buffer = await packer.packing(pixelMap, options);
const filePath = `${getContext(this).cacheDir}/signature_${Date.now()}.png`;
const file = fs.openSync(filePath, fs.OpenMode.READ_WRITE | fs.OpenMode.CREATE | fs.OpenMode.TRUNC);
fs.writeSync(file.fd, buffer);
fs.closeSync(file);
packer.release();
pixelMap.release();
this.savedCount++;
const d = new Date();
const hm = `${d.getHours()}:${String(d.getMinutes()).padStart(2, '0')}:${String(d.getSeconds()).padStart(2, '0')}`;
this.savedTip = `${filePath}(${hm})`;
promptAction.showToast({ message: '✅ 签名已导出' });
} catch (err) {
promptAction.showToast({ message: `导出失败: ${JSON.stringify(err)}` });
}
}
几个工程细节:
- 空画布守卫:没有笔迹就导出毫无意义,先 toast 提示——边界校验放在动作入口;
- try/catch:
packing是异步,openSync/writeSync是同步 IO,都可能抛异常(磁盘满、编码失败),捕获后提示而非静默崩溃; - 文件名带时间戳:
signature_${Date.now()}.png,重复导出不覆盖; - 成功后更新文案:
savedCount++(@State 自动刷新标题栏)、savedTip(显示路径与时间)——导出是「数据 + UI 双更新」的典型场景。
时序注意:await packer.packing 之后的代码在 Promise 解决后执行,此时必须确认 pixelMap 还没被释放。代码里 release 放在写入完成后,顺序正确。如果误把 release 放在 packing 之前,会得到「空图」或异常。
五、撤销与清空:数据操作的返回值
导出之外的两个操作都是「改数据 + 重绘」:
private undo(): void {
if (this.strokes.length === 0) {
promptAction.showToast({ message: '没有可撤销的笔迹' });
return;
}
this.strokes.pop(); // 弹出最后一笔(数据)
this.strokeCount = this.strokes.length; // 刷新文案(状态)
this.redraw(); // 全量重绘(画面)
}
private clearAll(): void {
if (this.strokes.length === 0) {
return;
}
this.strokes = [];
this.current = null;
this.strokeCount = 0;
this.redraw();
}
模式的复用:strokes.pop() 是撤销的全部数据逻辑,其余都是配套动作。因为画面恒等于 strokes 的投影,撤销天然正确——不需要记录任何「画布反操作」,这就是数据驱动绘制对操作系统的降维打击。
撤销的边界:strokes.length === 0 时 pop 会得到 undefined,且后续 strokeCount = this.strokes.length 会是 -1(如果写成 --)——所以入口必须守卫。清空的守卫是 length === 0 时直接 return(没有可清的),避免无意义的置空与重绘。
clearAll 里 this.current = null 的作用:如果在书写过程中(drawing 状态)点清空,current 还指向一个已入栈的 Stroke;清空后如果不置 null,下一次 Down 事件处理时会用旧的 current 引用——虽然 Down 会创建新 current,但残留引用可能导致「清空后笔迹复活」的诡异 bug(取决于触摸时序)。置 null 是防御性清理。
撤销的体验细节:撤销是「逐笔回退」,用户可能需要连点多次。每点一次 pop 一笔 + 重绘一次,几十笔的撤销操作都是毫秒级,无需担心。撤销到空之后再次点撤销,toast 提示「没有可撤销的笔迹」,交互不会静默失败。
六、导出背景色的秘密
Canvas 组件的 backgroundColor('#FFFFFF') 是组件背景,不在绘制内容层里——getPixelMap 抓的是绘制层。但实际效果中,白底组件 + 白底 clearRect 区域,导出的 PNG 是白底还是透明?
关键在 redraw() 里的 globalCompositeOperation:签名场景全程 source-over,且未绘制区域是透明像素。如果 Canvas 组件自身是白底,视觉上「白纸」,但 getPixelMap 抓到的透明区导出 PNG 后会显示为透明(图片查看器按透明处理,贴到深色背景上会露底)。
要让 PNG 彻底白底,导出前先铺一层白:
// 导出前:先画白底再画笔迹(涂鸦画板 2-4 会给出完整写法)
this.ctx.fillStyle = '#FFFFFF';
this.ctx.fillRect(0, 0, this.canvasW, this.canvasH);
// 然后重绘笔迹...
签名应用里白色背景 + 透明像素的差异肉眼几乎不可见,但透明底 vs 白底是图片分发的常识问题——第 2 个应用(涂鸦画板,涉及橡皮擦透明像素)会重点处理。
透明底也是特性:如果你想要「透明背景的签名图片」贴到任意底色文档上(如合同、海报),不铺白底反而是对的。所以「要不要白底」取决于分发场景,不是绝对的。
七、文章小结
本节完成了签名板的全部操作:撤销(pop + 重绘)、清空(置空 + 重绘)、导出(getPixelMap → ImagePacker → fileIo 三步落盘)。核心链路是 画布像素 → PNG 字节 → 沙箱文件,配套习惯是资源用完即 release、空画布/异常先校验、时间戳命名防覆盖。
回顾本节新增的 API 与职责:
| API | 职责 | 注意 |
|---|---|---|
| getPixelMap | 画布像素快照 | 同步、vp 坐标 |
| image.createImagePacker | 编码器 | 用完 release |
| packer.packing | 编码成字节 | 异步、await |
| fileIo.openSync/writeSync/closeSync | 落盘 | OpenMode 三件套 |
| release() | 释放资源 | PixelMap/ImagePacker 都要 |
下一节给出签名板完整代码解读与运行效果说明,收尾整个应用。
八、动手练习
- 换 JPEG 导出:把 format 改成
image/jpeg,quality 分别试 80 / 50 / 20,对比文件体积与边缘质量,理解有损压缩; - 透明底实验:导出 PNG 后用看图软件(或 PS)把图片放到深色背景上,观察透明 vs 白底的差异;
- release 遗漏实验:注释掉
pixelMap.release()和packer.release(),连续导出 50 次,用 Profiler 观察内存曲线——直观感受资源泄漏; - filesDir 实验:把 cacheDir 换成
getContext(this).filesDir,导出后重启应用,用文件管理器(devtools 查看沙箱)确认文件是否仍在。
九、FAQ 扩展
Q1:cacheDir 的文件用户看得到吗?
应用沙箱内,用户文件管理器默认看不到;但应用自己可以用它做分享(配合 systemShare 分享面板把文件发给微信等)。签名应用存 cacheDir 已够用,追求持久化可存 filesDir 或媒体库。
Q2:JPEG 和 PNG 怎么选?
线条/文字/图表选 PNG(无损、锐利);照片/渐变图选 JPEG(体积小、有损)。quality 参数只对 JPEG 生效。
Q3:getPixelMap 是同步的,会不会卡 UI?
画布尺寸固定(vp 级),像素拷贝量有限,同步返回在毫秒级,可接受。超大画布(如长截图)才考虑异步或分块导出。
Q4:导出时画布上有半透明内容怎么办?
PNG 保留 alpha 通道,半透明区域导出后仍是半透明。如果需要「白底 + 不透明」,导出前铺白底即可(见第六节)。
Q5:packing 会抛异常吗?
会。常见异常:PixelMap 已被释放(提前 release)、format 非法、内存不足。所以导出方法整体包在 try/catch 里,失败时 toast 提示而非崩溃。
Q6:签名导出后如何分享给别人?
用 systemShare(@kit.ShareKit)或系统分享面板,把文件 URI 传入即可拉起微信/邮件等分享入口。本应用只做到落盘 + 提示路径,分享是可选扩展。
十、动手练习
把本节的四个实验做完,你对「像素 → 文件」这条链路的理解会远超背 API:
- 换 JPEG 导出:把 format 改成
image/jpeg,quality 分别试 80 / 50 / 20,对比文件体积与边缘质量,理解有损压缩; - 透明底实验:导出 PNG 后用看图软件(或 PS)把图片放到深色背景上,观察透明 vs 白底的差异;
- release 遗漏实验:注释掉
pixelMap.release()和packer.release(),连续导出 50 次,用 Profiler 观察内存曲线——直观感受资源泄漏; - filesDir 实验:把 cacheDir 换成
getContext(this).filesDir,导出后重启应用,用文件管理器(devtools 查看沙箱)确认文件是否仍在; - 导出速度实验:在 saveImage 前后打点(
Date.now()),统计一次完整导出(抓像素 + 编码 + 写盘)的耗时,评估同步 vs 异步环节对 UI 的影响。
十一、补充阅读:导出链路的性能画像
导出的三个环节各自的时间特征值得量化理解:
| 环节 | 耗时量级 | 是否阻塞 UI | 优化方向 |
|---|---|---|---|
| getPixelMap 同步拷贝 | 毫秒级(小画布) | 是(同步) | 画布大时分块/异步 |
| packer.packing 编码 | 几十毫秒级 | 否(await) | 降低 quality / 缩小尺寸 |
| fileIo 写盘 | 毫秒级 | 是(同步) | 换异步 write |
签名板画布小,整条链路 <100ms,用户无感知。但这条经验直接适用于后续应用:任何「像素级」操作都要先评估数据量,再决定同步还是异步。
为什么导出不用 @State 触发
导出完成后 savedCount++、savedTip = ... 是 @State 更新——这会触发 build 重跑,把新的文案渲染出来。但注意:导出本身不改 strokes,所以 build 重跑不会调 redraw(),画面保持原样,只是文案刷新。这正是「UI 文案走 @State、画面走手动重绘」分层的又一次验证。
手写签名板完整代码与运行效果
一、代码全景
完整源码见 Canvas_2d/1_手写签名板/SignaturePad.ets,结构如下:
import { image } from '@kit.ImageKit';
import { fileIo as fs } from '@kit.CoreFileKit';
import { promptAction } from '@kit.ArkUI';
interface Point { x: number; y: number; } // 采样点
interface Stroke { points: Point[]; color: string; width: number; } // 一笔
@Entry
@Component
struct SignaturePad {
// 画布上下文
private settings: RenderingContextSettings = new RenderingContextSettings(true);
private ctx: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.settings);
private canvasW: number = 0; // 画布尺寸(onReady 赋值)
private canvasH: number = 0;
private strokes: Stroke[] = []; // 数据层:全部笔迹
private current: Stroke | null = null; // 正在书写的笔
private drawing: boolean = false; // 是否正在落笔
@State strokeCount: number = 0; // UI:笔数
@State savedCount: number = 0; // UI:保存份数
@State savedTip: string = ''; // UI:保存路径提示
build() { /* 标题栏 + 画布 + 操作栏 + 提示 */ }
private handleTouch(e: TouchEvent): void { /* Down/Move/Up 三态采集 */ }
private redraw(): void { /* clearRect + 逐笔重绘 */ }
private drawStroke(s: Stroke): void { /* 中点贝塞尔平滑 */ }
private undo(): void { /* pop + 重绘 */ }
private clearAll(): void { /* 置空 + 重绘 */ }
private async saveImage(): Promise<void> { /* getPixelMap 三步导出 */ }
}
文件结构注释:一眼看懂各部分职责
把整个 .ets 文件从上到下走一遍,每个区块的职责:
| 区块 | 内容 | 职责 |
|---|---|---|
| import 区 | image / fileIo / promptAction | 依赖引入 |
| interface 区 | Point / Stroke | 数据模型 |
| 常量区(可选) | INK_COLOR / PEN_WIDTH | 调参集中地 |
| 组件头 | @Entry @Component struct | 页面声明 |
| 成员变量区 | ctx / strokes / @State | 状态与上下文 |
| build() | 四段 UI | 布局 |
| 方法区 | handleTouch / redraw / drawStroke / undo / clearAll / saveImage | 逻辑 |
这个「从上到下 = 从声明到行为」的组织方式是 ArkTS 单文件页面的标准结构,阅读顺序即编写顺序。
二、关键代码逐段回放
2.1 触摸三态(采集)
if (e.type === TouchType.Down) {
this.drawing = true;
this.current = { points: [{ x, y }], color: '#1F2937', width: 3 };
this.strokes.push(this.current); // 落笔即入栈,笔画顺序即绘制顺序
this.redraw();
} else if (e.type === TouchType.Move && this.drawing && this.current) {
this.current.points.push({ x, y }); // 持续采样
this.redraw();
} else if (e.type === TouchType.Up) {
this.drawing = false;
this.strokeCount = this.strokes.length;
}
设计要点:Down 时创建 Stroke 并立即入栈——这样即使只画了一笔就撤销,栈里的顺序也始终是「绘制顺序 = 数据顺序」。Move 时直接把点追加到 current.points(共享引用),所以 strokes 数组里这笔的数据是「活的」:撤销前不断增长。Up 时只关掉 drawing 标记并更新文案,不需要额外动作——笔画数据已经完整入栈。
2.2 平滑笔迹(绘制核心)
this.ctx.moveTo(pts[0].x, pts[0].y);
for (let i = 1; i < pts.length - 1; i++) {
const mx = (pts[i].x + pts[i + 1].x) / 2; // 中点做曲线端点
const my = (pts[i].y + pts[i + 1].y) / 2;
this.ctx.quadraticCurveTo(pts[i].x, pts[i].y, mx, my); // 采样点做控制点
}
this.ctx.lineTo(last.x, last.y);
配合 lineCap = 'round' 与 lineJoin = 'round',一笔从「折线」变成「平滑曲线 + 圆头圆角」,手写感就此成型。
2.3 导出(像素级)
const pixelMap = this.ctx.getPixelMap(0, 0, this.canvasW, this.canvasH);
const packer = image.createImagePacker();
const buffer = await packer.packing(pixelMap, { format: 'image/png', quality: 100 });
const filePath = `${getContext(this).cacheDir}/signature_${Date.now()}.png`;
const file = fs.openSync(filePath, fs.OpenMode.READ_WRITE | fs.OpenMode.CREATE | fs.OpenMode.TRUNC);
fs.writeSync(file.fd, buffer);
fs.closeSync(file);
packer.release();
pixelMap.release();
三、运行效果
| 操作 | 效果 |
|---|---|
| 手指在画布书写 | 立即出现黑色圆头笔迹,平滑连贯无锯齿 |
| 快速连写 | 笔迹随手指实时延伸,无残影(每帧全量重绘) |
| 点「🔄 撤销」 | 最后一笔消失,可连续撤销到空画布 |
| 点「🧹 清空」 | 全部笔迹消失,笔数归零 |
| 点「💾 保存图片」 | Toast「✅ 签名已导出」,下方显示文件完整路径与时间 |
| 标题栏「↻」 | 强制重绘(等价于刷新画面) |
边界验证:空画布点保存 → Toast「先写一个签名吧」;空画布点撤销 → Toast「没有可撤销的笔迹」;画完点清空再写,一切正常。
四、可扩展方向
- 签名笔迹对比:新签名与数据库里保存的签名做相似度校验(配合 OCR / 特征点);
- 多颜色:加入颜色选择,即第 2 个应用的调色盘思路;
- 双指缩放:监听 touches.length 变化,把坐标映射从恒等改为带缩放矩阵。
五、应用小结
手写签名板完整落地了**「数据驱动绘制」**的全套模式:
- 模型:Stroke 点序列,画面 = 数据的投影;
- 采集:onTouch 三态,坐标直接用 vp;
- 绘制:clearRect + 中点贝塞尔逐笔重绘,round 线帽线角出质感;
- 操作:撤销/清空 = 改数据 + 重绘,天然正确;
- 导出:getPixelMap → ImagePacker → fileIo,PNG 落盘可分发。
至此,签名板这个「最小的绘制应用」闭环完成。它只用了 Canvas 的一小部分能力(路径 + 像素),却完整覆盖了采集 → 绘制 → 操作 → 输出的全链路——这正是本系列刻意设计的「低起点、全流程」学习路径。
下一节开启第 2 个应用——涂鸦画板:多色画笔、橡皮擦、撤销重做,数据模型全面升级。
六、FAQ 补充
Q:为什么标题栏刷新按钮用「↻」文本而不是图标?
减少资源依赖,emoji/符号即可表达语义,工程里完全可以用系统图标或自定义图标替换,不影响功能。
Q:本应用和浏览器 Canvas 有何异同?
API 名称与语义基本一致(moveTo/lineTo/quadraticCurveTo 等),差异在「生态」:HarmonyOS 用 CanvasRenderingContext2D 绑定组件、getPixelMap 替代 toDataURL、ImagePacker 替代浏览器编码流程。有 Web Canvas 经验的开发者可以快速迁移。
Q:签名板还能加什么交互?
常见升级:笔迹回放动画(把 Stroke 的点按时间顺序逐步绘制,适合「演示签名过程」)、压感粗细(pressure 字段)、多签对比、签名自动居中缩放。每个方向都建立在「数据驱动」模型之上,改动都集中在数据层与绘制层。
Q:为什么 Down 时就要 push 到 strokes?
两个原因:一是保证「绘制顺序 = 数据顺序」,无论何时撤销都正确;二是 Move 时直接往 current.points 里追加点,current 是 strokes 里元素的引用——这样数据始终「活着」,重绘时永远是最新内容。如果等 Up 才入栈,Move 期间重绘就看不到这笔。
Q:Up 时为什么不用调用 redraw()?
Up 时最后一个 Move 已经追加了点并重绘过,画面已经是最新状态;Up 只负责收尾(关 drawing、更新文案)。不过如果你在 Up 时做了数据收尾操作(如截断多余点),则需要再调一次 redraw()。
Q:current 与 strokes 的关系怎么理解?
current 是「正在画的那一笔」的快捷引用,strokes 是「全部笔迹」的数组。Down 创建 current 并 push 进 strokes;Move 通过 current 追加点(strokes 里的同一对象同步变化);Up 后 current 置 null,strokes 完整保留这一笔。这是典型的「引用别名」用法——两个名字指向同一个对象。
七、代码运行检查清单
拿到完整代码后,按以下清单自查,能避免绝大多数「运行起来不对劲」的情况:
| 检查项 | 正确姿势 | 错误表现 |
|---|---|---|
| import 完整性 | image / fileIo / promptAction 三处导入 | 编译报「找不到模块」 |
| settings 抗锯齿 | new RenderingContextSettings(true) |
斜线锯齿明显 |
| ctx 绑定 | Canvas(this.ctx) 与成员变量同实例 |
运行时 undefined 报错 |
| onReady 赋值 | canvasW/canvasH 在 onReady 里取 | 首帧空白 / 守卫拦截 |
| redraw 守卫 | if (this.canvasW === 0) return |
未就绪时绘制异常 |
| 撤销边界 | length === 0 时 return + toast | 越界 / 笔数变 -1 |
| 导出顺序 | packing 完成后才 release | 空图 / 异常 |
| 文件命名 | 时间戳防覆盖 | 二次导出覆盖首份 |
八、性能与内存小结
签名板在性能上是「轻量级」应用,但仍值得总结它的资源画像,为后续重型应用打底:
- CPU 绘制:每帧全量重绘 ≈ 数十次路径构建 + 描边,毫秒级;
- GPU 提交:每次 stroke() 一次提交,笔数少无压力;
- 内存:Stroke 对象随笔数增长,几十笔占用可忽略;导出时 PixelMap + 编码缓冲为临时峰值,用完即 release;
- 唯一需要警惕的:误把 strokes 挂 @State 导致 build 高频重跑——这会拖慢整个页面而不只是画布。
下一节开启第 2 个应用——涂鸦画板:多色画笔、橡皮擦、撤销重做,数据模型全面升级。
九、动手练习:把签名板「玩坏」
学习一个应用的最好方式不是读,而是改。以下练习由易到难,每个都能加深对数据驱动模型的理解:
- 改颜色:把 Down 里的
color: '#1F2937'改成任意色值,运行后书写,验证「一笔的视觉属性固化在数据里」; - 改线宽:把
width: 3改成 8,写一个大字,对比笔迹质感——线宽 8vp 的笔迹会遮挡更多细节; - 加粗度切换:给操作栏加两个按钮「细/粗」,onClick 里改一个
@State penWidth,Down 时读取——第 2 个应用的雏形; - 加颜色选择:加一行 4 色圆点,点击切换
@State penColor——调色盘的雏形; - 撤销体验:快速连画 20 笔然后连点撤销 20 次,观察每帧重绘是否流畅,估算「全量重绘」的性价比。
十、从签名板到涂鸦画板:模型复用清单
本应用是系列的基础模板,下一节会在这个模型上做升级。提前列出「哪些能复用、哪些要改」,帮你建立清晰的迁移预期:
| 模块 | 签名板写法 | 涂鸦画板的改动 | 复用度 |
|---|---|---|---|
| Point / Stroke 模型 | 无 kind | 增加 kind 工具字段 | 高(加字段) |
| handleTouch 三态 | Down/Move/Up | Down 时固化工具快照 | 高(微调) |
| drawStroke 贝塞尔 | 中点法 | 原样复用 | 100% |
| redraw 管线 | clearRect + 逐笔 | 逐笔前切合成模式 | 高(加一行) |
| undo | 单栈 pop | 双栈(+redoStack) | 中(加栈) |
| saveImage | 三步导出 | 导出前白底重绘 | 中(加环节) |
| UI 骨架 | 标题 + 画布 + 操作栏 | 增加调色盘行、粗细行 | 中(加行) |
复用度最高的部分是绘制核心(贝塞尔、管线、路径 API),改动最大的部分是数据模型与操作逻辑。这印证了本系列一以贯之的架构主张:绘制层是「稳定的引擎」,数据层是「变化的剧本」,UI 层是「可换的舞台」。
十一、系列导读:五个应用的技术脉络
作为应用 1 的收尾,把整个系列的「知识地图」提前铺开,方便你带着全局观学习后续四篇:
| 应用 | 核心新知识 | 复用签名板的模块 |
|---|---|---|
| 1 签名板 | 路径绘制、贝塞尔、导出 | ——(起点) |
| 2 涂鸦画板 | 合成模式、双栈、白底导出 | 贝塞尔、管线、三步导出 |
| 3 数据图表 | 坐标变换、渐变、动画 | clearRect 管线、getPixelMap |
| 4 时钟仪表盘 | 时间驱动、旋转、save/restore | 贝塞尔(刻度/指针)、管线 |
| 5 粒子特效 | 对象池、帧循环、性能优化 | 管线、合成模式(发光) |
可以看到:每个应用都只引入一小块新知识,其余全部复用。这是本系列「循序渐进」的刻意设计——学完五个应用,你就掌握了 Canvas 绘制的完整技能树,而不是碎片化的 API 清单。
十二、从签名板运行效果到真实产品的差距
Demo 和产品之间还差几步,这里列出签名板从「能跑」到「能交付」的补全清单,供有兴趣继续打磨的读者参考:
| 差距点 | Demo 现状 | 产品化方案 |
|---|---|---|
| 签名保存位置 | cacheDir(可能被系统清理) | filesDir 或媒体库(photoAccessHelper) |
| 签名分享 | 只提示路径 | systemShare 拉起分享面板 |
| 笔迹持久化 | 仅导出图片 | strokes JSON 序列化,下次进入恢复 |
| 笔迹回放 | 无 | 按时间戳逐点重绘动画 |
| 压感支持 | 固定宽度 | 读取 touch.pressure 映射宽度 |
| 画布自适应 | 固定高度 | layoutWeight + onAreaChange 重绘 |
这些升级都不改变「数据驱动」架构——所有改动仍然落在数据层(加字段)与绘制层(加逻辑),UI 层几乎不动。这正是本系列坚持架构先行、模型先行的意义:Demo 的架构决定了它能长成什么样的产品。
更多推荐


所有评论(0)