在一块电子签名板上,用户画完第一笔,点了“撤销”,整块画布却仍留着上一笔的影子;再把手机横过来,笔迹跟着页面一起被拉宽。表面看只是一个绘图控件的小毛病,实际上暴露了两种常见的状态设计错误:把 Canvas 像素当作业务数据,以及把屏幕坐标当作签名的永久坐标。如果画布既保存结果,又承担历史记录,撤销、重做和尺寸适配必然互相牵制。

这里设计一个独立的 HarmonyOS InkBoard 练习应用:在中央签名区域自由绘制,底部有“撤销”“重做”“清空”三个明确动作,上方显示当前笔画数。目标不是生成具备法律效力的电子签章,更不涉及签名认证、身份校验或上传;我们只解决触摸采样、笔画记录、按需重绘和画布大小变化这四件前端工程问题。应用使用 ArkTS 声明式 UI、ArkUI Canvas 和 CanvasRenderingContext2D,以 API 10 及以上常见接口为基础,首次接触 Canvas 的开发者可以直接理解组件与数据的关系。

InkBoard 触摸轨迹与可重绘状态技术封面

一、先放弃“画完就存在画布里”的想法

Canvas 是一个绘制目标,不是可供业务查询的历史数据库。假如代码每次手指移动只执行 lineTo 和 stroke,用户最后确实看到了连续曲线,但当“撤销”需要去掉上一条完整笔画时,程序并不知道哪些像素属于哪次手指按下。最容易写错的方式是按时间把 clearRect 加在尾部:清除画布后既没有旧笔迹,也没有办法只恢复前面几笔。

本例把一次从按下到抬起的连续触摸称为一个 Stroke,每个笔画包含一组点。**所有已确认笔画组成一个数组 strokes**,撤销栈 redoStack 保存被撤销的完整笔画,当前正在画的临时点集为 current。每次重绘先清空画布,再按原始顺序把已确认笔画与当前临时笔画全部画回来。于是撤销和重做只移动数据,不需要保存一张又一张完整 Bitmap,也无需猜测 Canvas 曾经渲染了哪些像素。

对性能的理解要克制:完整重绘的成本随笔画点数增长,适用于短签名、简单草图等小型数据。若是长时间绘图、上万点路径或多图层编辑,应考虑点抽稀、离屏缓存、分块重绘等策略,不能照搬一个全量 clearRect 循环就声称已经优化。我们只在必要的动作和新触点出现时重绘,不用无条件定时器重复刷新。

二、保存逻辑坐标,画布缩放时使用统一比例

签名区在手机竖屏时可能只有 330vp 宽,横屏后可能变成 600vp。如果直接把事件坐标的 x、y 写成永久笔迹点,重建画布后就会发生裁切。另一种办法是把横纵坐标分别除以实际宽高,得到两个 0~1 的比例;这种办法在不同长宽比之间虽然能装下整幅图,却会把圆形画成椭圆、把字迹横向拉伸。

为保证笔画几何比例,本例引入逻辑画布 1000×600。实际 Canvas 宽高分别为 w/h,比例 scale = min(w/1000, h/600),再计算画布左右、上下的留白。触摸点通过逆变换记录在逻辑画布坐标中,重绘时应用相同的统一比例。这样方向变化后内容可能有合理留白,但不会被拉成另一种笔迹。这是一种简洁的 contain 布局方案,并没有解决滚动容器内的触点事件竞争;真正上线时还应禁用签名区域的外部手势抢占并进行真机测试。

// entry/src/main/ets/pages/InkBoardPage.ets
class InkPoint {
  x: number;
  y: number;
  constructor(x: number, y: number) { this.x = x; this.y = y; }
}

@Entry
@Component
struct InkBoardPage {
  private settings: RenderingContextSettings = new RenderingContextSettings(true);
  private context: CanvasRenderingContext2D =
    new CanvasRenderingContext2D(this.settings);
  @State private strokes: InkPoint[][] = [];
  @State private redoStack: InkPoint[][] = [];
  private current: InkPoint[] = [];
  private drawing: boolean = false;

  private transform(): number[] {
    const scale = Math.min(this.context.width / 1000,
      this.context.height / 600);
    return [scale, (this.context.width - 1000 * scale) / 2,
      (this.context.height - 600 * scale) / 2];
  }
  private toPoint(x: number, y: number): InkPoint {
    const t = this.transform();
    return new InkPoint((x - t[1]) / t[0], (y - t[2]) / t[0]);
  }
}

