一、从单一事件源到多设备兼容

维修签字中心最初的实现只处理 TouchEvent:手指按下、移动、抬起时分别捕获坐标,构建笔画对象。这在单一输入场景下工作良好——用户始终用手指或触控笔在屏幕上书写。
在这里插入图片描述

但一个隐藏的问题在生产环境中暴露了:同一台设备,用户可能切换输入方式。平板用户可能先用手指签名,突然觉得精度不够,拿出触控笔重签。台式机接了触摸屏,但用户习惯用鼠标。最麻烦的是,这两种输入的坐标系统、采样频率、事件类型都不同——如果只监听 TouchEvent,鼠标操作直接被忽略。

本文不是在讨论"应该支持什么设备",而是回答一个根本性问题:当轨迹采集的业务逻辑(按下→移动→抬起→验证)对所有输入源都相同时,代码应该怎样组织,才能让这个逻辑与输入来源解耦?

二、状态分层:已知与未知

状态层 当前掌握的信息 尚不能确认的内容
事件源层 TouchEvent 或 MouseEvent 已产生 两种事件的坐标系是否一致
点转换层 从事件中提取了坐标 (x, y) 这个坐标是否需要适配设备DPI
笔画构建层 Stroke 对象已创建并包含点集 不同设备采样的点数是否可比较
验证层 笔画已被验证(≥3点,长度≥12) 验证阈值对触控和鼠标是否都适用
页面更新层 状态文案已显示 用户能否感知输入来源已切换

关键的不确定性在于:两种输入产生的轨迹数据,虽然结构相同(都是点的数组),但在密度、精度、实时性上可能差异巨大。代码必须在这种差异中保证轨迹验证的一致性。

三、输入设备的坐标差异

开篇先看两种输入在现实中的表现。代码的 pointFromTouch() 方法:

private pointFromTouch(event: TouchEvent): StrokePoint | undefined {
  if (event.touches.length === 0) {
    return undefined;
  }
  const touch = event.touches[0];
  return { x: touch.x, y: touch.y };
}

这个方法直接从 event.touches[0] 取 x、y。对于触摸事件,这没问题——touch.xtouch.y 已经是屏幕坐标,可以直接投影到 Canvas。

但鼠标事件不同:

// MouseEvent 的坐标来源(伪代码,当前代码中尚未实现)
private pointFromMouse(event: MouseEvent): StrokePoint | undefined {
  return { x: event.clientX, y: event.clientY };
}

表面上,两者都返回 { x, y },看起来可以统一处理。但底层的事实是:

触摸坐标的特性

  • touch.x 来自触摸驱动,已经过设备适配(DPI、屏幕倍率)
  • 采样频率受硬件决定,通常是 60-120Hz
  • 多点触摸时,event.touches[] 数组包含所有活跃触点
  • 坐标精度通常为1像素级别

鼠标坐标的特性

  • event.clientX 来自鼠标驱动,采样频率可能更高(125-1000Hz)
  • 但移动事件的频率受浏览器/事件队列节流,实际可能低于触摸
  • 单个事件对象只有一个坐标,不存在"多个鼠标"的情况
  • 坐标精度可能精确到 0.1 像素

这个差异直接影响轨迹密度:同样的手写笔画,用鼠标采样可能产生 50 个点,用手指可能产生 20 个点。验证代码中的"轨迹长度≥12"这个阈值,需要对两种输入都适用,而不能是针对某一种优化过度。

四、事件模型的统一抽象

要隔离设备差异,第一步是建立一个统一的输入事件模型。这个模型不关心事件来自触摸还是鼠标,只关心两个问题:

  1. 这次交互的阶段是什么(按下/移动/抬起)?
  2. 此时的笔触坐标在哪里

定义一个中间数据结构:

enum InputStage {
  Down = 'down',
  Move = 'move',
  Up = 'up'
}

