HarmonyOS 6.1.1 Canvas:第一次做全屏手写板-怎样把按下、移动与抬起连成一条轨迹
一、从一笔一点到连贯轨迹
初次实现全屏手写板时,最直观的思路是:捕获 TouchEvent,存储点坐标。但 HarmonyOS 6.1.1 上的 Canvas 手写实现揭示了一个被忽视的细节——不是"一次确认一条轨迹",而是"按下时创建轨迹,移动时增量添加,抬起时闭合笔画"。
这种设计看似简单,实际上决定了轨迹采集的实时性、编辑能力和跨设备兼容性。

1.1 代码层面的事实
维修签字中心的实现(full_screen_signature.ets)展示了完整的三阶段流程:
private handleSignatureTouch(event: TouchEvent): void {
const point = this.pointFromTouch(event);
// 阶段一:按下(TouchType.Down)
if (event.type === TouchType.Down && point) {
const stroke: Stroke = { points: [point] };
this.activeStroke = stroke;
this.strokes = [...this.strokes, stroke];
return;
}
// 阶段二:移动(TouchType.Move)
if (event.type === TouchType.Move && point && this.activeStroke) {
this.activeStroke.points.push(point);
this.strokes = [...this.strokes];
this.redraw();
return;
}
// 阶段三:抬起(TouchType.Up / Cancel)
if (event.type === TouchType.Up || event.type === TouchType.Cancel) {
if (point && this.activeStroke) {
this.activeStroke.points.push(point);
}
this.activeStroke = undefined;
}
}
这段代码有三个关键特性值得注意:
特性一:按下时即时创建轨迹对象。不是等待移动事件才初始化,而是在 TouchType.Down 时立即构建 Stroke 对象并加入数组。这意味着平台在感知笔触的第一时刻就锁定了轨迹的起点。
特性二:移动事件中增量更新活跃笔画。每个 TouchType.Move 都通过 push() 将新点添加到 activeStroke.points,而非覆盖。平台并不关心总点数,只关心"当前这一步新增了什么"。
特性三:抬起时补全终点。TouchType.Up 或 TouchType.Cancel 时,最后一个点被显式添加。这看似冗余,实际上保证了笔画的几何完整性——在某些设备或场景下,Move 事件的最后一帧与 Up 事件的触点位置可能不同。
二、为什么不是"一次确认"?增量设计的三重价值

2.1 实时反馈
如果采用"按下时记录起点,抬起时一次性提交所有点"的设计,应用必须等待用户松开手指才能获得完整轨迹。在手写交互中,这意味着:
- 用户看不到正在进行的笔划
- 应用无法进行实时的笔迹优化或滤波
- 长笔画需要等待数百毫秒才能显示
而增量设计让每个 Move 事件都立即驱动重绘:
if (event.type === TouchType.Move && point && this.activeStroke) {
this.activeStroke.points.push(point);
this.strokes = [...this.strokes]; // 触发状态更新
this.redraw(); // 实时绘制
}
这样用户在手写过程中就看到笔迹被逐点绘制,视觉反馈与物理笔触同步。
2.2 撤销能力的基础