这里的 RenderingContextSettings(true) 开启抗锯齿以改善线条观感;CanvasRenderingContext2D 只能绑定到一个 Canvas 组件,不应在多个卡片中共用这一上下文。context.width/height 的数值在 Canvas 就绪后才有意义;因此绘制入口放在 onReady 和后续触摸回调中。生产代码还需在 transform() 前处理宽高为 0 的边界:画布尚未完成布局时跳过事件,不进行除零换算。

三、触摸事件不写 UI 历史,只负责生成笔画数据

ArkUI 的 onTouch 可以取得按下、移动、抬起和取消等事件。签名画布只采纳当前的单点输入;当出现多指接触时,取消正在收集的临时轨迹,避免两根手指的坐标突然连成一笔。抬起时将 current 作为一个完整的 Stroke 放进历史;若当前只有一个点,也可以将它绘制成一个小圆点,不能因 lineTo 不够两点就直接丢失一次点击。

private handleTouch(event: TouchEvent): void {
  if (this.context.width <= 0 || this.context.height <= 0) { return; }
  if (event.touches.length > 1) {
    this.current = []; this.drawing = false; this.paint(); return;
  }
  if (event.type === TouchType.Cancel) {
    this.current = []; this.drawing = false; this.paint(); return;
  }
  const touch = event.changedTouches[0];
  if (!touch) { return; }
  const point = this.toPoint(touch.x, touch.y);
  if (event.type === TouchType.Down) {
    this.current = [point]; this.drawing = true;
  } else if (event.type === TouchType.Move && this.drawing) {
    this.current = [...this.current, point];
  } else if (event.type === TouchType.Up && this.drawing) {
    this.current = [...this.current, point];
    this.strokes = [...this.strokes, this.current];
    this.redoStack = [];
    this.current = []; this.drawing = false;
  }
  this.paint();
}

注意 changedTouches 与 touches 的意义不同:前者与本次变化点有关,后者是当前仍参与触摸的点集,Up 之后 touches 可能为空,因此不能总从 touches[0] 读取抬起坐标。这个最小实现还没有做触点 ID 锁定;手写笔和手指同时进入、超出画布后回到画布等边界,宜基于 TouchObject 标识补充捕获策略。为了让初版代码可读,先约束为单指连续输入;不能把它当作完善的多点签名算法。

只有 Down 之后的一串 Move 与 Up 会形成一条历史记录。即使两条笔画最终相连,也不会被合并,因为撤销应按照用户的手势次数执行,而不是按视觉连续性猜测分段。这也解释了为什么临时 current 不应被放进 @State strokes 中每移动一次就提交:那会把一个连续笔画拆成许多撤销层级,点击撤销时只能擦掉末端极短的一截。

DevEco Studio 白色主题:InkBoardPage 的触摸处理与绘制区

四、重绘路径与撤销栈要使用同一份来源

核心渲染函数始终从 strokes 重建画面。Canvas 上可以通过 beginPath、moveTo、lineTo、stroke、clearRect 控制路径;本例默认画深蓝色圆端细线。路径是画布操作指令,真实可持久化数据仍是 InkPoint[][]。下面 renderStroke() 是最重要的绘制片段:

private renderStroke(points: InkPoint[], s: number, ox: number,
  oy: number): void {
  if (points.length === 0) { return; }
  if (points.length === 1) {
    this.context.fillStyle = '#1C3E75';
    this.context.fillRect(ox + points[0].x * s,
      oy + points[0].y * s, Math.max(2, 3 * s), Math.max(2, 3 * s));
    return;
  }
  this.context.beginPath();
  this.context.moveTo(ox + points[0].x * s, oy + points[0].y * s);
  for (let i = 1; i < points.length; i++) {
    this.context.lineTo(ox + points[i].x * s, oy + points[i].y * s);
  }
  this.context.stroke();
}

private paint(): void {
  const t = this.transform();
  if (t[0] <= 0) { return; }
  this.context.clearRect(0, 0, this.context.width, this.context.height);
  this.context.strokeStyle = '#1C3E75';
  this.context.lineWidth = Math.max(1.5, 3 * t[0]);
  this.context.lineCap = 'round';
  this.strokes.forEach((stroke: InkPoint[]) =>
    this.renderStroke(stroke, t[0], t[1], t[2]));
  this.renderStroke(this.current, t[0], t[1], t[2]);
}

