Canvas + HarmonyOS 手写签名:笔迹重绘与撤销重做【共创稿事节】
在一块电子签名板上,用户画完第一笔,点了“撤销”,整块画布却仍留着上一笔的影子;再把手机横过来,笔迹跟着页面一起被拉宽。表面看只是一个绘图控件的小毛病,实际上暴露了两种常见的状态设计错误:把 Canvas 像素当作业务数据,以及把屏幕坐标当作签名的永久坐标。如果画布既保存结果,又承担历史记录,撤销、重做和尺寸适配必然互相牵制。
这里设计一个独立的 HarmonyOS InkBoard 练习应用:在中央签名区域自由绘制,底部有“撤销”“重做”“清空”三个明确动作,上方显示当前笔画数。目标不是生成具备法律效力的电子签章,更不涉及签名认证、身份校验或上传;我们只解决触摸采样、笔画记录、按需重绘和画布大小变化这四件前端工程问题。应用使用 ArkTS 声明式 UI、ArkUI Canvas 和 CanvasRenderingContext2D,以 API 10 及以上常见接口为基础,首次接触 Canvas 的开发者可以直接理解组件与数据的关系。

一、先放弃“画完就存在画布里”的想法
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 中每移动一次就提交:那会把一个连续笔画拆成许多撤销层级,点击撤销时只能擦掉末端极短的一截。

四、重绘路径与撤销栈要使用同一份来源
核心渲染函数始终从 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 自身的局部坐标,不要直接混用整个窗口坐标。

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

还要刻意制造异常输入:在空画布上点“撤销”,不会抛错;在笔画绘制一半时触发取消,不应产生半条已提交笔画;连续点击清空不会恢复已删记录;单击画布要能留下小点,而不是造成不可见的“已记录 1 笔”。绘图过程中触点过密可能导致数组增长,工程版应考虑基于距离阈值的采样合并,但阈值过大又会丢失笔锋拐点,应在不同触控设备上权衡。
本例存储的是可重绘的路径数据,不是可靠的签章证据:没有签名者认证、时间戳证明、内容防篡改、服务端审计或电子合同流程。若将来需要保存图片,可在明确的用户操作下调用相应的 Canvas 导出能力,并在权限、文件格式和内存限制方面单独设计;不要把本篇只展示画面和撤销功能的示例说成已经完成安全签署。对于真正需要法律效力的电子签署,还需要与合规服务和签约协议体系对接。
最后,画布还要考虑触摸与滚动之间的竞争。签名区域的垂直滑动可能被外围 Scroll 当成滚动手势,导致一次签名断成两段;如果页面必须允许滚动,应将签名区域放在视觉上独立的固定容器内,并根据组件事件分发规则处理手势优先级。为避免用户误以为操作已经“提交到服务器”,本例不放“保存成功”之类的虚假状态,而是在计数区域直接展示“已记录 2 笔”等局部事实。对于高对比度或大字体模式,按钮的文字和点击区域需要独立于 Canvas 的颜色配置调整,保证签名操作不是只有视觉正常的人才能完成。
这样的设计取舍很明确:Canvas 负责把点画出来,笔画数组负责记录用户做了什么,历史栈负责定义“撤销了哪一笔”,窗口布局只决定映射比例。把这四层拆开以后,哪怕下一步要增加笔刷颜色、线宽、橡皮擦和保存草稿,也不必再从一堆像素里反推用户动作。对于一个能反复修改的触控组件而言,可重建的数据比已经显示的结果更值得维护。
参考文档:华为 Canvas 组件、CanvasRenderingContext2D 接口、触摸事件 onTouch / TouchEvent。
更多推荐



所有评论(0)