增量采集让撤销(undo)变成了原子操作。代码中的 undoStroke() 方法只需删除最后一笔:
private undoStroke(): void {
this.strokes = this.strokes.slice(0, this.strokes.length - 1);
this.activeStroke = undefined;
this.redraw();
}
如果轨迹是一次性提交的点数组,撤销要么是"全部撤销",要么需要额外的版本管理。而增量模型中,每个独立的笔画都可被精确撤销——用户可以逐笔修正,而无须清空整个签名重来。
2.3 跨设备坐标标准化的先决条件
手写设备的分辨率、DPI、触摸采样率差异很大。HarmonyOS 的做法是采集原始坐标,然后通过 normalizedStrokes() 方法统一转换为标准化空间:
private normalizedStrokes(): Stroke[] {
const width = this.canvasWidth > 0 ? this.canvasWidth : 240;
const height = this.canvasHeight > 0 ? this.canvasHeight : 130;
return this.strokes.map((stroke: Stroke): Stroke => {
const normalizedPoints: StrokePoint[] = stroke.points.map((point: StrokePoint): StrokePoint => {
return { x: point.x * 240 / width, y: point.y * 130 / height };
});
return { points: normalizedPoints };
});
}
这个转换在导出时进行,而不是采集时。原始坐标保留设备的完整精度信息,标准化坐标用于跨平台归档(如 SVG 附件)。这种分离只有在增量采集下才有意义——你可以在不丢失精度的前提下先收集,再按需转换。
三、activeStroke:如何管理"正在进行中"的笔画
代码中有一个关键的状态变量:
@State private activeStroke: Stroke | undefined = undefined;
它代表当前正在采集的笔画。生命周期是:
- 按下:
activeStroke = 新的Stroke对象 - 移动:
activeStroke.points.push(新点) - 抬起/取消:
activeStroke = undefined
这个设计解决了三个实际问题:
问题一:多点触摸的消歧。理论上手机或平板可能同时捕获多个触点(如用户的两根手指都接触屏幕)。activeStroke 的单一引用确保同一时刻只有一笔在进行——其他触点被忽略或归为新笔画。
问题二:事件重排的容错。在某些低端设备或高负载场景下,触摸事件队列可能乱序。代码通过检查 this.activeStroke 的存在性来验证当前是否处于"笔画进行中"状态:
if (event.type === TouchType.Move && point && this.activeStroke) {
// 只有当 activeStroke 存在时才处理 Move 事件
}
如果收到无序的 Move 事件(比如前一笔还未结束就收到下一笔的 Down),现有的 activeStroke 会被覆盖,新笔画的第一个点会被添加,旧笔画被自动闭合(因为没有相应的 Up 事件来更新它)。
问题三:内存和渲染性能。活跃笔画对象只在绘制时被引用,一旦抬起就立即释放。这比维护整个笔画历史栈更轻量。
四、笔画验证:为什么需要"最少3个点"和"轨迹长度"
手写识别中,有效笔画的定义不仅是"有点",而是:
private isValidStroke(stroke: Stroke): boolean {
if (stroke.points.length < 3) {
return false;
}
let length = 0;
for (let index = 1; index < stroke.points.length; index += 1) {
const horizontal = stroke.points[index].x - stroke.points[index - 1].x;
const vertical = stroke.points[index].y - stroke.points[index - 1].y;
length += Math.sqrt(horizontal * horizontal + vertical * vertical);
}
return length >= 12;
}
两个阈值的含义:
最少3个点是几何最小值。两个点只能确定一条线段,无法区分真实笔划与误触。3个点允许应用判断笔迹的方向变化和曲率。
轨迹长度 ≥ 12是物理阈值。在 240×130 的标准化空间中,这大约是画布宽度的 5%。这个阈值过滤掉了抖动(用户手指不稳定产生的微小晃动)。代码会逐笔计算欧氏距离的累和,完全捕获了笔画的真实轨迹长度,而不是简单地检查起点和终点的直线距离。
这两个条件联合使用的效果是:允许短笔画存在(用户可能写"i"的点),但拒绝纯粹的抖动。
五、状态文案的业务映射
每次触摸阶段结束,应用都更新 signatureStatus 文案:
if (event.type === TouchType.Down && point) {
this.signatureStatus = '正在采集现场签名…';
}
if (event.type === TouchType.Up || event.type === TouchType.Cancel) {
this.signatureStatus = this.validStrokeCount() > 0
? '笔迹已采集,可确认归档'
: '笔迹过短,请重新签字';
}
这不是装饰性文案,而是实时的业务状态指示。平台在每个触摸阶段都做出判断:
- 第一次按下时,立即告知用户"采集已启动"
- 抬起时,基于笔画有效性实时判断"是否可以确认"
这种设计把验证责任从"提交时检查"前移到"笔画完成时检查",让用户能立即看到每笔是否被系统接受,而不必等待后续的表单提交。
六、重绘触发的时机选择
代码中有两类重绘调用:
// 关键重绘:每个 Move 事件立即触发
if (event.type === TouchType.Move && point && this.activeStroke) {
this.activeStroke.points.push(point);
this.strokes = [...this.strokes];
this.redraw(); // ← 立即触发
}
// 最终重绘:抬起时统一触发
if (event.type === TouchType.Up || event.type === TouchType.Cancel) {
this.activeStroke = undefined;
this.redraw(); // ← 最后一次渲染
}
为什么每个 Move 都要立即重绘,而不是在抬起时统一处理?
答案涉及三个考量:
考量一:触摸采样率与显示刷新率的不匹配。触摸事件可能以 100Hz 采样,但屏幕只以 60Hz 刷新。如果在 Move 中只更新点数据,在下一次屏幕刷新时才绘制,会丢失采样间隔内的细节。立即调用 redraw() 确保即使屏幕来不及渲染每一帧,也能捕获完整的点集。
考量二:应用层的性能可见性。每次立即重绘让应用能实时观察到自己的渲染负担。如果在高频 Move 事件中重绘导致帧率下降,开发者会立即看到卡顿,而不是在事后分析日志。
考量三:触摸响应感的心理预期。研究表明,用户对手写工具的响应延迟非常敏感。即使只延迟 16ms(一帧),用户也能察觉笔迹与笔触的不同步。立即重绘保证了视觉反馈的最小延迟。
七、从"点的数组"到"笔的流程"——平台能力下沉,应用责任转移
HarmonyOS 6.1.1 的手写实现体现了一个深层的设计哲学变化:
传统设计(平台提供高度集成的手写控件):
- 应用只需要放一个 SignaturePad 组件
- 平台负责采集、渲染、验证、导出
- 应用的责任:处理生成的签名图片或数据
当前设计(平台提供原始触摸事件和 Canvas,应用自己组织流程):
- 应用捕获原始 TouchEvent
- 应用自己构建 Stroke 数据结构
- 应用自己验证笔画有效性
- 应用自己实现撤销、清空、历史管理
这种转移的代价是应用需要理解"按下→移动→抬起→验证→存储"的完整流程。但收益是:
- 可见性:每个环节都在应用控制下,可以插入业务逻辑(如权限检查、水印、加密)
- 灵活性:可以对不同场景采用不同的采集策略(如医疗签名与游戏手写)
- 可扩展性:轨迹数据成为业务资产,可以进一步分析(笔速、压力、停顿)
相对应的,应用需要承担以前平台承担的复杂性。
八、常见问题深解
Q1:为什么 Move 事件中要检查 this.activeStroke 的存在性?
A:确保状态机的正确性。如果收到孤立的 Move 事件(没有前置的 Down),代码会安全地忽略它,而不会崩溃或创建幽灵笔画。这是防御式编程的典型——假设事件队列可能乱序或丢失。
Q2:为什么要在 Up 事件时再次 push 终点?
A:保证几何闭合。在某些触摸驱动实现中,最后一个 Move 事件的坐标与 Up 事件时的手指实际位置可能有微小偏差。显式添加最后一个点确保笔画端点的准确性,特别是对于需要精确匹配(如签名对比)的场景。
Q3:activeStroke 为什么用 undefined 而不是 null?
A:TypeScript 类型严格性。undefined 作为默认值更符合 ArkTS 的最佳实践。在 Move 处理中 this.activeStroke 的存在性检查会更自然:if (this.activeStroke) 直接判断定义状态,而不需要 !== null。
Q4:笔迹长度阈值(12)是怎么确定的?
A:基于标准化空间和用户体验的平衡。在 240×130 的标准空间中,12 像素大约是一个"可感知的笔划"的最小长度。太小(如 5)会导致抖动被接受;太大(如 50)会拒绝短笔画(如标点或字母的装饰笔)。这个值通常通过大规模用户测试确定,不是任意的。
Q5:为什么 normalizedStrokes 方法在导出时而不是采集时标准化?
A:保留原始精度。采集时保留设备原生坐标,导出时才转换到标准空间。这样可以同时满足两个需求:(1) 本地编辑时使用高精度原始数据,避免累积舍入误差;(2) 跨平台分享时使用统一的标准化坐标,保证兼容性。
Q6:如果用户在抬起前快速移出 Canvas 区域会怎样?
A:代码中的 handleSignatureTouch 只处理 Canvas 范围内的事件。如果用户快速移出,触摸事件会路由到其他组件,导致 Move 事件中断。此时 activeStroke 仍然引用该笔画对象,但不再收到更新。当用户抬起手指时,无论在何处,TouchType.Up 或 TouchType.Cancel 都会被触发,笔画被闭合。实际效果是笔画在移出时就停止了延伸,这是符合预期的。
Q7:Canvas 的 redraw() 和 React 状态更新 this.strokes = [...this.strokes] 的顺序重要吗?
A:非常重要。代码中状态更新总是先于 redraw():
this.activeStroke.points.push(point);
this.strokes = [...this.strokes]; // 状态更新
this.redraw(); // 然后重绘
原因是 ArkTS 的 UI 更新机制。状态变化(@State 注解)会触发 UI 重新计算,而 redraw() 是 Canvas 的直接绘制命令。如果顺序反过来,Canvas 会绘制已更新的点数据,但 UI 框架可能还在处理旧的状态,导致渲染不一致。
九、工程现实的约束
约束一:触摸事件的队列
HarmonyOS 的触摸事件是异步分发的,意味着:
- 高频率手写操作时,事件可能堆积
- Move 事件的密度取决于系统负载和屏幕刷新率
- 在极限情况下(多个应用竞争系统资源),采样间隔可能长达 50ms,导致笔迹变得"粗糙"
应用层无法改变这一点,但可以通过贝塞尔曲线插值在相邻点之间补充中间点,平滑笔迹。
约束二:Canvas 绘制性能
每个 Move 事件都调用 redraw() 意味着可能每秒触发 100 次重绘。在高端设备上不成问题,但在中低端设备上可能导致帧率下降。
代码没有进行帧率限制(如只在偶数帧才重绘),这是一个设计选择——宁愿牺牲整体帧率,也要保证笔迹的实时性。如果应用需要同时运行其他高负载操作,可以考虑在 Move 处理中添加防抖逻辑。
约束三:点数据的内存占用
一条包含 1000 个点的笔画会产生大约 8KB 的数据(每个点两个数字)。长时间签名或频繁撤销后重签会积累多条笔画。代码中 this.strokes 数组没有大小限制。
在真实应用中应该考虑:
- 限制单个会话的最大笔画数(如 50 笔)
- 定期清理无效笔画(如撤销后的笔画)
- 或者实现分层存储(当前编辑的笔画在内存,历史笔画序列化到磁盘)
十、技术演进的方向
HarmonyOS 6.1.1 的这种设计(应用自己处理触摸流程,平台保证事件完整性)反映了生态的一个趋势:
从"黑盒组件"到"透明协议"。早期的手写控件是黑盒的——你放上去,它就工作,但你看不到内部发生了什么。现在的设计思路是让应用能看到并控制每一步。
这种转变的代价是学习曲线变陡。新手可能需要花时间理解"为什么要在 Down 时创建对象"和"为什么需要 activeStroke"。但收益是:一旦理解了这个流程,开发者就能扩展和定制任何手写场景——不仅仅是签名,还可以是注释、草图、数学公式识别等。
十一、结论与应用启示
HarmonyOS 6.1.1 的全屏手写实现不是"给你一个手写板",而是"教你怎样从原始触摸事件构建一条连贯轨迹"。
三个关键的设计决策:
-
增量采集:每个阶段(按下、移动、抬起)都对应明确的数据操作,而不是等待最终状态。这让实时反馈、撤销能力和跨设备标准化成为可能。
-
状态管理:
activeStroke表达了"笔画进行中"的明确状态,让事件处理变得鲁棒(容错率高)。 -
延迟验证:笔画有效性不在采集时决定,而在用户抬起时(甚至可以在导出时)。这给应用足够的空间调整验证规则。
对于开发者的实践启示:
- 理解事件序列:不要试图用单个事件表达整个交互,而是把 Down、Move、Up 看作一个状态机
- 保留原始数据:采集时存储完整的点集,标准化和处理留给后续步骤
- 实时反馈:在 Move 阶段就让用户看到笔迹,而不是等待确认
- 灵活的验证:让应用根据业务场景调整"有效笔画"的定义,而不被平台硬性规则约束
这不仅是一篇关于手写板实现的技术文章,更是对"平台能力下沉、应用智能上升"这个生态趋势的一次观察。
必要条件|发布前准备清单
发布前逐项确认:
- SDK/API与构建工具:HarmonyOS 6.1.1 API 24 已安装,
entry模块构建成功。

- Kit:本文页面使用的 Kit 已引入,API 与 API 24 匹配。
- 模块/页面:Stage 配置、页面路由、设备类型和权限声明完整。
- 设备权限:Camera、AI字幕等能力已完成运行时授权,拒绝状态已处理。

- 系统能力/硬件:目标设备具备本文需要的摄像头、麦克风、地图、视觉或文件能力。


MapKit文章在系统能力勾选项后增加一项:AppGallery Connect 中项目已选定、应用包名和签名证书与工程一致、MapKit 服务已开通且应用服务凭据/授权配置已完成;服务密钥只保存在安全配置中。



更多推荐
所有评论(0)