news 2026/8/20 23:53:03

在 HarmonyOS 6.1.1 的维修签名页,我为何让画布重新绘制

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
在 HarmonyOS 6.1.1 的维修签名页,我为何让画布重新绘制

维修签字页最容易被低估的地方,是把它理解成“在画布上画几条线”。实际接入时,画面上同时存在几类不同性质的数据:手指或触控笔刚落下时的当前笔画、已经完成的笔画、历史记录中的只读笔迹,以及页面提示的本地确认状态。它们看起来都会落在同一块 Canvas 上,却不能用同一条写入路径处理。

我在这个场景里遇到的表象很简单:点了撤销、清空、历史预览、退出预览或抗锯齿开关后,页面文字已经变了,签名板却可能仍然保留旧笔迹。继续书写时,用户会不确定自己是在补写当前签名,还是意外改动一条历史记录;点“确认并归档”后,又容易把本地页面记录误认为真实工单已经回写。

所以我没有把“重新绘制”当成一个补丁,而是把它放到每次状态变更之后。这里的重绘只做一件事:用当前允许展示的笔迹重新生成 Canvas 像素。它能证明页面已按本地状态更新,不能证明签名已提交到外部系统、真实工单已经归档,或任意设备上的笔迹效果完全一致。

先把问题拆成四层

在维修签名场景里,先分清数据所在的层,比直接追某个按钮更重要。

层次页面中的数据可以确认不能据此确认
触摸输入activeStroke、触点坐标当前触摸正在被页面采集签名已有效、已确认或已保存
新建签名strokes已采集的本地笔画可参与绘制已回写真实工单或已持久化
历史预览signatureHistoryselectedHistoryId指定历史记录可被选中并只读展示历史记录来自真实服务端
页面状态signatureStatusviewModeantialiasEnabled当前页面给出的本地提示和展示模式后端、设备或业务流程已经完成

这四层都可能触发视觉变化,但不能互相替代。例如,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()进入。它先拒绝预览状态下的写入,再按DownMoveUpCancel处理同一条笔画:

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()只取当前触点的xy,代码没有做压感、笔锋或身份认证,因此不能把这块画布描述为具有电子签名法律效力的签署链路。

为什么移动一次就要重绘一次

Canvas 不是声明式文本组件。strokes数组更新后,之前执行过的moveTolineTostroke不会自动根据新数组改写已经存在的像素。因此,移动事件只追加坐标而没有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'说明页面选择圆形端点和圆形连接来呈现笔迹;它们描述的是当前本地绘制样式,不是对签名真实性或图像质量的认证。scaleXscaleY则解释了预览与新建模式为何不能完全沿用同一坐标比例:历史记录按 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()只清除选中历史项、回到新建模式,让用户继续处理已有的strokesstartNewSignature()在此基础上还清空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 基准坐标。后续历史预览再按照当前画布尺寸放大,能够减少“在一个尺寸写入、换一个尺寸预览时位置完全失真”的问题。它能证明页面进行了本地坐标归一化,不能证明记录离开当前页面后仍然完整可用,因为没有持久化和跨设备同步的实现。

场景解说:此图应显示有效笔迹后的确认操作、历史列表新增记录和“未回写真实工单”提示;只能证明本地历史状态变化,不能代替外部工单系统的回执证据。

抗锯齿是重绘条件,不是另一篇文章的主线

签名页提供antialiasEnabledtoggleAntialias(),但这里它服务于手写笔迹的显示状态,而不是把整篇写成文字边缘对比。切换时先尝试写入当前绘制上下文,再无论成功或回退都调用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,若当前运行环境抛出异常就恢复之前的开关值,随后再重画签名板。它解决的是“按钮文本变了但画布仍保留旧像素”的一致性问题。

我不会把它描述成“关闭后签名一定更锯齿”或“开启后每台设备都更平滑”。源码只处理设置与重绘,没有提供真机截图、像素测量或设备对照。正确的观察顺序是:记录切换前状态,点击开关,确认状态文案与按钮一致,再观察签名字迹是否随完整重绘更新;若需要评价视觉差异,必须补充相同画布尺寸、相同笔迹和目标设备上的实际取证。

我按这条操作链做人工复核

为了把“页面显示了签名”与“业务完成”分开,我会按以下顺序核对当前工程页面:

  1. 进入FullScreenSignaturePage,确认新建模式提示为“请在签名板内书写”,签名板为空且带有基线。
  2. 在签名板内完成一条连续笔迹,观察状态从“正在采集现场签名…”变为有效笔迹提示;短划或短按时应保留“笔迹过短”分支。
  3. 点击“撤销”,确认最后一笔从 Canvas 消失;点击“清空画布”,确认当前新建笔迹清除,但页面基线仍保留。
  4. 选中一条历史记录,确认页面进入历史预览并拒绝继续书写、撤销、清空和确认;退出预览后,确认可重新进入新建签名流程。
  5. 写入有效笔迹后点击“确认并归档”,确认本地历史列表增加记录,并且状态明确显示“未回写真实工单”。
  6. 切换抗锯齿,确认按钮和状态文案同步;仅在补充同尺寸目标设备取证后,才对边缘差异做视觉结论。