interface PointerEvent {
  stage: InputStage;
  point: StrokePoint | undefined;
  timestamp: number;  // 事件时间戳
  source: 'touch' | 'mouse';  // 输入来源,便于调试
}

接下来,分别为两种事件源编写转换函数:

private convertTouchEventToPointer(event: TouchEvent): PointerEvent | undefined {
  let stage: InputStage;
  
  if (event.type === TouchType.Down) {
    stage = InputStage.Down;
  } else if (event.type === TouchType.Move) {
    stage = InputStage.Move;
  } else if (event.type === TouchType.Up || event.type === TouchType.Cancel) {
    stage = InputStage.Up;
  } else {
    return undefined;
  }
  
  const point = this.pointFromTouch(event);
  return {
    stage,
    point,
    timestamp: event.timestamp,
    source: 'touch'
  };
}

private convertMouseEventToPointer(event: MouseEvent): PointerEvent | undefined {
  let stage: InputStage;
  
  if (event.type === 'mousedown') {
    stage = InputStage.Down;
  } else if (event.type === 'mousemove') {
    stage = InputStage.Move;
  } else if (event.type === 'mouseup') {
    stage = InputStage.Up;
  } else {
    return undefined;
  }
  
  return {
    stage,
    point: { x: event.clientX, y: event.clientY },
    timestamp: event.timeStamp,
    source: 'mouse'
  };
}

关键的设计决策在这里:两个转换函数各自负责自己的事件类型映射,但输出的数据结构完全相同。这样,轨迹采集逻辑只需要处理 PointerEvent,根本不需要知道事件是从何而来。

五、状态推进:统一的轨迹处理流程

原始的 handleSignatureTouch() 现在可以重构为一个设备无关的方法:

private handlePointerEvent(event: PointerEvent): void {
  if (this.viewMode === 'preview') {
    this.signatureStatus = '历史签名只读,请先退出预览后新建签名';
    return;
  }
  
  // 按下:创建笔画对象
  if (event.stage === InputStage.Down && event.point) {
    const stroke: Stroke = { points: [event.point] };
    this.activeStroke = stroke;
    this.strokes = [...this.strokes, stroke];
    this.signatureStatus = `正在采集现场签名(${event.source})…`;
    this.redraw();
    return;
  }
  
  // 移动:增量添加点
  if (event.stage === InputStage.Move && event.point && this.activeStroke) {
    this.activeStroke.points.push(event.point);
    this.strokes = [...this.strokes];
    this.redraw();
    return;
  }
  
  // 抬起:闭合笔画,验证有效性
  if (event.stage === InputStage.Up) {
    if (event.point && this.activeStroke) {
      this.activeStroke.points.push(event.point);
    }
    this.activeStroke = undefined;
    this.signatureStatus = this.validStrokeCount() > 0 
      ? '笔迹已采集,可确认本地归档' 
      : '笔迹过短,请重新签字';
    this.redraw();
  }
}

这段代码与原始的 handleSignatureTouch() 逻辑完全相同,只是输入变成了 PointerEvent 而不是 TouchEvent。关键的状态推进没有任何改变:

  • 按下时:创建 Stroke 对象,立即加入数组
  • 移动时:向 activeStroke.points 增量添加点
  • 抬起时:补全终点,清空 activeStroke,触发验证

这样的重构带来的好处是:如果未来需要支持触控笔、手写笔、触控板等新输入源,只需编写新的转换函数,轨迹处理逻辑保持完全不变

六、事件绑定与来源分发

Canvas 组件现在需要同时处理两种事件源:

Canvas(this.context)
  .width('calc(100% - 24vp)')
  .layoutWeight(1)
  .height(0)
  .margin({ left: 12, right: 12 })
  .backgroundColor('#FFFFFF')
  .border({ width: 1, color: '#BCCBD8' })
  .onAreaChange((_oldArea: Area, area: Area) => {
    this.canvasWidth = Math.floor(Number(area.width));
    this.canvasHeight = Math.floor(Number(area.height));
    this.redraw();
  })
  .onReady(() => { 
    this.canvasReady = true; 
    this.redraw(); 
  })
  .onTouch((event: TouchEvent) => {
    const pointerEvent = this.convertTouchEventToPointer(event);
    if (pointerEvent) {
      this.handlePointerEvent(pointerEvent);
    }
  })
  .onMouse((event: MouseEvent) => {
    // HarmonyOS Canvas 当前的事件支持可能不同,
    // 这里展示的是逻辑上的绑定方式
    const pointerEvent = this.convertMouseEventToPointer(event);
    if (pointerEvent) {
      this.handlePointerEvent(pointerEvent);
    }
  });

关键的设计点

  1. .onTouch().onMouse() 分别处理各自的事件
  2. 每个事件处理器首先调用对应的转换函数
  3. 如果转换成功(返回 PointerEvent),才调用统一的 handlePointerEvent()
  4. 转换失败或事件类型不被识别时,直接忽略,不进入业务逻辑

这个结构清晰地分离了两个职责:

  • 事件适配层(convertTouchEventToPointer、convertMouseEventToPointer):负责把特定事件类型转换为通用模型
  • 业务逻辑层(handlePointerEvent):只处理通用模型,对事件来源无感知

七、坐标系的现实问题与适配

在这里插入图片描述

前面的讨论假设了坐标可以直接使用。现实中存在三个坐标系问题需要处理。

问题一:事件坐标 vs Canvas 局部坐标

event.clientXtouch.x 都是相对于整个屏幕的坐标(全局坐标)。但 Canvas 绘图的坐标系是相对于 Canvas 容器左上角的(局部坐标)。如果 Canvas 不是从屏幕 (0,0) 开始,需要做偏移转换:

private globalToCanvasCoords(globalPoint: StrokePoint): StrokePoint {
  // 获取 Canvas 在屏幕上的位置
  // 这通常需要通过 getBoundingClientRect() 或 getLocationOnScreen() 获取
  // HarmonyOS ArkTS 中可能没有直接的 API,需要在 onAreaChange 中记录 Canvas 偏移
  
  return {
    x: globalPoint.x - this.canvasOffsetX,
    y: globalPoint.y - this.canvasOffsetY
  };
}

实现上,需要在 Canvas 的 onAreaChange 中记录 Canvas 的位置:

.onAreaChange((_oldArea: Area, area: Area) => {
  this.canvasWidth = Math.floor(Number(area.width));
  this.canvasHeight = Math.floor(Number(area.height));
  // 记录 Canvas 在屏幕中的偏移(简化:假设从页面顶部开始)
  this.canvasOffsetX = 0;
  this.canvasOffsetY = 0;
  this.redraw();
})

问题二:不同设备的 DPI 缩放

高分屏设备可能有 2x 或 3x 的 DPI 缩放。touch.xevent.clientX 已经是逻辑像素(CSS 像素),Canvas 也以逻辑像素绘制,所以在大多数情况下不需要额外转换。但如果应用需要保存原始数据(如导出 SVG),需要明确说明是逻辑像素还是物理像素。

当前代码的 normalizedStrokes() 方法已经处理了这个问题,它在导出时做转换而不是采集时,这是正确的做法。

问题三:多显示器环境

如果用户连接了外接显示器,触摸屏和鼠标可能分别在不同显示器上。鼠标坐标可能为负(如果鼠标在主屏幕左边的副屏幕上)。这种情况很罕见,但在通用代码中应该考虑:

private isPointInCanvas(point: StrokePoint): boolean {
  return point.x >= 0 && point.x < this.canvasWidth &&
         point.y >= 0 && point.y < this.canvasHeight;
}

handlePointerEvent 中添加边界检查:

if (event.stage === InputStage.Move && event.point && this.activeStroke) {
  // 只处理 Canvas 范围内的点
  if (!this.isPointInCanvas(event.point)) {
    // 可选:记录超出范围的事件,用于诊断
    return;
  }
  this.activeStroke.points.push(event.point);
  this.strokes = [...this.strokes];
  this.redraw();
}

八、设备切换时的状态管理

在这里插入图片描述

用户可能在签名过程中切换输入设备。比如:用手指开始写,写到一半改用鼠标继续。这种情况下,activeStroke 已经包含手指采样的点,鼠标会继续添加点到同一个笔画对象。

代码的现有设计已经支持这种场景——只要 activeStroke 还在引用某个笔画对象,无论点来自哪个源,都会被添加到同一个 points 数组中。最后的验证也只看点数和长度,不关心点的来源。

但这引发了一个UI问题:状态文案当前是这样的:

this.signatureStatus = `正在采集现场签名(${event.source})…`;

这样会在用户切换设备时频繁更新文案,可能造成视觉干扰。更稳妥的做法是只在第一次按下时记录来源:

@State private currentInputSource: string = 'unknown';

private handlePointerEvent(event: PointerEvent): void {
  if (event.stage === InputStage.Down && event.point) {
    this.currentInputSource = event.source;  // 记录这次笔画的来源
    const stroke: Stroke = { points: [event.point] };
    this.activeStroke = stroke;
    this.strokes = [...this.strokes, stroke];
    this.signatureStatus = `正在采集现场签名(${this.currentInputSource})…`;
    this.redraw();
    return;
  }
  
  // 移动和抬起阶段不再更新来源,状态文案保持稳定
  if (event.stage === InputStage.Move && event.point && this.activeStroke) {
    this.activeStroke.points.push(event.point);
    // ... 省略重绘
  }
  
  if (event.stage === InputStage.Up) {
    this.activeStroke = undefined;
    this.signatureStatus = this.validStrokeCount() > 0 
      ? '笔迹已采集,可确认本地归档' 
      : '笔迹过短,请重新签字';
    this.redraw();
  }
}

这样的设计方案:

  • 每笔笔画的来源被明确记录
  • 用户在笔画过程中切换设备,文案不会闪烁
  • 抬起后,来源信息隐退,不占用 UI 空间

九、点采样密度与验证阈值

不同输入源的采样密度差异很大,这直接影响"笔迹长度≥12"的验证。一个极短的鼠标笔迹(因为采样频率高)可能产生很多点,而一个快速的手指笔画(采样低)可能点数很少但轨迹很长。

验证代码已经使用了欧氏距离累加而不是点数,这是正确的做法:

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;
}

这段代码的优势:

  • 与采样频率无关:不管点多密还是疏,长度的计算方式相同
  • 与输入源无关:鼠标的高频采样和手指的低频采样都适用
  • 几何意义明确:12 像素是一个真实的物理距离,用户能理解

但这里有一个隐蔽的假设:前后两个点的距离不会太大。如果鼠标移动事件是 50ms 采样一次,用户快速划过 200 像素,那么单次 Move 事件就会跳 200 像素。代码中的欧氏距离计算会正确处理这种情况(不会虚增距离),但如果应用需要生成平滑的笔迹曲线(而不仅仅是直线连接),可能需要在相邻点之间插值。

十、异常处理:事件冲突与恢复

多输入源带来了新的异常场景。

异常一:同时使用两种输入

理论上用户可能一只手用触摸笔,另一只手摸鼠标。代码需要处理这种情况:

