在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

本文由原 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 的自定义绘制有两条路:

  1. Canvas 组件 + CanvasRenderingContext2D:命令式绘制,像在纸上画画——moveTo 落笔、lineTo 走线、fillRect 涂色、arc 画弧。灵活、性能好,是绘制型应用(本系列 5 个应用)的主角;
  2. 自定义组件 onDraw:在自定义组件里拿到 Canvas 绘制上下文直接画,适合组件级定制,把绘制逻辑封装进一个可复用的组件。

本系列全部使用方案 1,目标是把 CanvasRenderingContext2D 的核心 API 讲透。从最贴近用户直觉的「手写签名」开始,逐步升级到涂鸦画板、数据图表、时钟仪表盘、粒子特效,覆盖路径绘制、合成模式、动画循环、像素导出等全部核心知识点。

为什么第一个应用选签名板

选签名板作为系列开篇有三个理由:

  • API 覆盖面恰到好处:只用到了路径绘制(moveTo / quadraticCurveTo / stroke)与触摸事件,没有多余的复杂度,适合建立「数据 → 绘制」的心智模型;
  • 交互闭环完整:采集(触摸)→ 绘制(贝塞尔)→ 操作(撤销/清空)→ 输出(导出图片),一条链路走完一个绘制应用的全部环节;
  • 数据模型典型:签名板是典型的「图形 = 数据的投影」场景,这种模型是后续 4 个应用的基础——涂鸦画板是它的多色升级版,图表是它的数据可视化版,粒子特效是它的动画版。

二、签名板的需求分析

手写签名板要解决什么问题?把「用手指写字」变成「可保存的图片」。需求拆解:

需求 说明 技术落点
触摸书写 手指按下 → 拖动 → 抬起,形成一笔笔迹 onTouch 事件三态采集
笔迹平滑 手指采样是离散点,直接连线会有折角 二次贝塞尔曲线(中点法)
撤销 写错了,回退最后一笔 数据栈 pop + 重绘
清空 全部重写 数据数组置空 + 重绘
导出图片 把画布内容变成 PNG 文件,可发送给他人 getPixelMap + ImagePacker + fileIo

技术选型:全部落在 CanvasRenderingContext2D 上——触摸事件采集坐标点、路径 API 绘制、像素 API 导出,不引入任何第三方库。这也是刻意为之:自定义绘制的核心能力都内置于框架,学会它就能做出大量应用,不需要依赖外部图形库。

为什么不用预置组件模拟? 有人会说「签名不就是一条线吗,用 Image 显示一张预先生成的图不就行了」——但那是静态图,无法响应实时书写。签名板的核心是实时性:手指画到哪里,笔迹必须立刻出现在哪里。只有 Canvas 命令式绘制能在触摸事件的回调里同步完成「取点 → 画线」这一闭环。

需求里的边界情况

需求分析时最容易遗漏的是「不画线的时候怎么办」:

  1. 轻点一下:按下立即抬起,只产生 1~2 个采样点,没有「笔画」可言,但要画出一个点(或极短线段),不能留白;
  2. 快速连写:手指快速划动时 Move 采样可能稀疏,两点间距拉大,直接连线会「跳」,需要贝塞尔平滑兜底;
  3. 画布未就绪:onReady 之前触摸事件理论上不会来,但严谨起见 redraw() 里要有 canvasW === 0 守卫;
  4. 反复撤销到空:撤销不能越界(数组越界或弹出空气),要在入口判断 length === 0;
  5. 导出时恰好无内容:空画布导出没有意义,要在导出入口提示用户。

这些边界情况会在后续各篇的实现代码里逐一体现,现在先在需求层面把它们「登记」下来。

三、数据模型:一笔 = 一串点

绘制型应用的关键是把「画面」抽象成数据。签名板的画面就是若干「笔画」的叠加:

/** 笔迹点 */
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 自身,不依赖任何外部状态。

