维修签字页最容易被低估的地方,是把它理解成“在画布上画几条线”。实际接入时,画面上同时存在几类不同性质的数据:手指或触控笔刚落下时的当前笔画、已经完成的笔画、历史记录中的只读笔迹,以及页面提示的本地确认状态。它们看起来都会落在同一块 Canvas 上,却不能用同一条写入路径处理。
我在这个场景里遇到的表象很简单:点了撤销、清空、历史预览、退出预览或抗锯齿开关后,页面文字已经变了,签名板却可能仍然保留旧笔迹。继续书写时,用户会不确定自己是在补写当前签名,还是意外改动一条历史记录;点“确认并归档”后,又容易把本地页面记录误认为真实工单已经回写。
所以我没有把“重新绘制”当成一个补丁,而是把它放到每次状态变更之后。这里的重绘只做一件事:用当前允许展示的笔迹重新生成 Canvas 像素。它能证明页面已按本地状态更新,不能证明签名已提交到外部系统、真实工单已经归档,或任意设备上的笔迹效果完全一致。
先把问题拆成四层
在维修签名场景里,先分清数据所在的层,比直接追某个按钮更重要。
| 层次 | 页面中的数据 | 可以确认 | 不能据此确认 |
|---|---|---|---|
| 触摸输入 | activeStroke、触点坐标 | 当前触摸正在被页面采集 | 签名已有效、已确认或已保存 |
| 新建签名 | strokes | 已采集的本地笔画可参与绘制 | 已回写真实工单或已持久化 |
| 历史预览 | signatureHistory、selectedHistoryId | 指定历史记录可被选中并只读展示 | 历史记录来自真实服务端 |
| 页面状态 | signatureStatus、viewMode、antialiasEnabled | 当前页面给出的本地提示和展示模式 | 后端、设备或业务流程已经完成 |
这四层都可能触发视觉变化,但不能互相替代。例如,signatureStatus写着“本地确认已记录”,只能说明当前组件更新了状态文案;signatureHistory多了一条记录,才能说明页面内存中新增了一条本地历史项;两者都不能推出真实工单已收到签名。把证据层拆开,后面的每一次重绘才不会被写成虚假的业务成功。
场景解说:此图应记录新建签名模式、签名板和历史列表的页面基线;只能证明页面已展示,不能证明已有有效签名或工单回写。
先定重绘的唯一输入:当前应当可见的笔迹
这个页面有“新建”和“预览”两种模式。新建模式下,Canvas 应显示当前用户写入的strokes;预览模式下,Canvas 必须改为显示被选中历史记录的strokes。如果绘制函数直接引用this.strokes,那么进入历史预览后仍会画出新建签名,页面的“历史预览”标签就成了一个只有文字变化的假状态。
源码把这个选择收敛到visibleStrokes():
private visibleStrokes(): Stroke[] { const record = this.selectedHistory(); return this.viewMode === 'preview' && record ? record.strokes : this.strokes; }这段代码能够证明:渲染前会根据viewMode和已选历史记录,在历史笔迹与当前笔迹之间作出本地选择。它不能证明record.strokes一定来自服务端,也不能说明这些坐标已经被持久化;页面里预置的历史项和本地确认新增的历史项,都只是当前组件可展示的数据。
我把它看作绘制层的“唯一入口”。触摸、撤销、清空和预览切换可以分别修改不同状态,但redraw()不再判断业务动作本身,只取visibleStrokes()。这样做的好处是,排查时可以把问题分开:若模式标签已变而画面没变,先检查是否触发redraw();若已触发重绘却仍是错误笔迹,再检查visibleStrokes()的选择条件,而不是把所有怀疑堆到触摸事件上。
一笔签名不是一次赋值,而是一段触摸生命周期
签名采集从handleSignatureTouch()进入。它先拒绝预览状态下的写入,再按Down、Move、Up与Cancel处理同一条笔画:
private handleSignatureTouch(event: TouchEvent): void { if (this.viewMode === 'preview') { this.signatureStatus = '历史签名只读,请先退出预览后新建签名'; return; } const point = this.pointFromTouch(event); if (event.type === TouchType.Down && point) { const stroke: Stroke = { points: [point] }; this.activeStroke = stroke; this.strokes = [...this.strokes, stroke]; this.signatureStatus = '正在采集现场签名…'; this.redraw(); return; } if (event.type === TouchType.Move && point && this.activeStroke) { this.activeStroke.points.push(point); this.strokes = [...this.strokes]; this.redraw(); return; } if (event.type === TouchType.Up || event.type === TouchType.Cancel) { if (point && this.activeStroke) { this.activeStroke.points.push(point); } this.activeStroke = undefined; this.signatureStatus = this.validStrokeCount() > 0 ? '笔迹已采集,可确认本地归档' : '笔迹过短,请重新签字'; this.redraw(); } }这段代码能确认三件事。第一,按下时会创建一条包含起点的Stroke,并将它同时设置为活动笔画和当前笔画列表的一员。第二,移动事件把后续坐标追加到这条活动笔画中,再通过this.strokes = [...this.strokes]让页面状态重新参与渲染。第三,抬起或取消时都会结束活动笔画,并根据当前有效笔画数更新本地提示。
它同样划出了两个边界。触摸事件被收到,不等于笔迹已经有效;Cancel发生时,页面只是在本地结束本次采集,不能把它解释为用户完成签字。另一个边界是坐标来源:pointFromTouch()只取当前触点的x、y,代码没有做压感、笔锋或身份认证,因此不能把这块画布描述为具有电子签名法律效力的签署链路。
为什么移动一次就要重绘一次
Canvas 不是声明式文本组件。strokes数组更新后,之前执行过的moveTo、lineTo和stroke不会自动根据新数组改写已经存在的像素。因此,移动事件只追加坐标而没有redraw()时,数据层的笔画会越来越长,画布上却仍停在旧轮廓;等到抬笔时才一次性绘制,用户看到的是断续或滞后的反馈。
页面的redraw()处理顺序是固定的:先判断画布是否已经准备好,再应用当前抗锯齿设置,清除旧像素、铺设白底与签名基线,最后按模式绘制visibleStrokes()。
private redraw(): void { if (!this.canvasReady || this.canvasWidth <= 0 || this.canvasHeight <= 0) { return; } this.applyAntialias(); this.context.clearRect(0, 0, this.canvasWidth, this.canvasHeight); this.context.fillStyle = '#FFFFFF'; this.context.fillRect(0, 0, this.canvasWidth, this.canvasHeight); this.context.strokeStyle = '#D5E0EA'; this.context.lineWidth = 1; this.context.beginPath(); this.context.moveTo(34, this.canvasHeight - 64); this.context.lineTo(this.canvasWidth - 34, this.canvasHeight - 64); this.context.stroke(); if (this.viewMode === 'preview') { this.drawStrokes(this.context, this.visibleStrokes(), this.canvasWidth / 240, this.canvasHeight / 130, '#172D40', 5); } else { this.drawStrokes(this.context, this.visibleStrokes(), 1, 1, '#172D40', 5); } }这里“清除后完整重画”不是多余操作。撤销、清空和模式切换都可能使旧笔画不再属于当前可见集合。若只在新增笔画时继续lineTo,就没有可靠方式擦掉最后一笔、整页笔迹或新建模式下残留的历史预览;先清空再从visibleStrokes()重建,才能让画布与当前状态一致。
这段代码能够确认当前组件使用“状态是来源、Canvas 是投影”的方式工作。它不能确认渲染帧率、触控延迟或不同屏幕密度下的肉眼效果,因为源码没有提供性能采样、真机记录或设备对照数据。页面运行后出现笔迹,也只能证明本地 Canvas 已绘制这些点。
笔迹如何真正落到 Canvas 上
drawStrokes()并不保存业务数据,它只把已经存在的坐标按给定比例转为线段。每一条少于两个点的笔画会被跳过,以免“刚按下一个点”被错误画成一条线:
private drawStrokes(context: CanvasRenderingContext2D, strokes: Stroke[], scaleX: number, scaleY: number, color: string, lineWidth: number): void { context.strokeStyle = color; context.lineWidth = lineWidth; context.lineCap = 'round'; context.lineJoin = 'round'; strokes.forEach((stroke: Stroke) => { if (stroke.points.length < 2) { return; } context.beginPath(); context.moveTo(stroke.points[0].x * scaleX, stroke.points[0].y * scaleY); for (let index = 1; index < stroke.points.length; index += 1) { context.lineTo(stroke.points[index].x * scaleX, stroke.points[index].y * scaleY); } context.stroke(); }); }lineCap = 'round'与lineJoin = 'round'说明页面选择圆形端点和圆形连接来呈现笔迹;它们描述的是当前本地绘制样式,不是对签名真实性或图像质量的认证。scaleX、scaleY则解释了预览与新建模式为何不能完全沿用同一坐标比例:历史记录按 240×130 的基准坐标保存,在主签名板预览时需要放大到当前画布尺寸;新建笔迹还处于当前画布坐标中,可以按 1:1 绘制。
若忽略这一点,历史预览通常会出现两种问题:要么笔迹被挤在左上角,要么通过错误比例拉伸到超出边界。这里的比例换算能证明坐标映射存在,不能证明它已适配所有窗口尺寸;需要在具体运行环境中观察onAreaChange后的实际宽高,才能评价视觉效果。
场景解说:此图应记录选中历史记录后的只读预览、签名板笔迹及“历史预览”状态;只能证明本地选中记录被重绘,不能证明历史记录已从外部系统查询成功。
历史预览必须禁止写入,而不只是把按钮藏起来
签名历史面板通过selectHistoryRecord()切换到预览模式:
private selectHistoryRecord(id: string): void { this.selectedHistoryId = id; this.viewMode = 'preview'; this.activeStroke = undefined; this.signatureStatus = '正在预览历史签名,笔迹已锁定'; this.redraw(); }表面上它只做了四次赋值,实际上缺一项都会留下歧义。selectedHistoryId指定预览对象;viewMode = 'preview'让visibleStrokes()改取历史笔迹;清空activeStroke避免上一次正在写的笔画继续保留为活动状态;最后强制redraw(),把旧画面替换为所选记录。仅改变列表高亮或标题文字,不足以完成“切换预览”。
真正的保护并不在按钮样式,而在写入路径的第一行。触摸、撤销、清空和确认都先判断viewMode:
if (this.viewMode === 'preview') { this.signatureStatus = '历史签名只读,请先退出预览后新建签名'; return; }这段判断能够证明当前页面不会在预览模式继续修改strokes或新增本地历史记录。它不能证明用户在其他页面、其他进程或真实工单系统中也没有修改历史数据;这个组件没有跨页面锁定和服务端权限校验的证据。把边界写清楚,比把“笔迹锁定”夸大成全面的业务锁定更可靠。
退出预览和重新开始的语义也不相同。exitPreview()只清除选中历史项、回到新建模式,让用户继续处理已有的strokes;startNewSignature()在此基础上还清空strokes,创建一张新的本地签名画布。两者都要重绘,否则状态文案虽然已变,Canvas 仍可能保留历史笔迹,形成“可写模式却看着像历史记录”的误导。
撤销与清空:先改变来源数据,再让画布跟随
维修签名常见的两个操作是撤销最后一笔和清空整张签名板。它们不是对 Canvas 像素做局部覆盖,而是先更新strokes,再调用redraw():
private undoStroke(): void { if (this.strokes.length === 0) { this.signatureStatus = '没有可撤销的笔画'; return; } this.strokes = this.strokes.slice(0, this.strokes.length - 1); this.activeStroke = undefined; this.signatureStatus = this.strokes.length === 0 ? '已撤销全部笔画' : '已撤销最后一笔'; this.redraw(); } private clearSignature(): void { this.strokes = []; this.activeStroke = undefined; this.signatureStatus = '签名画布已清空'; this.redraw(); }这条顺序有明确的因果关系。slice()返回去掉最后一项后的新数组,下一次重绘就不会再把最后一笔画出来;清空数组后,重绘仍会保留背景和基线,但不会有任何drawStrokes()的笔迹输入。若反过来先擦画布、后更新数组,下一次触摸、尺寸变化或抗锯齿切换触发重绘时,被“擦掉”的旧笔迹还会从数组中重新出现。
同样需要避免把状态文案当结果证据。“已撤销最后一笔”只能说明当前组件完成了本地数组更新;“签名画布已清空”不等于本地历史记录被删除,更不等于真实工单中的任何签名被撤回。页面没有实现远程删除或历史项删除逻辑,所以正文不能把这两个按钮说成工单操作。
有笔迹不一定能确认:页面如何识别过短输入
仅凭strokes.length > 0判定签名完成,会把一次误触也计为签字。当前页面用isValidStroke()过滤过短笔画:少于三个点直接无效;满足点数后,再累加相邻点之间的二维距离,只有总长度不少于 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。它不能验证签字人的身份、签名字形是否符合公司制度、笔迹是否可作为法律凭据,也没有给出任何防伪、时间戳签名或服务端验签逻辑。它的作用是让“请先完成有效签字”不只是空提示,而是有一个能从当前源代码核对的本地阈值。
对读者而言,最值得复核的是操作路径,而不是猜一个签名看起来是否像真的:先只短按或短划一次,确认状态仍提示“笔迹过短”;再完成一条多点且足够长的笔画,确认状态变为“笔迹已采集,可确认本地归档”。这能验证当前页面的本地阈值分支;没有业务系统回执时,不能把它称为验收通过或工单完结。
点击确认后,为什么我仍只写“本地确认”
confirmSignature()先禁止预览态确认,再检查有效笔画数。满足条件时,它将当前笔迹坐标归一化,然后追加一条历史记录:
private confirmSignature(): void { if (this.viewMode === 'preview') { this.signatureStatus = '历史签名只读,请先退出预览后新建签名'; return; } const count = this.validStrokeCount(); if (count === 0) { this.signatureStatus = '请先完成有效签字'; return; } const record: SignatureHistoryRecord = { id: `local-${this.signatureHistory.length + 1}`, action: '完工确认', signer: '维修人员', signedAt: '刚刚', strokes: this.normalizedStrokes() }; this.signatureHistory = [record, ...this.signatureHistory]; this.signatureStatus = `本地确认已记录,共 ${count} 笔有效笔画,未回写真实工单`; this.redraw(); }关键事实就在状态文案最后七个字:未回写真实工单。这里的确认结果是页面内存中的signatureHistory新增一项,方便在左侧历史列表中预览;源代码没有网络请求、数据库写入、工单接口回执或失败重试逻辑。因此,无论按钮写着“确认并归档”,还是历史列表数量增加,都只能描述为本地演示记录。
normalizedStrokes()的存在也有实际原因。新建签名使用当前 Canvas 的像素坐标;存入历史项前,页面按当前宽高换算为 240×130 基准坐标。后续历史预览再按照当前画布尺寸放大,能够减少“在一个尺寸写入、换一个尺寸预览时位置完全失真”的问题。它能证明页面进行了本地坐标归一化,不能证明记录离开当前页面后仍然完整可用,因为没有持久化和跨设备同步的实现。
场景解说:此图应显示有效笔迹后的确认操作、历史列表新增记录和“未回写真实工单”提示;只能证明本地历史状态变化,不能代替外部工单系统的回执证据。
抗锯齿是重绘条件,不是另一篇文章的主线
签名页提供antialiasEnabled和toggleAntialias(),但这里它服务于手写笔迹的显示状态,而不是把整篇写成文字边缘对比。切换时先尝试写入当前绘制上下文,再无论成功或回退都调用redraw():
private toggleAntialias(): void { const previous = this.antialiasEnabled; this.antialiasEnabled = !previous; if (this.applyAntialias()) { this.antialiasStatus = this.antialiasEnabled ? '抗锯齿已开启' : '抗锯齿已关闭'; } else { this.antialiasEnabled = previous; this.applyAntialias(); } this.redraw(); }这条链路能确认:页面将本地开关尝试应用到CanvasRenderingContext2D,若当前运行环境抛出异常就恢复之前的开关值,随后再重画签名板。它解决的是“按钮文本变了但画布仍保留旧像素”的一致性问题。
我不会把它描述成“关闭后签名一定更锯齿”或“开启后每台设备都更平滑”。源码只处理设置与重绘,没有提供真机截图、像素测量或设备对照。正确的观察顺序是:记录切换前状态,点击开关,确认状态文案与按钮一致,再观察签名字迹是否随完整重绘更新;若需要评价视觉差异,必须补充相同画布尺寸、相同笔迹和目标设备上的实际取证。
我按这条操作链做人工复核
为了把“页面显示了签名”与“业务完成”分开,我会按以下顺序核对当前工程页面:
- 进入
FullScreenSignaturePage,确认新建模式提示为“请在签名板内书写”,签名板为空且带有基线。 - 在签名板内完成一条连续笔迹,观察状态从“正在采集现场签名…”变为有效笔迹提示;短划或短按时应保留“笔迹过短”分支。
- 点击“撤销”,确认最后一笔从 Canvas 消失;点击“清空画布”,确认当前新建笔迹清除,但页面基线仍保留。
- 选中一条历史记录,确认页面进入历史预览并拒绝继续书写、撤销、清空和确认;退出预览后,确认可重新进入新建签名流程。
- 写入有效笔迹后点击“确认并归档”,确认本地历史列表增加记录,并且状态明确显示“未回写真实工单”。
- 切换抗锯齿,确认按钮和状态文案同步;仅在补充同尺寸目标设备取证后,才对边缘差异做视觉结论。
这六步覆盖当前页面可观察的本地状态变化。它们不包含账号、服务端工单、数据库或真实签字人验证,因为工程文件里没有对应调用链。人工审核时若需要发布“工单已更新”“记录已归档到后台”之类的结论,应先补充外部系统回执、接口路径和失败处理证据,不能从这张 Canvas 的页面状态推导。
结论:重绘负责对齐本地状态,不负责替业务背书
维修签名页的难点不在于画出一条线,而在于让每一次本地状态变化都有唯一、可追溯的画面结果。触摸采集更新的是当前笔画;预览切换更换的是可见笔迹来源;撤销和清空先改变数组再重画;确认新增的是本地历史记录;抗锯齿切换改变的是绘制配置。它们都需要通过redraw()回到同一条绘制路径,才能避免旧像素留在画布上误导操作人。
与此同时,重绘的责任到 Canvas 为止。它能让读者观察到本地状态与签名板一致,不能让页面凭空拥有真实工单回写、服务端持久化、身份认证或跨设备同步能力。把这一层边界说清楚,才是我在维修签名页中坚持每次状态变化后重新绘制的原因。
必要条件|运行条件矩阵
| 必要条件 | 工程位置 | 运行前动作 | 未满足时现象 |
|---|---|---|---|
| SDK/API与构建工具 | build-profile.json5、Hvigor | 确认 compile/compatible/target 均为 API 24 | 类型检查或构建失败 |
| Kit引入 | 当前文章对应页面 import | 检查 Kit 名称和 API 是否与 API 24 匹配 | 编译期找不到类型或方法 |
| 模块与页面配置 | module.json5、main_pages.json | 确认页面已注册、模块为 Stage | 页面无法启动或路由失败 |
| 设备权限与动态授权 | requestPermissions、abilityAccessCtrl | 先查询并申请权限 | 能力初始化被拒绝或跳过 |
| 系统能力与硬件 | 设备能力、Camera、MapKit、VisionKit等 | 在目标设备检查能力 | 进入不支持、等待或人工降级状态 |
矩阵中的 SDK/API 行核对完成后插入构建证据(需同时看到 API 24 和构建工具信息):
设备权限行核对完成后插入授权证据:
系统能力与硬件行核对完成后插入版本/能力证据:
MapKit文章在系统能力行后增加:AppGallery Connect 项目已创建或选定,应用包名和签名证书与工程一致,MapKit 服务已开通并按控制台要求完成应用服务凭据/授权配置。截图不得带出密钥或证书私钥。