做签名、批注或电子票据时,Canvas 里的文字经常不是一开始就确定的。用户可能先输入工单号,再调整字号,最后决定是否打开抗锯齿。真正容易出错的地方并不是按钮能不能点击,而是按钮点击后,页面到底有没有把新状态交给同一个 Canvas 上下文并重新绘制。
如果只把按钮文字从"AA 开"改成"AA 关",页面看起来已经响应了,画布却可能仍然保留旧结果。这样的 工程 在演示时很容易被误判为"动态抗锯齿已经接入"。所以这篇文章不从 API 列表开始,而是从一个前端问题开始:切换 antialias 后,怎样让输入、前一次状态、本次状态和重绘结果在同一条链路上?
先固定一个可以比较的签名批注
本 工程 没有直接把整张签名图塞进页面,而是用一段接近真实工单的批注文字作为稳定样本:工单 WO-6101。字号在 84px 和 96px 之间切换,字重在 900 和 700 之间切换,抗锯齿则在 true 和 false 之间切换。
固定输入的目的不是限制功能,而是让比较有意义。假如切换开关的同时换了文字、字号和字重,即使前后画面不同,也无法判断差异来自哪个变量。前端测试中,先固定样本,再只改变一个状态,是最便宜也最有效的排查手段。
页面上有两个输出面板。左侧是"当前输出",右侧是"上次快照"。第一次打开页面时右侧还没有快照;点击 AA 开关、切换样本、字号或字重后,当前状态会先被保存到上次快照,再生成新的当前输出。这样读者不用依赖肉眼猜测,也能知道这一次操作前后到底改了什么。
对电子签名而言,这种可比较性比一张"效果很好"的终态截图更重要。签名画布通常同时承载自由轨迹、姓名或工单文字、日期、印章占位和辅助线。只要其中两个变量同时变化,边缘差异就会被笔迹密度、字号或者缩放比例掩盖。把文字标注抽成稳定样本,是先把复杂业务收束成可验证的渲染问题;等这条链路可靠以后,再把真实手写轨迹接回来,工程风险会小得多。
状态不要只放在按钮里
页面的核心状态很小,但每一个字段都有明确职责:
ts
interface SnapshotState {
sampleText: string;
fontSizeValue: number;
fontWeightLabel: string;
antialiasEnabled: boolean;
}
@State private sampleText: string = '工单 WO-6101';
@State private fontSizeValue: number = 84;
@State private fontWeightLabel: string = '900';
@State private antialiasEnabled: boolean = true;
@State 负责驱动 ArkUI 组件显示,SnapshotState 则负责描述一次画布输出。两者不应混成一个字符串。例如,antialiasStatus 可以显示"抗锯齿:开启",但它不是渲染依据;真正决定 Canvas 如何绘制的是 antialiasEnabled。
同样,redrawStatus 只用于告诉操作者最近一次绘制使用了什么参数,它不能代替 renderCanvas()。这两个概念分开以后,页面文字即使出现问题,也不会悄悄改变画布实际使用的值。
这里有一个容易被忽略的前端边界:ArkUI 的 @State 属于声明式 UI 状态,Canvas 2D 上下文却是一套带顺序的即时绘制状态。修改 @State 后,Button、Text 等组件会按照框架机制刷新;已经落在 Canvas 位图上的像素不会因为状态变量改变而自动回放一遍绘制命令。开发者必须主动清理画布、重新设置上下文属性,再把图形和文字画回去。
换句话说,页面中存在两条相邻但不同的更新链:一条负责让控件显示新值,一条负责让画布产生新像素。按钮"看起来切换成功"只说明第一条链通了,不能替第二条链作证。
#mermaid-svg-FqukiiPtnWqd6ZiZ{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-FqukiiPtnWqd6ZiZ .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-FqukiiPtnWqd6ZiZ .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-FqukiiPtnWqd6ZiZ .error-icon{fill:#552222;}#mermaid-svg-FqukiiPtnWqd6ZiZ .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-FqukiiPtnWqd6ZiZ .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-FqukiiPtnWqd6ZiZ .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-FqukiiPtnWqd6ZiZ .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-FqukiiPtnWqd6ZiZ .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-FqukiiPtnWqd6ZiZ .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-FqukiiPtnWqd6ZiZ .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-FqukiiPtnWqd6ZiZ .marker{fill:#333333;stroke:#333333;}#mermaid-svg-FqukiiPtnWqd6ZiZ .marker.cross{stroke:#333333;}#mermaid-svg-FqukiiPtnWqd6ZiZ svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-FqukiiPtnWqd6ZiZ p{margin:0;}#mermaid-svg-FqukiiPtnWqd6ZiZ .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-FqukiiPtnWqd6ZiZ .cluster-label text{fill:#333;}#mermaid-svg-FqukiiPtnWqd6ZiZ .cluster-label span{color:#333;}#mermaid-svg-FqukiiPtnWqd6ZiZ .cluster-label span p{background-color:transparent;}#mermaid-svg-FqukiiPtnWqd6ZiZ .label text,#mermaid-svg-FqukiiPtnWqd6ZiZ span{fill:#333;color:#333;}#mermaid-svg-FqukiiPtnWqd6ZiZ .node rect,#mermaid-svg-FqukiiPtnWqd6ZiZ .node circle,#mermaid-svg-FqukiiPtnWqd6ZiZ .node ellipse,#mermaid-svg-FqukiiPtnWqd6ZiZ .node polygon,#mermaid-svg-FqukiiPtnWqd6ZiZ .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-FqukiiPtnWqd6ZiZ .rough-node .label text,#mermaid-svg-FqukiiPtnWqd6ZiZ .node .label text,#mermaid-svg-FqukiiPtnWqd6ZiZ .image-shape .label,#mermaid-svg-FqukiiPtnWqd6ZiZ .icon-shape .label{text-anchor:middle;}#mermaid-svg-FqukiiPtnWqd6ZiZ .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-FqukiiPtnWqd6ZiZ .rough-node .label,#mermaid-svg-FqukiiPtnWqd6ZiZ .node .label,#mermaid-svg-FqukiiPtnWqd6ZiZ .image-shape .label,#mermaid-svg-FqukiiPtnWqd6ZiZ .icon-shape .label{text-align:center;}#mermaid-svg-FqukiiPtnWqd6ZiZ .node.clickable{cursor:pointer;}#mermaid-svg-FqukiiPtnWqd6ZiZ .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-FqukiiPtnWqd6ZiZ .arrowheadPath{fill:#333333;}#mermaid-svg-FqukiiPtnWqd6ZiZ .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-FqukiiPtnWqd6ZiZ .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-FqukiiPtnWqd6ZiZ .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-FqukiiPtnWqd6ZiZ .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-FqukiiPtnWqd6ZiZ .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-FqukiiPtnWqd6ZiZ .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-FqukiiPtnWqd6ZiZ .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-FqukiiPtnWqd6ZiZ .cluster text{fill:#333;}#mermaid-svg-FqukiiPtnWqd6ZiZ .cluster span{color:#333;}#mermaid-svg-FqukiiPtnWqd6ZiZ div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-FqukiiPtnWqd6ZiZ .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-FqukiiPtnWqd6ZiZ rect.text{fill:none;stroke-width:0;}#mermaid-svg-FqukiiPtnWqd6ZiZ .icon-shape,#mermaid-svg-FqukiiPtnWqd6ZiZ .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-FqukiiPtnWqd6ZiZ .icon-shape p,#mermaid-svg-FqukiiPtnWqd6ZiZ .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-FqukiiPtnWqd6ZiZ .icon-shape .label rect,#mermaid-svg-FqukiiPtnWqd6ZiZ .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-FqukiiPtnWqd6ZiZ .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-FqukiiPtnWqd6ZiZ .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-FqukiiPtnWqd6ZiZ :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 是
否
用户点击 AA 开关
保存当前参数快照
切换 antialiasEnabled
写入 Canvas 上下文成功?
清空并重绘画布
刷新当前输出与重绘状态
恢复旧状态
显示环境不支持
这张图里最关键的不是成功分支,而是 保存旧值 位于 切换新值 之前,失败时还能沿原路径退回。只要顺序错一处,右侧快照和按钮状态就可能同时失真。
先保存旧状态,再改变新状态
切换开关的入口是 toggleAntialias()。这个函数有三个顺序不能调换:先保存旧快照,再尝试写入 Canvas 上下文,成功后重绘;如果写入失败,则恢复旧值。
ts
private toggleAntialias(): void {
const previous = this.antialiasEnabled;
this.capturePreviousSnapshot();
this.antialiasEnabled = !previous;
if (this.applyAntialias(this.context, this.antialiasEnabled)) {
this.antialiasStatus = `已从${previous ? '开启' : '关闭'}切换为${this.antialiasEnabled ? '开启' : '关闭'}`;
} else {
this.antialiasEnabled = previous;
this.applyAntialias(this.context, previous);
}
this.renderCanvas();
}
这里的 previous 是本次操作前的值,不是页面初始化时的值。capturePreviousSnapshot() 会把文字、字号、字重和 antialias 一起保存下来。因此,右侧快照不会只显示一个"旧的 AA 状态",而是完整描述上一次画布输出。
失败分支也很重要。CanvasRenderingContext2D.antialias 在当前运行环境不可用时,applyAntialias() 会返回 false。此时页面恢复旧值,而不是把按钮留在新状态。对于交互组件来说,"失败后状态回滚"比显示一个漂亮的成功提示更可靠。
从组件设计上看,toggleAntialias() 实际承担的是一次小型事务:旧值是回滚点,applyAntialias() 是写入动作,renderCanvas() 是提交后的可视化结果。如果写入失败却不回滚,UI 与上下文就会出现"双真相":控件认为已经关闭,画布仍按开启状态绘制。此后任何截图、日志和用户反馈都会互相冲突,排查成本远高于一次明确失败。
真正的 API 写入只有这一处
为了让状态来源清楚,工程 把 Canvas 属性写入集中在 applyAntialias():
ts
private applyAntialias(ctx: CanvasRenderingContext2D, enabled: boolean): boolean {
try {
ctx.antialias = enabled;
return true;
} catch (_error) {
this.antialiasStatus = '当前运行环境不支持动态抗锯齿';
return false;
}
}
这样审查代码时可以快速回答两个问题:谁修改了 Canvas 的 antialias,修改失败后页面怎么处理。相反,如果在按钮回调、绘制文字、绘制放大区域里分别写入这个属性,后续很难判断最终画面到底使用了哪个值。
需要注意的是,renderCanvas() 还会为当前面板和上次快照分别应用各自保存的 antialias 状态。这是有意设计的:右侧快照要保留旧条件,左侧当前输出要使用新条件。绘制结束后,状态面板会记录本次输出:
ts
const current = this.currentSnapshot();
// ...绘制当前面板和上次快照
this.redrawStatus = `已渲染 ${current.sampleText} · antialias=${current.antialiasEnabled ? 'true' : 'false'}`;
这句文字和 clearRect()、面板绘制、放大区域绘制处于同一个 renderCanvas() 调用中,可以帮助测试者把一次操作和一次绘制记录关联起来。不过,排查画布是否刷新时仍要回到实际绘制调用,不能只观察状态文字。
Canvas 上下文有状态,绘制顺序就是结果的一部分
CanvasRenderingContext2D 不是一组互不相关的工具函数。fillStyle、strokeStyle、font、textAlign、lineWidth 和 antialias 都会影响后续绘制调用,并保持到下一次被改写。当前输出与上次快照共用同一个上下文时,绘制每个面板前都必须恢复属于该快照的参数。
本 工程 在 drawSnapshotPanel() 开头调用 applyAntialias(ctx, snapshot.antialiasEnabled),随后再设置颜色、字体和线宽。这样做的含义是:每个面板的绘制结果只由传入的 SnapshotState 决定,而不是偶然继承前一个面板留下的上下文状态。
如果省略这一步,右侧虽然写着 antialias=true,实际却可能继承左侧刚设置的 false。文字标签和像素结果会各说各话。对于同屏对照页,这是比"按钮没响应"更隐蔽的错误,因为界面结构完整、参数标签也正确,只有绘制状态发生了串扰。
在更复杂的签名板中,可以使用 save() 和 restore() 管理局部绘制状态,把背景、签名轨迹、文字批注和印章分别包在独立的状态区间内。当前 工程 的参数较少,选择在每个绘制函数入口显式设置必要属性,更容易让文章读者看清状态来源。两种方式没有绝对优劣,关键是不能依赖"上下文现在大概是什么状态"。
onReady 与 onAreaChange 为什么都要防守
Canvas 在页面构建时并不一定立刻拥有可用尺寸。工程 用 canvasReady 阻止过早绘制,并在 onReady() 中进行第一次渲染;onAreaChange() 则更新画布宽高,在横竖屏变化或容器尺寸调整后重绘。
ts
private renderCanvas(): void {
if (!this.canvasReady) {
return;
}
// 根据最新 canvasWidth、canvasHeight 计算布局并完成整帧绘制
}
这段保护看起来简单,却决定了页面能否稳定启动。若在 Canvas 准备之前执行绘制,调用可能没有得到可观察结果;若尺寸变化后只更新宽高而不重绘,面板仍按旧坐标留在画布里,轻则留白,重则被底部操作栏遮挡。
完整的生命周期可以概括为:
#mermaid-svg-O9y9T7fG0aqNNXXA{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-O9y9T7fG0aqNNXXA .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-O9y9T7fG0aqNNXXA .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-O9y9T7fG0aqNNXXA .error-icon{fill:#552222;}#mermaid-svg-O9y9T7fG0aqNNXXA .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-O9y9T7fG0aqNNXXA .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-O9y9T7fG0aqNNXXA .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-O9y9T7fG0aqNNXXA .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-O9y9T7fG0aqNNXXA .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-O9y9T7fG0aqNNXXA .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-O9y9T7fG0aqNNXXA .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-O9y9T7fG0aqNNXXA .marker{fill:#333333;stroke:#333333;}#mermaid-svg-O9y9T7fG0aqNNXXA .marker.cross{stroke:#333333;}#mermaid-svg-O9y9T7fG0aqNNXXA svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-O9y9T7fG0aqNNXXA p{margin:0;}#mermaid-svg-O9y9T7fG0aqNNXXA .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-O9y9T7fG0aqNNXXA .cluster-label text{fill:#333;}#mermaid-svg-O9y9T7fG0aqNNXXA .cluster-label span{color:#333;}#mermaid-svg-O9y9T7fG0aqNNXXA .cluster-label span p{background-color:transparent;}#mermaid-svg-O9y9T7fG0aqNNXXA .label text,#mermaid-svg-O9y9T7fG0aqNNXXA span{fill:#333;color:#333;}#mermaid-svg-O9y9T7fG0aqNNXXA .node rect,#mermaid-svg-O9y9T7fG0aqNNXXA .node circle,#mermaid-svg-O9y9T7fG0aqNNXXA .node ellipse,#mermaid-svg-O9y9T7fG0aqNNXXA .node polygon,#mermaid-svg-O9y9T7fG0aqNNXXA .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-O9y9T7fG0aqNNXXA .rough-node .label text,#mermaid-svg-O9y9T7fG0aqNNXXA .node .label text,#mermaid-svg-O9y9T7fG0aqNNXXA .image-shape .label,#mermaid-svg-O9y9T7fG0aqNNXXA .icon-shape .label{text-anchor:middle;}#mermaid-svg-O9y9T7fG0aqNNXXA .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-O9y9T7fG0aqNNXXA .rough-node .label,#mermaid-svg-O9y9T7fG0aqNNXXA .node .label,#mermaid-svg-O9y9T7fG0aqNNXXA .image-shape .label,#mermaid-svg-O9y9T7fG0aqNNXXA .icon-shape .label{text-align:center;}#mermaid-svg-O9y9T7fG0aqNNXXA .node.clickable{cursor:pointer;}#mermaid-svg-O9y9T7fG0aqNNXXA .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-O9y9T7fG0aqNNXXA .arrowheadPath{fill:#333333;}#mermaid-svg-O9y9T7fG0aqNNXXA .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-O9y9T7fG0aqNNXXA .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-O9y9T7fG0aqNNXXA .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-O9y9T7fG0aqNNXXA .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-O9y9T7fG0aqNNXXA .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-O9y9T7fG0aqNNXXA .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-O9y9T7fG0aqNNXXA .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-O9y9T7fG0aqNNXXA .cluster text{fill:#333;}#mermaid-svg-O9y9T7fG0aqNNXXA .cluster span{color:#333;}#mermaid-svg-O9y9T7fG0aqNNXXA div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-O9y9T7fG0aqNNXXA .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-O9y9T7fG0aqNNXXA rect.text{fill:none;stroke-width:0;}#mermaid-svg-O9y9T7fG0aqNNXXA .icon-shape,#mermaid-svg-O9y9T7fG0aqNNXXA .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-O9y9T7fG0aqNNXXA .icon-shape p,#mermaid-svg-O9y9T7fG0aqNNXXA .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-O9y9T7fG0aqNNXXA .icon-shape .label rect,#mermaid-svg-O9y9T7fG0aqNNXXA .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-O9y9T7fG0aqNNXXA .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-O9y9T7fG0aqNNXXA .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-O9y9T7fG0aqNNXXA :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 是
否
组件进入页面
Canvas onReady
canvasReady = true
按当前尺寸绘制第一帧
Canvas onAreaChange
更新 canvasWidth / canvasHeight
Canvas 已准备?
按新尺寸重绘
等待 onReady
用户修改参数
保存旧快照
更新上下文状态
图中 onReady、onAreaChange 和用户操作最后都汇入同一个 renderCanvas()。这比为每个事件分别维护一套绘制逻辑更稳:布局计算、背景清理、快照绘制和状态记录只有一个实现入口,后续修改画布结构时不容易漏掉某条路径。
为什么要画"边缘放大"区域
签名板场景里,抗锯齿的差异最终体现在文字、斜线和边框的边缘。工程 在下方绘制了一个放大区域,用两组颜色模拟边缘从背景色过渡到前景色的过程:开启抗锯齿时使用平滑的中间色,关闭时使用更硬的边界。
这个区域的定位是"观察工具"。不同分辨率、缩放比例和字体渲染环境可能产生不同的实际边缘。开发时可以借助它检查当前快照是否进入绘制过程;到了真机,还要结合设备像素密度和实际字体观察最终效果。
更严谨地说,这块区域验证的是"参数到绘制分支"的确定性,不是"字体栅格化结果"的普遍性。真实字体边缘还会受到字体文件、字形 hinting、设备像素密度、系统缩放、Canvas 实际尺寸和截图缩放算法影响。若文章需要讨论肉眼差异,应固定设备、字体、字号、缩放倍数和截图方式,再对同一区域取样;否则,读者看到的可能只是图片平台二次压缩后的差异。
一次可复现的操作路径
在 API 24 模拟器上,我按下面的顺序核对了 工程:
- 打开
CanvasAntialiasTogglePage,保持样本为工单 WO-6101,字号 84px,字重 900,antialias 为true。 - 确认左侧显示当前输出,右侧显示"切换参数后保留上次快照"。
- 点击底部的
AA 开按钮,使状态从true切换到false。 - 观察右侧快照是否保留切换前的
true,左侧当前输出是否显示false,底部重绘状态是否更新。
在 API 24 模拟器中执行一次 true -> false 切换后,右侧保留了切换前的 true 快照,左侧更新为 false,底部状态随本次绘制刷新。操作、旧状态和当前输出能够在同一屏中连续观察。
对工程验收来说,前后快照至少解决了三件事。第一,它把操作前的参数留在当前页面,不必依赖测试者记忆。第二,它让一次切换成为可复述的过程,而不是只有终态。第三,它为异常定位提供分界:若按钮状态变化但左右画面参数相同,问题在快照或状态更新;若左右参数不同但像素完全未刷新,问题更可能在重绘入口或上下文应用。
模拟器与真机在字体栅格化、屏幕密度和显示缩放上可能存在差异。要确认目标设备上的实际文字边缘,应在真机上保持相同输入和操作,再对比同一局部区域。
最容易出现的三个假成功
第一种是只改按钮文案。按钮显示"AA 关"了,但 renderCanvas() 没有调用,画布仍是旧图。解决办法是把重绘状态和前后快照放在页面上,而不是只看按钮。
第二种是先修改 antialiasEnabled,再保存快照。这样右侧快照记录到的也是新值,前后对比失去意义。快照必须在状态变化前捕获。
第三种是失败后仍保持新状态。比如上下文写入抛出异常,页面却继续显示"抗锯齿:关闭",测试者会误以为 API 已经生效。applyAntialias() 的布尔返回值和回滚分支就是为了避免这种情况。
这套写法适合哪些页面
这种"当前值 + 上次快照 + 重绘记录"的结构,不只适用于 Canvas 抗锯齿。任何需要在页面上比较前后状态的交互,都可以借鉴:字号切换、主题切换、图表筛选、图片处理参数和表单预览都可以使用同样的思路。
关键不是多放几个状态标签,而是保证每个标签都能回到同一次操作:输入是什么,旧值是什么,新值是什么,哪个函数执行了实际绘制,失败后页面停在哪里。这样页面才是一个可调试的工程工具,而不是只能展示"成功"的样板。
对于真正的手写签名,还要再加一层数据与视图分离。手势移动过程中记录的点序列才是签名数据,Canvas 上的线条只是它的当前呈现。切换 antialias、旋转屏幕或恢复页面时,不应把旧位图不断缩放,而应清空画布,根据保存的点序列和文字批注重新播放绘制。这样抗锯齿状态改变后,历史轨迹与新轨迹才能使用一致的渲染条件。
如果签名点非常多,频繁完整重放会带来性能压力。可以把用户正在书写的轨迹作为增量层,把已经确认的内容缓存到离屏画布;参数发生全局变化时,再执行一次完整重放。这个优化属于下一阶段,当前样稿先保证状态链正确,因为一条错误的渲染链即使更快,也只是更快地产生不可解释的结果。
从 工程 走向生产签名板,还要拆开三类数据
演示页使用一个 SnapshotState 就能描述文字输出,生产签名板则不宜把所有内容塞进同一个组件状态。更稳妥的做法是拆成三层:第一层保存业务内容,例如签署人、工单号、时间和签名轨迹;第二层保存渲染参数,例如字号、字重、线宽、颜色和 antialias;第三层保存操作历史,供撤销、重做和审计使用。
这三层的生命周期并不相同。业务内容需要跟随表单保存,渲染参数可以按页面或设备调整,操作历史则可能只在本次编辑会话中存在。若把它们压成一张位图,页面旋转、尺寸变化或切换抗锯齿时只能缩放旧像素,既损失清晰度,也无法说明文字和轨迹当初使用了什么参数。
因此,最终导出图片应是一次"根据确定数据生成结果"的动作,而不是唯一的数据来源。用户每次落笔先更新轨迹模型,再执行增量绘制;撤销时修改模型并重放;切换 antialias 时保留业务数据,只替换渲染参数并重建画面。这样即使后续增加证书背景、印章或水印,也能沿用同一套数据流,而不用在越来越复杂的 Canvas 回调里寻找隐藏状态。
还要留意画布尺寸与显示尺寸并非永远等价。高密度屏幕上,如果只按组件的逻辑尺寸建立绘制坐标,导出的细节可能低于屏幕实际能力。生产实现应统一逻辑坐标、画布像素尺寸和导出尺寸的换算,并让触摸点到画布坐标的转换使用同一比例。antialias 能改善边缘处理,却不能弥补坐标和分辨率模型本身的错误。
FAQ:开发时最常遇到的问题
问题一:按钮已经显示"AA 关",为什么画布看起来没有变化?
先确认 toggleAntialias() 最后是否执行了 renderCanvas(),再确认绘制函数是否在 fillText()、stroke() 之前写入了当前 antialias。按钮文字由 ArkUI 状态驱动,Canvas 像素由命令式绘制产生,两者不会自动同步。若调用链完整但肉眼仍看不出差异,应放大文字斜边或曲线边缘,并保持字体、字号、设备缩放和截图比例不变。
问题二:为什么要保存完整快照,不能只保存上一次的 antialias 值?
因为一帧画面不只由 antialias 决定。文字、字号和字重任何一个变化都会改变边缘。如果右侧只记录布尔值,却没有保存其余参数,前后画面即使不同也缺少可比条件。完整快照让一帧输出拥有自足的参数说明,后续扩展字色、缩放或字体时也有清晰的数据入口。
问题三:为什么切换失败后还要再把旧值写回上下文?
组件状态回滚只恢复了 ArkUI 中的布尔变量,不能假设上下文一定保持旧值。属性写入可能在异常前已经改变部分内部状态,也可能因运行环境实现不同而表现不一致。显式把旧值重新应用,是让 UI 状态和绘制状态重新对齐;如果旧值也无法写入,页面应保持原状态并提示当前环境不支持动态切换。
问题四:onReady() 和 onAreaChange() 都调用重绘,会不会重复执行?
初始化阶段可能出现相邻调用,但两者职责不同:onReady() 宣告 Canvas 可以绘制,onAreaChange() 提供实际布局尺寸。canvasReady 可以过滤尺寸回调早于准备完成的情况。若业务绘制成本很高,可以记录最近一次有效宽高,仅在尺寸确实变化时重绘;不要因此删除任一生命周期入口。
问题五:同一个上下文画两个对照面板,会不会互相污染?
会,如果绘制函数依赖前一次遗留的 font、fillStyle、lineWidth 或 antialias。解决方式是在每个面板入口显式设置所需属性,或者使用 save() / restore() 隔离状态。无论采用哪种方式,面板标题里显示的参数都必须与实际写入上下文的参数来自同一份快照。
问题六:关闭抗锯齿后为什么没有出现非常明显的锯齿?
差异大小取决于字体轮廓、字号、像素密度、缩放和截图显示比例。大号粗体的水平与垂直笔画可能仍然规整,斜线、曲线、小字号或高倍放大区域更容易观察差异。不能为了让截图更"明显"而同时更换多个参数,否则文章失去单变量对照的可信度。
问题七:真实签名轨迹切换 antialias 后,是否需要全部重画?
如果目标是让已存在的签名与后续笔迹使用同一渲染条件,就需要基于保存的点序列重放。Canvas 不会因为上下文属性改变而自动处理已经生成的像素。实际项目应保存轨迹数据,而不是只保存屏幕上的最终位图;复杂场景可以借助离屏缓存降低重放成本。
问题八:模拟器运行正常后,还需要做真机测试吗?
需要。模拟器适合检查按钮事件、快照顺序、异常回滚和重绘调用是否连贯;真机测试关注字体栅格化、屏幕密度、触摸操作和实际显示效果。两类环境的关注点不同,不能用其中一类完全代替另一类。
最后一句
Canvas 的 antialias 动态开关并不难写,难的是让用户和测试者确认它真的影响了画布。把旧状态先保存下来,把属性写入集中到一个函数,把重绘作为状态变化后的明确步骤,再把当前输出和上次快照放到同一屏,前端就拥有了一条可以追踪的状态链。
本 工程 已在 API 24 模拟器上完成一次 true -> false 的切换与重绘。不同真机上的实际像素差异仍应在目标设备上单独测试。把逻辑调试和视觉测试分开,能让这套页面更自然地进入真实项目。
附录
A. 开发环境要求
| 项目 | 要求 |
|---|---|
| 开发工具 | 安装能够使用 HarmonyOS 6.1.1 SDK 的 DevEco Studio |
| SDK | HarmonyOS 6.1.1,API 24 |
| 开发语言 | ArkTS |
| UI 框架 | ArkUI,Stage 模型 |
| 构建工具 | 项目自带 hvigorw.bat;本项目使用过 DevEco Studio Hvigor 6.24.4 |
| 操作系统 | 当前工程可在 Windows 环境中使用 PowerShell 或 DevEco Studio 构建 |
| 依赖 | 以项目根目录和 entry 模块的 oh-package.json5 为准;当前工程未声明第三方依赖 |
项目的 SDK 配置位于根目录 build-profile.json5,三个版本字段保持一致:
json5
{
"compileSdkVersion": "6.1.1(24)",
"compatibleSdkVersion": "6.1.1(24)",
"targetSdkVersion": "6.1.1(24)",
"runtimeOS": "HarmonyOS"
}
若本机没有安装 API 24 SDK,HarmonyOS 6.1.1 新增接口可能在类型检查或编译阶段不可用。此时应先通过 DevEco Studio 的 SDK Manager 安装对应 SDK,再重新同步工程。
B. 工程配置要求
页面源码位于:
text
entry/src/main/ets/pages/article01/CanvasAntialiasTogglePage.ets
页面需要登记在 entry/src/main/resources/base/profile/main_pages.json 中:
json
"pages/article01/CanvasAntialiasTogglePage"
entry 模块使用 Stage 模型,目标设备类型包含 phone、tablet 和 2in1。运行具体页面前,应根据源码涉及的系统能力核对权限、服务配置和输入资源;纯 UI 页面不需要额外申请与功能无关的权限。
在 sourceproject 目录执行以下命令可以构建 HAP:
powershell
.\hvigorw.bat --mode module -p module=entry@default -p product=default assembleHap --no-daemon
预期结果是 Hvigor 输出 BUILD SUCCESSFUL。若使用 DevEco Studio,也可以选择 entry 模块和 default product 后直接构建或运行。连接真机时还需要配置可用的调试签名;仅执行本地未签名构建时,不等同于已经可以安装到真实设备。
C. 测试环境要求
逻辑测试建议先使用 HarmonyOS 6.1.1 API 24 模拟器。测试前固定输入样本、初始状态和操作顺序,每轮只改变一个核心变量;涉及外部服务或硬件能力时,还需准备对应权限、认证、网络或测试资源。
测试时建议固定以下基线:
| 参数 | 基线值 |
|---|---|
| 系统镜像 | HarmonyOS 6.1.1 API 24 |
| 初始状态 | 页面默认状态,操作前重新进入或执行重置 |
| 测试输入 | 使用文章约定的固定样本和固定参数 |
| 主操作 | 每轮只执行文章描述的一项核心操作 |
| 观察内容 | 输入、当前状态、结果区域、异常提示及必要回调 |
基线流程至少重复两轮,确认页面能够从相同起点得到稳定状态。尺寸适配测试至少覆盖横屏、竖屏或一次窗口缩放,检查主要内容、状态区域和操作控件没有互相遮挡。
D. 运行环境要求
模拟器用于检查页面启动、基础交互、状态更新、异常分支和布局适配。运行镜像应为 HarmonyOS 6.1.1 API 24,并确保目标页面已注册且能够从应用入口正常打开。
真机用于检查真实触摸、屏幕显示、性能以及模拟器无法完整提供的硬件或系统能力。设备系统需要满足工程的 compatibleSdkVersion,即 HarmonyOS 6.1.1 API 24;同时开启开发者模式和调试连接,并使用有效签名安装应用。
若页面涉及相机、麦克风、地图、网络服务、媒体文件或授权样本,应在运行前准备相应权限、认证信息和合法测试数据;不涉及外部能力的页面可直接在模拟器中完成基础交互测试。模拟器与真机使用相同的固定输入和操作顺序,便于比较两类环境中的差异。