一、从一笔一点到连贯轨迹

初次实现全屏手写板时,最直观的思路是:捕获 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.UpTouchType.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.UpTouchType.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 的全屏手写实现不是"给你一个手写板",而是"教你怎样从原始触摸事件构建一条连贯轨迹"。

三个关键的设计决策:

  1. 增量采集:每个阶段(按下、移动、抬起)都对应明确的数据操作,而不是等待最终状态。这让实时反馈、撤销能力和跨设备标准化成为可能。

  2. 状态管理activeStroke 表达了"笔画进行中"的明确状态,让事件处理变得鲁棒(容错率高)。

  3. 延迟验证:笔画有效性不在采集时决定,而在用户抬起时(甚至可以在导出时)。这给应用足够的空间调整验证规则。

对于开发者的实践启示:

  • 理解事件序列:不要试图用单个事件表达整个交互,而是把 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 服务已开通且应用服务凭据/授权配置已完成;服务密钥只保存在安全配置中。

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

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