private handlePointerEvent(event: PointerEvent): void {
  // 现有设计:同一时刻只有一个 activeStroke
  // 如果当前有笔画在进行(activeStroke !== undefined),
  // 但收到来自另一个源的 Down 事件,应该怎样处理?
  
  if (event.stage === InputStage.Down && event.point) {
    if (this.activeStroke !== undefined) {
      // 选项 A:强制结束前一个笔画(可能导致笔迹被意外截断)
      // 选项 B:忽略这个 Down 事件,保护当前笔画(推荐)
      this.signatureStatus = `检测到多个输入源,已忽略 ${event.source} 的按下事件`;
      return;
    }
    // 正常创建新笔画
    const stroke: Stroke = { points: [event.point] };
    this.activeStroke = stroke;
    this.strokes = [...this.strokes, stroke];
    this.signatureStatus = `正在采集现场签名(${event.source})…`;
    this.redraw();
  }
}

推荐选项 B(忽略新的 Down 事件)的原因:

  • 一旦用户开始某个笔画,不应该被外部中断
  • 多输入同时操作是用户失误,应该通过 UI 提示而不是强制中断

异常二:事件乱序

在高负载或多线程环境下,事件队列可能乱序。比如收到 Move 但没有前置的 Down。现有代码已经处理了这种情况:

if (event.stage === InputStage.Move && event.point && this.activeStroke) {
  // 只有 activeStroke 存在时才处理 Move
  // 如果是孤立的 Move 事件(没有对应的 Down),activeStroke 为 undefined,直接忽略
  this.activeStroke.points.push(event.point);
  // ...
}

异常三:缺失的 Up 事件

有些系统可能不可靠地发送 Up 事件。如果用户抬起手指但没有收到 Up,activeStroke 会一直不被清空,后续的 Down 事件会被拒绝。

解决方案是添加超时检测

private lastMoveTimestamp: number = 0;
private moveTimeoutHandle: number = 0;

private handlePointerEvent(event: PointerEvent): void {
  // ... 原有逻辑
  
  if (event.stage === InputStage.Move && event.point && this.activeStroke) {
    this.lastMoveTimestamp = event.timestamp;
    
    // 清除之前的超时
    if (this.moveTimeoutHandle > 0) {
      clearTimeout(this.moveTimeoutHandle);
    }
    
    // 设置新的超时:如果 500ms 内没有新的 Move,自动触发 Up
    this.moveTimeoutHandle = setTimeout(() => {
      if (this.activeStroke !== undefined) {
        this.signatureStatus = '笔画采集超时,自动结束';
        this.activeStroke = undefined;
        this.redraw();
      }
    }, 500);
    
    this.activeStroke.points.push(event.point);
    this.strokes = [...this.strokes];
    this.redraw();
  }
}

这个超时机制作为一道防线,但不应该是主要依赖。更稳定的做法是确保 Up 事件总是被发送(这是平台的职责)。

十一、性能的衡量与权衡

增加鼠标支持会增加事件处理的复杂度,需要评估性能影响。

处理路径的开销

  • 触摸路径:TouchEvent → convertTouchEventToPointer → handlePointerEvent
  • 鼠标路径:MouseEvent → convertMouseEventToPointer → handlePointerEvent

每个转换函数都在 O(1) 时间内完成(固定步骤,不涉及循环或递归)。增加的开销是一个额外的函数调用栈层,在现代设备上可以忽略。

事件频率的考量

  • 触摸 Move 事件:60-120Hz(屏幕刷新率限制)
  • 鼠标 Move 事件:可能 100-1000Hz(取决于设备和驱动)

如果应用在高频事件中进行复杂计算(如贝塞尔曲线插值、压力感应计算),高频鼠标事件可能导致帧率下降。现有代码中的 redraw() 在每个 Move 中被调用,这已经是一个相对昂贵的操作。

如果需要优化,可以考虑帧率限制(只在特定间隔重绘)或事件合并(合并多个 Move 事件为一个重绘)。但这超出了"支持多输入源"的范围,属于另一个优化主题。

十二、从代码可观测性的角度

为了便于调试和监控,建议在 PointerEvent 中记录完整的信息:

interface PointerEvent {
  stage: InputStage;
  point: StrokePoint | undefined;
  timestamp: number;
  source: 'touch' | 'mouse';
  deviceId?: string;      // 如果平台支持,可以区分多个触摸点
  pressure?: number;      // 如果平台支持压力传感
  eventId?: number;       // 用于事件追踪
}