撤销操作把 strokes 的最后一条完整笔画取出来,放入 redoStack;重做则反向移动。为了保证状态更新可以被 UI 明确观察,赋值使用新数组。用户在撤销之后又画新的一笔时必须清空 redoStack,否则“重做”会插回旧分支的历史,而这已经不再对应当前绘图过程。

private undo(): void {
  if (this.strokes.length === 0) { return; }
  const last = this.strokes[this.strokes.length - 1];
  this.strokes = this.strokes.slice(0, -1);
  this.redoStack = [...this.redoStack, last];
  this.paint();
}
private redo(): void {
  if (this.redoStack.length === 0) { return; }
  const last = this.redoStack[this.redoStack.length - 1];
  this.redoStack = this.redoStack.slice(0, -1);
  this.strokes = [...this.strokes, last];
  this.paint();
}
private clearAll(): void {
  this.strokes = []; this.redoStack = []; this.current = [];
  this.drawing = false; this.paint();
}

build() 只需要一个 Column:标题 InkBoard、说明“请在下方区域签名”、包含 Canvas(this.context) 的白色签名区域,下面是三个操作按钮与“已记录 N 笔”的状态。核心绑定为 Canvas(this.context).height(320).width('100%').onReady(() => this.paint()).onTouch((e: TouchEvent) => this.handleTouch(e))。如果实际布局把 Canvas 放在多层 padding 容器内,触摸坐标应使用相对于 Canvas 自身的局部坐标,不要直接混用整个窗口坐标。

InkBoard 9:16 完整竖屏签名与撤销重做界面设计

五、按状态机验收,而不是看一张漂亮签名图

最小检查可以按以下顺序进行。先画一笔,笔画数变为 1;再画第二笔,计数变为 2;按撤销只消失第二笔,按重做又恢复第二笔。再撤销一次,此时在画布上新画第三笔,重做按钮应不可用。执行清空后所有历史、重做队列与临时笔画都为空,不能留下可恢复的旧轨迹。最后在签名尚未提交时改变窗口尺寸,onReady 重新计算统一比例,已有笔画应该保持相对形状;这一步必须用宽窄不同的实际预览尺寸来检查。

笔画数组与撤销/重做两个栈的状态流转

还要刻意制造异常输入:在空画布上点“撤销”,不会抛错;在笔画绘制一半时触发取消,不应产生半条已提交笔画;连续点击清空不会恢复已删记录;单击画布要能留下小点,而不是造成不可见的“已记录 1 笔”。绘图过程中触点过密可能导致数组增长,工程版应考虑基于距离阈值的采样合并,但阈值过大又会丢失笔锋拐点,应在不同触控设备上权衡。

本例存储的是可重绘的路径数据,不是可靠的签章证据:没有签名者认证、时间戳证明、内容防篡改、服务端审计或电子合同流程。若将来需要保存图片,可在明确的用户操作下调用相应的 Canvas 导出能力,并在权限、文件格式和内存限制方面单独设计;不要把本篇只展示画面和撤销功能的示例说成已经完成安全签署。对于真正需要法律效力的电子签署,还需要与合规服务和签约协议体系对接。

最后,画布还要考虑触摸与滚动之间的竞争。签名区域的垂直滑动可能被外围 Scroll 当成滚动手势,导致一次签名断成两段;如果页面必须允许滚动,应将签名区域放在视觉上独立的固定容器内,并根据组件事件分发规则处理手势优先级。为避免用户误以为操作已经“提交到服务器”,本例不放“保存成功”之类的虚假状态,而是在计数区域直接展示“已记录 2 笔”等局部事实。对于高对比度或大字体模式,按钮的文字和点击区域需要独立于 Canvas 的颜色配置调整,保证签名操作不是只有视觉正常的人才能完成。

这样的设计取舍很明确:Canvas 负责把点画出来,笔画数组负责记录用户做了什么,历史栈负责定义“撤销了哪一笔”,窗口布局只决定映射比例。把这四层拆开以后,哪怕下一步要增加笔刷颜色、线宽、橡皮擦和保存草稿,也不必再从一堆像素里反推用户动作。对于一个能反复修改的触控组件而言,可重建的数据比已经显示的结果更值得维护。

参考文档:华为 Canvas 组件、CanvasRenderingContext2D 接口、触摸事件 onTouch / TouchEvent。

Logo

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

更多推荐