补充:坐标系的三个关键约定

签名板的一切绘制都建立在同一个坐标系上,这里提前把三个约定讲清楚,后续所有应用都遵守它们:

  1. 原点在左上角:Canvas 坐标原点 (0, 0) 位于组件左上角,x 轴向右增大,y 轴向下增大——这与数学坐标系(y 向上)相反,初学时容易画反,务必先建立「屏幕坐标」的直觉;
  2. 单位是 vp(虚拟像素):触摸坐标 t.x / t.y、绘制坐标 ctx.width / ctx.height 都是 vp,与设备物理像素无关。vp 的好处是不同分辨率设备上画面比例一致,无需关心屏幕密度换算;
  3. 绘制坐标 = 触摸坐标:触摸事件返回的坐标就是组件内的 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 重跑时自动重绘——这是行不通的,原因有二:

  1. @State 是浅观察:对数组的 pushpop 操作,ArkUI 的状态管理默认不触发刷新(需要 @Observed / 数组整体赋值等高级用法);
  2. 即使触发了 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)→ 逐笔重绘。每一步都有讲究:

  1. clearRect:画布是「一次性画布」,不先擦除就直接画会叠加残影,所以每次重绘必须先清空。clearRect(0, 0, w, h) 清空的是绘制内容层,不影响组件背景色;
  2. globalCompositeOperation:先用 source-over 兜底复位——本应用只用普通覆盖,但后续涂鸦画板的橡皮擦会把它换成 destination-out,这里先养成「每次重绘前显式复位」的习惯。如果不复位,一旦某次绘制改了合成模式,后续所有重绘都会受影响;
  3. 逐笔重绘:每笔独立 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')
  }
}

两个初始化关键点(后续每篇都会用到,先记牢):

  1. RenderingContextSettings(true):开启抗锯齿,否则斜线、圆弧会有明显锯齿。true 表示「允许抗锯齿渲染」,这是绘制型应用的基本配置;
  2. 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 触摸事件坐标——解决「手指按下去,点在哪儿」。

九、动手练习

学完本节,建议动手验证以下三个点,每个都能加深对「数据驱动绘制」的理解:

  1. 给 strokes 加 @State 试试:把 private strokes 改成 @State strokes,运行后书写,观察笔迹是否「时有时无」或出现奇怪的重绘行为,从而亲手验证「@State 不驱动 Canvas 重绘」这一结论;
  2. 去掉 redraw 里的 clearRect:注释掉清屏代码,连续书写几笔,观察笔迹叠加残影的现象,理解「一次性画布」的含义;
  3. 去掉 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 目标是白纸 + 工具条:一大块白底画布让人「想写字」,下方三个按钮处理「撤销 / 清空 / 保存」。页面结构:

  1. 标题栏:✍️ 手写签名板 + 笔数·保存数 + 刷新;
  2. 签名画布:白色圆角卡片,占主视觉;
  3. 操作栏:撤销(灰)/ 清空(红)/ 保存(蓝);
  4. 保存提示:显示导出文件路径。
为什么 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 的成员变量,在整个页面生命周期内复用同一个实例。原因:

  1. Canvas 组件依赖它Canvas(this.ctx) 绑定的是这个实例,换一个实例就要重新绑定;
  2. 绘制状态有记忆:ctx 内部保存了 fillStyle、lineWidth 等状态,跨函数调用共享——redraw 里设置的样式,drawStroke 里可以直接用(只要逻辑上一致);
  3. 性能:重复创建上下文有开销,而组件渲染会频繁触发。
常见错误: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();
})

两个关键点

  1. 尺寸只能在这里拿ctx.width / ctx.height 返回画布逻辑尺寸(vp),必须等组件布局完成后才有值。若在 build 之前读,拿到的是 0——所以 redraw() 里加了 if (this.canvasW === 0) return; 守卫,防止在画布未就绪时执行绘制;
  2. 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 不会触发。排查方法:

  1. 确认画布没有被其他组件覆盖(检查 z 序,Column 中子组件后写的在上层);
  2. 如果确实有覆盖,给画布加 .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 尺寸,零换算