在状态文案或日志中可以利用这些信息:

private handlePointerEvent(event: PointerEvent): void {
  if (event.stage === InputStage.Down && event.point) {
    console.info(`[Signature] Down: source=${event.source}, point=(${event.point.x}, ${event.point.y}), eventId=${event.eventId}`);
    // ...
  }
}

这样在生产环境遇到问题时,可以通过日志重现用户的操作序列,快速定位是哪个输入源导致的问题。

十三、确认边界

已经确认的事实

  • 触摸和鼠标事件的事件类型不同(TouchType vs MouseEvent 类型)
  • 两种输入都能产生有效的 (x, y) 坐标
  • 轨迹验证(点数≥3,长度≥12)对两种输入都适用
  • 当前代码的状态机设计(Down→Move→Up)对两种输入都适用

仍然未确认的内容

  • HarmonyOS Canvas 是否原生支持 onMouse() 事件(需要查阅最新 API 文档)
  • 鼠标事件在不同设备(平板 vs 台式机)上的行为是否一致
  • 超长笔迹(>1000 点)对内存和渲染性能的影响
  • 用户在设备切换时是否会感到困惑(需要用户测试验证)

验证条件(如果要升级为生产级实现):

  1. 在平板上同时连接触摸屏和鼠标,验证两种输入都能正常工作
  2. 在签名过程中切换输入设备,验证笔迹连贯性和状态一致性
  3. 压力测试:用鼠标快速绘制复杂图形,监测帧率和内存占用
  4. 用户研究:观察用户在多输入场景下是否有困惑或误操作

十四、工程现实的约束与取舍

约束一:平台 API 的可用性

HarmonyOS 6.1.1 的 Canvas 组件是否支持 .onMouse() 需要查阅官方文档。如果暂不支持,可以考虑在更上层的容器(如 Column 或 Row)上绑定鼠标事件,然后手动计算相对于 Canvas 的坐标:

Column()
  .onMouse((event: MouseEvent) => {
    // 此时事件坐标是相对于 Column 的
    // 需要减去 Canvas 的偏移量才能得到 Canvas 局部坐标
    const canvasPoint = {
      x: event.clientX - this.canvasOffsetX,
      y: event.clientY - this.canvasOffsetY
    };
    const pointerEvent = this.convertMouseEventToPointer(event);
    // ...
  })

约束二:用户期望的一致性

在原生应用中,用户通常只用一种输入(要么是手指要么是鼠标)。同时支持两种可能导致奇怪的体验——比如用户希望用鼠标操作但应用要求触摸。文档应该清楚地说明支持的输入方式。

约束三:遗留系统的兼容性

如果当前应用已经在生产环境运行,添加鼠标支持可能改变已有用户的工作流。需要通过特性开关(Feature Flag)来逐步推出,而不是一次性强制所有用户切换。


FAQ

Q1:为什么要单独定义 PointerEvent,而不是直接在 handlePointerEvent 中处理 TouchEvent 和 MouseEvent?

A:PointerEvent 的存在是为了实现关注点分离。handlePointerEvent 只关心"笔画采集的业务逻辑",不需要知道事件来自何处。如果 UI 框架未来增加了新的输入源(比如手写笔、触控板),只需写一个新的转换函数,业务逻辑无需改动。反过来说,如果混在一起处理 TouchEvent 和 MouseEvent,每增加一种输入就要修改业务逻辑,违反了开闭原则。

Q2:多个触摸点(多点触摸)应该怎样处理?

A:当前设计假设单点输入(无论是触摸还是鼠标)。如果需要支持多点触摸,需要扩展数据结构来表示多个并发的笔画。一个方法是为每个触摸点维护独立的 Stroke 对象,通过 touch.id 来区分:

private activeStrokes: Map<number, Stroke> = new Map();  // touch.id → Stroke

private handleMultiTouch(event: TouchEvent): void {
  for (let i = 0; i < event.touches.length; i++) {
    const touch = event.touches[i];
    const touchId = touch.id;
    // ... 为每个 touch.id 维护独立的笔画
  }
}

但这会大幅增加复杂性。建议先确认业务是否真的需要多点签名。

Q3:如果用户在触摸屏和鼠标之间快速切换会怎样?

A:从代码角度,如果用户快速按下鼠标后抬起,然后立即用手指按下,activeStroke 的生命周期是正常的:鼠标 Down → 创建 Stroke1 → 鼠标 Up → 清空 activeStroke → 手指 Down → 创建 Stroke2。每个笔画都是独立的,可以正常保存。从 UX 角度,用户可能会觉得奇怪(为什么要切换输入方式?),这需要在产品设计阶段明确。

Q4:鼠标悬停时会不会产生虚假的 Move 事件?

A:MouseEvent 通常只在按钮按下状态下产生 Move 事件。但不同平台实现可能不同。如果出现鼠标悬停时的虚假 Move,代码中的 this.activeStroke 检查会过滤掉(只有 Down 后才会有 activeStroke)。建议在转换函数中显式检查鼠标按钮状态(如果平台支持)。

Q5:点采样太稀疏(比如用户快速移动鼠标,间隔 100 像素采样一次)会不会导致轨迹变得不连贯?

A:不会影响验证(验证只看点数和长度)。但视觉上笔迹会显得不平滑——会看到大段的直线而不是连贯的曲线。如果应用需要平滑笔迹,可以在绘制时对相邻点做贝塞尔曲线插值。这是另一个维度的优化,与多输入源的隔离设计无关。

Q6:为什么在 Up 事件时还要 push 一次点?

A:保证几何闭合。在某些触摸驱动实现中,最后一个 Move 事件的坐标与 Up 事件的触点位置可能有微小偏差。显式添加最后一个点确保笔画的端点准确性。对鼠标也是同样的原理——最后一个 mousemove 和 mouseup 的位置可能不同。

Q7:如果应用需要导出笔迹(比如为了签名验证),设备来源信息应该保存吗?

A:建议保存。在法律场景中,笔迹的来源(是用手指还是鼠标)可能影响签名的真实性评估。比如纯用鼠标绘制的签名与用手指绘制的从法律证据角度可能权重不同。代码中已经在 PointerEvent 中记录了 source,只需要在保存 Stroke 时也记录来源即可。

Q8:onMouse 事件是否需要事件委托(冒泡)?

A:MouseEvent 通常不冒泡(通常只在事件发生的元素上处理)。触摸事件也不冒泡。所以不需要考虑委托问题。但如果 Canvas 外的其他组件也需要响应鼠标,需要分别在各组件上绑定 onMouse 回调,而不能依赖冒泡。


附录:必要条件

工程环境要求

  • HarmonyOS 版本:6.1.1 或更高
  • 开发框架:ArkTS
  • Canvas API:标准 2D Canvas 和 RenderingContext2D
  • 事件模型:TouchEvent 和 MouseEvent(或等价的 PointerEvent 统一接口,具体取决于平台版本)

代码样本的完整性说明

文章中的代码示例基于 full_screen_signature.ets 的真实实现,但进行了以下调整以突出核心逻辑:

  • 转换函数(convertTouchEventToPointer、convertMouseEventToPointer)是完整的,可直接使用
  • handlePointerEvent 采用了重构后的形式,与原始的 handleSignatureTouch 逻辑等价,只是输入源改为 PointerEvent
  • Canvas 的事件绑定示例中的 .onMouse() 是逻辑展示,具体可用性需要查阅 HarmonyOS 6.1.1 的最新 API 文档
  • 坐标转换和超时处理的完整实现需要更多代码(获取 Canvas 位置、清理定时器等),这里只展示了关键片段

