巡检单上保留手写确认,看起来只是给页面放一块画布。真正需要提前决定的是:设备合起后,原来记录在大画布上的笔迹还算不算同一条线;误触了一笔,撤销的是最后一条笔画,还是把之前确认的内容也清掉?如果绘图逻辑以屏幕像素为唯一依据,这两个问题会在折叠态切换时同时出现。这里设计一个可独立复现的页面级演示:InkFold,页面文件为 SignPage.ets,会话编号 IF-1816。构造的验收状态是窄态 NARROW、画布宽度 360vp、撤销后保留 2 笔和 18 个采样点,状态显示 UNDO_APPLIED。这些数字用于说明数据契约,不是真机采集结果,也不是手写签名有效性的证据。## 一、先把“画得出来”与“留得住”分开在宽屏上绘制时,直接把 localX、localY 写进数组也能得到一条线。不过这种数组偷偷绑定了第一次绘制时的画布宽高;同一条笔迹从 720 宽的区域换到 360 宽的区域,如果不重映射,右半段会被裁掉。如果粗暴地把所有旧坐标乘 0.5,又要考虑高度有没有同时改变、触点边界有没有越界、采样间距是否也该缩放。我的取舍是保存归一化点,而不是保存最终要画到屏幕上的点。每个点存 u 与 v,落在 0~1,显示时才乘当前画布宽高。笔画是一组有顺序的点,整个签名是笔画数组。这是应用自己定义的数据格式,并非 PanGesture 提供了“归一化签名”这一系统能力。笔画边界必须保留。若只存一串点,撤销时很难区分“刚刚补的一笔”与“前一笔的尾巴”。更严重的是,取消事件可能发生在系统手势抢占、页面离开或触摸链路中断时;这时应该丢弃尚未提交的草稿,而不是把半截线写进正式记录。数据模型因此拆为 strokes(已提交)和 draft(当前进行中)。## 二、归一化写入要严守坐标单位下面的代码解决越界点、零尺寸画布和折叠切换的问题。输入 width、height 必须与触点坐标采用相同单位,不能一边传组件的 vp 宽度,另一边直接传未转换的物理像素。示例使用普通 ArkTS 类型,不涉及未验证的图像处理 Kit 接口。tsinterface InkPoint { u: number; v: number }interface InkStroke { points: InkPoint[] }function toUnit(x: number, y: number, width: number, height: number): InkPoint | null { if (width <= 0 || height <= 0 || !Number.isFinite(x) || !Number.isFinite(y)) { return null; } const u = Math.max(0, Math.min(1, x / width)); const v = Math.max(0, Math.min(1, y / height)); return { u, v };}对 0 宽度直接拒绝,比事后清理 Infinity 更可靠。Math.min 和 Math.max 只是把轻微越界的触点夹回画布,不等于允许屏幕外的拖动继续绘制;真正的异常手势还要在触摸入口阻断。这个函数也没有解决笔压、采样平滑和多指干扰,它做的是最小坐标契约。测试样例可以写成单独的纯函数断言:画布 720×360 时点 (360,180) 归一化为 (0.5,0.5);画布收窄为 360×180 后重绘,仍位于新画布的中心 (180,90)。这里是数学推导,不是界面运行测量。用这一对数字比讨论“有没有看起来偏一点”更容易定位问题。在真实项目里还要确定 Canvas 的绘制分辨率与布局宽度之间的换算;如果接入高密度导出图像,应另建物理像素与逻辑单位的映射,不能直接复用本文 width 值。InkStroke 适合小型本地签收草稿,正式电子签名业务还需要身份确认、证据链和数据保护,不能靠笔迹点阵独自承担。## 三、手势更新不等于一笔已经提交官方 PanGesture 文档提供 onActionStart、onActionUpdate、onActionEnd 与 onActionCancel。示例明确展示了 GestureEvent 回调和方向、触发距离配置。这里故意把绘图与业务提交放在不同函数里:移动中只更新 draft,抬手才合并进 strokes,取消则丢掉草稿。这样折叠时即使产生取消,也不会凭空多出完整的一笔。下面的组件片段用于把触摸坐标送入归一化模型。recordPoint 内部调用上一段 toUnit,它不会操作外部权限或网络。其余布局、状态字段按工程拆分到同一 SignPage.ets,这里展示影响状态边界的部分。tsCanvas(this.context) .width('100%') .height(180) .onReady(() => { this.repaint(); }) .onAreaChange((_old: Area, next: Area) => { this.canvasWidth = Number(next.width); this.canvasHeight = Number(next.height); this.repaint(); }) .gesture(PanGesture({ fingers: 1, direction: PanDirection.All, distance: 1 }) .onActionStart((e: GestureEvent) => { this.draft = []; if (e.fingerList.length > 0) { this.recordPoint(e.fingerList[0].localX, e.fingerList[0].localY); } }) .onActionUpdate((e: GestureEvent) => { if (e.fingerList.length > 0) { this.recordPoint(e.fingerList[0].localX, e.fingerList[0].localY); } }) .onActionEnd(() => { this.finishStroke(); }) .onActionCancel(() => { this.draft = []; this.repaint(); }))````distance: 1` 是为了降低起笔阈值,并不意味着系统可以提供每个触摸采样点。手势识别前的小位移可能丢失,单点按下也未必形成笔画;如果场景对字迹完整度要求很高,应评估底层触摸事件与手写笔能力,而不是把 PanGesture 当成无损笔迹采集器。`fingerList` 必须判空,局部坐标也不能在不同组件区域间直接相加。布局切换时,`onAreaChange` 发生的先后不一定与动画结束同步。只要重绘从标准化数据恢复,它就不需要等待某个固定延时;但正在进行中的 `draft` 必须在尺寸变化时终止或重建,否则同一笔前后段可能各用一套宽度。演示方案选择尺寸切换时废弃当前草稿,提示用户重新落笔。对于平板侧栏拖动这种连续变化,实际还需要节流重绘和追踪绘制耗时。![](https://i-blog.csdnimg.cn/direct/66f180afb0e749a6b30897623418eb95.png)图中的 DevEco Studio 为**生成的演示界面**:左侧是 `InkFold` 目录,中间强调 `toUnit` 与 `PanGesture`,右侧模拟器显示撤销后的签名草稿,底部 HiLog 用构造日志解释状态推进。图片不代表在某个 SDK 版本编译成功或设备手势性能合格。## 四、撤销只作用于已确认的最后一笔撤销按钮的职责应当单一:先结束当前草稿,再从已提交数组中移除最后一条笔画。如果页面把当前笔画与历史笔画混在同一个数组,手指尚未抬起时点击按钮就容易删到上一笔。下面这段 `undoLast` 和 `repaint` 解决的是可逆性与屏幕重建,它们是应用层方法,不是系统的撤销 API。tsprivate undoLast(): void { this.draft = []; if (this.strokes.length === 0) { return; } this.strokes = this.strokes.slice(0, this.strokes.length - 1); this.status = ‘UNDO_APPLIED’; this.repaint();}private repaint(): void { this.context.clearRect(0, 0, this.canvasWidth, this.canvasHeight); this.context.strokeStyle = ‘#176CBA’; this.context.lineWidth = 2; for (const stroke of this.strokes) { if (stroke.points.length === 0) { continue; } this.context.beginPath(); this.context.moveTo(stroke.points[0].u * this.canvasWidth, stroke.points[0].v * this.canvasHeight); for (const point of stroke.points.slice(1)) { this.context.lineTo(point.u * this.canvasWidth, point.v * this.canvasHeight); } this.context.stroke(); }}````slice返回新数组,便于观察状态替换;若后续增加重做,应把被移除的InkStroke压入独立的 redo 栈,任何新笔画提交后清空 redo,避免重做重放到另一份签名。这里只处理“撤销上一笔”,不做无限历史,也不声称支持可验证的审计链。构造验收过程是:会话IF-1816先模拟三笔、共24点;操作撤销上一笔后,页面变成2 笔、18点,状态UNDO_APPLIED。示意日志固定为 [InkFold] IF-1816 layout=NARROW width=360vp、[InkFold] IF-1816 stroke=3 points=24、[InkFold] IF-1816 undo strokes=2 points=18。日志属于为了复核规则设计的样例文本,不能用作真实 HiLog 采集记录。![](https://i-blog.csdnimg.cn/direct/8a20f97f5aba4820be0d2d5161fa4cdb.png)另外,一个容易被忽略的测试是“撤销之后旋转设备,再次撤销”。如果撤销记录保存的是整笔的像素外接矩形,就必须再次计算缩放;而本文只在数组层删除最后一笔,旋转前后得到的剩余点集应相同。为了让复核更透明,可以在开发日志中记录撤销前后的笔画数、点数和会话编号,却不必打印每一个签名字迹坐标。对个人签名而言,原始轨迹也属于应谨慎处理的数据:应限制本地保留时间、避免默认上传,并在用户明确放弃签收时清理草稿。对于历史版本已经存储像素点的应用,迁移时必须附带原始画布宽高,否则既不能安全归一化,也不能保证还原位置。这一层兼容处理应当独立于UI重绘,在读入旧数据时完成。## 五、准备交付前还应主动验证的边界只看一个宽屏转窄屏的截图,很容易误判方案稳妥。建议把检查矩阵扩到:零尺寸、快速连续折叠、两指干扰、页面进入后台后重新进入、撤销空数组、撤销后再次书写、画布高度变化、不同屏幕密度。每一项要独立观察输入与结果,而不是靠“状态是绿色”就认定没有数据丢失。尤其是系统抢占手势时,onActionCancel是否按预期触发,应当在目标真机上验证。对于性能,最容易浪费资源的是每个移动事件都清空整块 Canvas 并重画全部笔迹。少量签收线条通常可接受;长时间手写板应考虑增量绘制、采样节流、分层缓存。这里不填写毫秒耗时,因为没有进行设备侧测试,也不把构造的18` 个点当作性能指标。结论并不复杂:画布只负责显示,归一化笔画才是数据,提交与取消要分界,撤销以整笔为单位。 折叠屏只是放大了旧设计中的坐标耦合问题。保住这个契约,再讨论更多笔迹特效才有意义。官方参考:- PanGesture 与事件回调- 手势触点 localX/localY 的语义- ArkUI Canvas 组件与 onReady- CanvasRenderingContext2D 路径处理 FAQ

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