下一节进入绘制核心:怎么把一坨采样点变成平滑的签名笔迹——中点贝塞尔曲线的完整推导与实现。

八、动手练习

  1. 抗锯齿对比:把 RenderingContextSettings(true) 改成 false,写一个带斜线的「之」字,对比边缘毛刺,直观感受抗锯齿的价值;
  2. 坐标偏移实验:故意用 t.windowX / t.windowY 代替 t.x / t.y,观察笔迹偏移现象,并测量偏移量与画布位置的关系;
  3. 尺寸守卫实验:删掉 onReady 里对 canvasW/canvasH 的赋值(保持 0),运行后书写,观察 redraw 的守卫如何阻止绘制;
  4. 多指实验:用两个手指同时在画布上划,观察 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);  // 折角!

得到的是折线:每两个点之间的拐角都是「硬转折」,写出来的字像锯齿拼的,完全不像手写。

折线丑在哪里:三个层面的问题
  1. 几何层面:折线的顶点处存在「方向突变」,即一阶导数不连续。数学上,曲线的平滑度由导数连续性定义——一阶连续(切线连续)是「视觉平滑」的最低要求,折线在一阶就不连续;
  2. 渲染层面:即使开了抗锯齿,折角处仍会产生明显的「毛刺感」,因为像素覆盖率在转角处剧烈变化;
  3. 心理层面:手写笔迹在人的认知里是「连续流动的笔画」,折线破坏了这种流动感,观感立刻「出戏」。

解决思路:不直接连线,而是让曲线「平滑地滑过」采样点。标准做法是贝塞尔曲线——用相邻点之间的中点做端点、原采样点做控制点,曲线就不会「扎到」控制点里,而是被它「牵引」着平滑过渡。

二、原理:二次贝塞尔曲线 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 处的切线方向都不连续,视觉上就是每个采样点处「咯噔」一下、出现小包。

正确做法——中点贝塞尔

  1. 对相邻采样点 PiPi+1,取中点 Mi = (Pi + Pi+1) / 2
  2. Pi 为控制点、Mi 为中点(曲线端点),画 quadraticCurveTo(Pi, Mi)
  3. 这样相邻两段曲线在 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 方头,向外延伸半径 等宽线条、强调

roundsquare 都会向外延伸半个线宽,区别只是帽沿是圆是方。签名用 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 全流程。

八、动手练习

  1. 直连 vs 贝塞尔对比:把 drawStroke 的贝塞尔分支改成纯 lineTo 折线,写「永」字对比效果;
  2. 鼓包实验:故意用 quadraticCurveTo(pts[i+1], pts[i+2])(控制点 = 采样点),观察每个采样点处的鼓包;
  3. 线帽实验:把 lineCap 依次改为 butt / square / round,对比笔迹端点形状;
  4. 点数退化实验:写一个「点一下」的轻触,观察 ❤️ 点分支是否画出一个圆点。

九、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),这里正好截取整张画布。