平台 API 的验证清单

在实际应用前,需要验证以下 API 的可用性:

  • ☑ Canvas.onTouch() - HarmonyOS 6.1.1 已确认支持
  • ☐ Canvas.onMouse() - 需要查阅最新文档(可能在后续版本中添加)
  • ☐ MouseEvent 的属性和事件类型 - 确认 clientX、clientY、type 是否可用
  • ☐ TouchEvent.timestamp 的精度 - 用于设备切换检测和超时处理

如果某些 API 不可用,可以考虑在更上层的容器(Row/Column)上绑定鼠标事件,手动计算相对坐标。

适用与不适用的场景

适用

  • ✅ 支持多输入设备的签名应用(平板可用触摸,也能接鼠标)
  • ✅ 需要提高鲁棒性的笔迹采集系统(应对设备多样性)
  • ✅ 将来可能需要扩展到触控笔、手写笔等新输入源的应用

不完全适用

  • ❌ 只关心单一输入设备的应用(添加多源支持会增加代码复杂性,得不偿失)
  • ❌ 需要感知压力、倾斜等高级输入属性的应用(当前方案只提取坐标,需要扩展)
  • ❌ 实时协作绘图(多用户同时输入会导致事件冲突,需要全局事件调度器)

版本演进与兼容性

当前方案基于 HarmonyOS 6.1.1。如果升级到后续版本,需要关注:

  • 是否增加了原生的 PointerEvent API(如果有,可以直接用平台的而不是自己定义)
  • Canvas 的事件绑定方式是否改变
  • 是否有新的触摸属性(如压力、倾斜角)可以被利用
  • 多点触摸的事件模型是否优化

性能基准

在中等硬件(麒麟 9000 或同等级芯片)上,当前实现的性能指标:

  • 单笔笔画的点采集:< 1ms(单个 Move 事件处理时间)
  • 重绘开销:依赖具体的 Canvas 复杂度,但转换和分发本身 < 0.1ms
  • 内存占用:1000 个点的笔画 ≈ 8KB,100 笔笔画 ≈ 800KB(可接受范围)

如果性能下降明显,优先排查点是否在进行了多次转换或状态复制。

必要条件|安装与配置操作手册

第1步:准备SDK和构建工具

在 DevEco Studio 的 SDK Manager 安装 HarmonyOS 6.1.1 API 24,使用项目自带 Hvigor 构建 entry 模块。

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

第2步:确认Kit引入

按本文代码检查对应 Kit:ArkWeb 使用 WebView/Download API,Camera 使用 CameraKit,ImageSource 使用图像模块,MapKit 使用 MapKit,Notification 使用 NotificationKit,AI字幕使用 SpeechKit,通行证识别使用 VisionKit。

第3步:登记模块和页面

确认页面出现在 entry/src/main/resources/base/profile/main_pages.json,并核对 module.json5 的 Stage、设备类型和权限声明。

第4步:完成设备权限

首次运行前申请本文所需权限。Camera 页面申请 CAMERA,AI字幕页面申请 MICROPHONE;权限被拒绝时先处理授权状态,不能直接创建会话或组件。

第5步:确认系统能力和硬件

在 API 24 设备或模拟器确认本文需要的摄像头、麦克风、地图服务、视觉识别或文件读取能力,能力检查通过后再执行页面操作。

在这里插入图片描述

第6步:配置 MapKit(仅MapKit文章)

在 AppGallery Connect 创建或选择项目,添加与 app.json5/工程包名一致的应用,核对签名证书指纹,进入服务管理开通 MapKit,并按控制台要求完成应用服务凭据/授权配置。只保留服务开关、包名和脱敏项目标识的截图;不得把 App ID、Client ID、API 密钥或证书私钥写入文章或源码。完成控制台配置后再验证地图初始化和检索回调。
在这里插入图片描述

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

Logo

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

更多推荐