
維修簽字頁最容易被低估的地方是把它理解成“在畫布上畫幾條線”。實際接入時畫面上同時存在幾類不同性質的數據手指或觸控筆剛落下時的當前筆畫、已經完成的筆畫、歷史記錄中的只讀筆跡以及頁面提示的本地確認狀態。它們看起來都會落在同一塊 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()把舊畫面替換為所選記錄。僅改變列表高亮或標題文字不足以完成“切換預覽”。真正的保護并不在按鈕樣式而在寫入路徑的第一行。觸摸、撤銷、清空和確認都先判斷viewModeif (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 基準坐標。后續歷史預覽再按照當前畫布尺寸放大能夠減少“在一個尺寸寫入、換一個尺寸預覽時位置完全失真”的問題。它能證明頁面進行了本地坐標歸一化不能證明記錄離開當前頁面后仍然完整可用因為沒有持久化和跨設備同步的實現。場景解說此圖應顯示有效筆跡后的確認操作、歷史列表新增記錄和“未回寫真實工單”提示只能證明本地歷史狀態變化不能代替外部工單系統的回執證據。抗鋸齒是重繪條件不是另一篇文章的主線簽名頁提供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 服務已開通并按控制臺要求完成應用服務憑據/授權配置。截圖不得帶出密鑰或證書私鑰。