这六步覆盖当前页面可观察的本地状态变化。它们不包含账号、服务端工单、数据库或真实签字人验证,因为工程文件里没有对应调用链。人工审核时若需要发布“工单已更新”“记录已归档到后台”之类的结论,应先补充外部系统回执、接口路径和失败处理证据,不能从这张 Canvas 的页面状态推导。

结论:重绘负责对齐本地状态,不负责替业务背书

维修签名页的难点不在于画出一条线,而在于让每一次本地状态变化都有唯一、可追溯的画面结果。触摸采集更新的是当前笔画;预览切换更换的是可见笔迹来源;撤销和清空先改变数组再重画;确认新增的是本地历史记录;抗锯齿切换改变的是绘制配置。它们都需要通过redraw()回到同一条绘制路径,才能避免旧像素留在画布上误导操作人。

与此同时,重绘的责任到 Canvas 为止。它能让读者观察到本地状态与签名板一致,不能让页面凭空拥有真实工单回写、服务端持久化、身份认证或跨设备同步能力。把这一层边界说清楚,才是我在维修签名页中坚持每次状态变化后重新绘制的原因。

必要条件|运行条件矩阵

必要条件工程位置运行前动作未满足时现象
SDK/API与构建工具build-profile.json5、Hvigor确认 compile/compatible/target 均为 API 24类型检查或构建失败
Kit引入当前文章对应页面 import检查 Kit 名称和 API 是否与 API 24 匹配编译期找不到类型或方法
模块与页面配置module.json5main_pages.json确认页面已注册、模块为 Stage页面无法启动或路由失败
设备权限与动态授权requestPermissionsabilityAccessCtrl先查询并申请权限能力初始化被拒绝或跳过
系统能力与硬件设备能力、Camera、MapKit、VisionKit等在目标设备检查能力进入不支持、等待或人工降级状态

矩阵中的 SDK/API 行核对完成后插入构建证据(需同时看到 API 24 和构建工具信息):

设备权限行核对完成后插入授权证据:

系统能力与硬件行核对完成后插入版本/能力证据:

MapKit文章在系统能力行后增加:AppGallery Connect 项目已创建或选定,应用包名和签名证书与工程一致,MapKit 服务已开通并按控制台要求完成应用服务凭据/授权配置。截图不得带出密钥或证书私钥。


版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/8/20 23:51:41

孤能子视角:感与质–––直觉操作与感质

(在以下的与AI互动中&#xff0c;在EIS理论约束下&#xff0c;DeepSeek叫信兄&#xff0c;Kim叫酷兄&#xff0c;我呢叫水兄。姑且当科幻小说看) (已由信兄整理成文)感与质&#xff1a;直觉操作与感质 EIS理论库认识论分册 2026-08-20 状态&#xff1a;已入库 题记含羞草一碰就…

作者头像 李华
网站建设 2026/8/20 23:51:35

从游戏脚本到企业RPA:构建通用自动化BOT的核心架构与工程实践

最近在技术社区里&#xff0c;一个看似“游戏外挂”的标题——“一个人拿不了的黄金&#xff0c;我带上BOT强行拿”——意外地引发了不少开发者的讨论。这背后指向的&#xff0c;其实是一个远比游戏脚本更通用、更核心的技术命题&#xff1a;如何让一个程序&#xff08;BOT&…

作者头像 李华
网站建设 2026/8/20 23:51:30

Claude文本水印技术解析:绿名单算法如何影响AI生成质量

如果你最近在使用 Claude 生成文本&#xff0c;可能会发现一些微妙的“不对劲”——某些词汇的用法略显生硬&#xff0c;句子的流畅度似乎打了折扣&#xff0c;甚至在一些本应简洁明了的技术描述中&#xff0c;出现了冗余的修饰。这很可能不是你的错觉&#xff0c;也不是模型“…

作者头像 李华
网站建设 2026/8/20 23:46:28

推理引擎部署前的配置核对

推理引擎部署前的配置核对 这篇要解决什么 推理引擎部署前的配置核对讨论的是一个可复查的工程问题。推理引擎部署前的配置核对不拿未经记录的事故、跑分或成本当作论据&#xff1b;判断需要回到当前项目的输入、版本和运行条件。 从边界开始 处理推理引擎部署前的配置核对时&a…

作者头像 李华
网站建设 2026/8/20 23:44:17

嵌入式系统开发:从三层架构到接口技术实战解析

1. 从“黑盒子”到“透明世界”&#xff1a;嵌入式系统的本质认知 很多刚接触嵌入式的朋友&#xff0c;常常会陷入一个误区&#xff1a;把嵌入式系统看作一个缩小版的电脑&#xff0c;认为只要会写C语言&#xff0c;就能搞定一切。这种认知偏差&#xff0c;往往会导致后续学习事…

作者头像 李华
网站建设 2026/8/20 23:38:23

【AI接入大模型SDK】人工智能发展历史 AI相关岗位 AI能否取代程序员

&#x1f3ac; 个人主页&#xff1a;艾莉丝努力练剑❄专栏传送门&#xff1a;《C语言》《数据结构与算法》《C/C干货分享&学习过程记录》 《Linux操作系统编程详解》《笔试/面试常见算法&#xff1a;从基础到进阶》《Python干货分享》⭐️为天地立心&#xff0c;为生民立命…

作者头像 李华