注意两点

  1. 同步行为:这个 API 是同步的,调用返回时像素已经拷贝完成,可以直接用于编码。如果画布很大(如长图),同步拷贝可能耗时较长,但签名板尺寸固定且不大,毫秒级完成;
  2. 坐标系:四个参数 (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():创建编码器;
  • PackingOptionformat 支持 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);
  • 文件目录:应用沙箱的 cacheDirgetContext(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)}` });
  }
}

几个工程细节

  1. 空画布守卫:没有笔迹就导出毫无意义,先 toast 提示——边界校验放在动作入口
  2. try/catchpacking 是异步,openSync/writeSync 是同步 IO,都可能抛异常(磁盘满、编码失败),捕获后提示而非静默崩溃;
  3. 文件名带时间戳signature_${Date.now()}.png,重复导出不覆盖;
  4. 成功后更新文案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 都要

下一节给出签名板完整代码解读与运行效果说明,收尾整个应用。

八、动手练习

  1. 换 JPEG 导出:把 format 改成 image/jpeg,quality 分别试 80 / 50 / 20,对比文件体积与边缘质量,理解有损压缩;
  2. 透明底实验:导出 PNG 后用看图软件(或 PS)把图片放到深色背景上,观察透明 vs 白底的差异;
  3. release 遗漏实验:注释掉 pixelMap.release()packer.release(),连续导出 50 次,用 Profiler 观察内存曲线——直观感受资源泄漏;
  4. 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:

  1. 换 JPEG 导出:把 format 改成 image/jpeg,quality 分别试 80 / 50 / 20,对比文件体积与边缘质量,理解有损压缩;
  2. 透明底实验:导出 PNG 后用看图软件(或 PS)把图片放到深色背景上,观察透明 vs 白底的差异;
  3. release 遗漏实验:注释掉 pixelMap.release()packer.release(),连续导出 50 次,用 Profiler 观察内存曲线——直观感受资源泄漏;
  4. filesDir 实验:把 cacheDir 换成 getContext(this).filesDir,导出后重启应用,用文件管理器(devtools 查看沙箱)确认文件是否仍在;
  5. 导出速度实验:在 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 变化,把坐标映射从恒等改为带缩放矩阵。

五、应用小结

手写签名板完整落地了**「数据驱动绘制」**的全套模式:

  1. 模型:Stroke 点序列,画面 = 数据的投影;
  2. 采集:onTouch 三态,坐标直接用 vp;
  3. 绘制:clearRect + 中点贝塞尔逐笔重绘,round 线帽线角出质感;
  4. 操作:撤销/清空 = 改数据 + 重绘,天然正确;
  5. 导出:getPixelMap → ImagePacker → fileIo,PNG 落盘可分发。

至此,签名板这个「最小的绘制应用」闭环完成。它只用了 Canvas 的一小部分能力(路径 + 像素),却完整覆盖了采集 → 绘制 → 操作 → 输出的全链路——这正是本系列刻意设计的「低起点、全流程」学习路径。

下一节开启第 2 个应用——涂鸦画板:多色画笔、橡皮擦、撤销重做,数据模型全面升级。

六、FAQ 补充

Q:为什么标题栏刷新按钮用「↻」文本而不是图标?
减少资源依赖,emoji/符号即可表达语义,工程里完全可以用系统图标或自定义图标替换,不影响功能。

Q:本应用和浏览器 Canvas 有何异同?
API 名称与语义基本一致(moveTo/lineTo/quadraticCurveTo 等),差异在「生态」:HarmonyOS 用 CanvasRenderingContext2D 绑定组件、getPixelMap 替代 toDataURLImagePacker 替代浏览器编码流程。有 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 个应用——涂鸦画板:多色画笔、橡皮擦、撤销重做,数据模型全面升级。

九、动手练习:把签名板「玩坏」

学习一个应用的最好方式不是读,而是改。以下练习由易到难,每个都能加深对数据驱动模型的理解:

  1. 改颜色:把 Down 里的 color: '#1F2937' 改成任意色值,运行后书写,验证「一笔的视觉属性固化在数据里」;
  2. 改线宽:把 width: 3 改成 8,写一个大字,对比笔迹质感——线宽 8vp 的笔迹会遮挡更多细节;
  3. 加粗度切换:给操作栏加两个按钮「细/粗」,onClick 里改一个 @State penWidth,Down 时读取——第 2 个应用的雏形;
  4. 加颜色选择:加一行 4 色圆点,点击切换 @State penColor——调色盘的雏形;
  5. 撤销体验:快速连画 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 的架构决定了它能长成什么样的产品

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