ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

HarmonyOS6.1.1-Canvas:维修签名-开关变化显式重绘

HarmonyOS6.1.1-Canvas:维修签名-开关变化显式重绘

我第一次给 Canvas 加抗锯齿开关时,以为这就是一行赋值的事。

this.antialiasEnabled = !this.antialiasEnabled;

点击按钮后,页面文字已经从“AA 开”变成了“AA 关”。看上去没有异常。可我盯着画布里的文字和斜线看了半天,画面像是从头到尾都没有变。

最初我把问题归到设备渲染差异上。后来才发现,问题比“这个设备看不出差别”更基础:我只改了页面状态,没确保状态变化重新进入 Canvas 的绘制流程。

那次排查的过程很像在找一盏明明亮着开关、灯泡却不亮的灯。按钮文案是开关,antialiasEnabled是墙里的线路,Canvas 里的像素才是灯泡。开关拨下去并不说明最后一环已经通电。这个比喻听起来朴素,但在 UI 开发里特别有用,因为我们每天都在看按钮、标签、Toast 这些“已经变化”的东西,很容易因此相信整件事已经完成。

下面这篇不是讲 Canvas 有哪些接口,也不是做性能横评。我只复盘这一个场景:一个 AA 开关看上去已经切换,为什么画布仍然像没收到通知一样,停在旧画面里。

在项目里,这种问题往往比崩溃更磨人。崩溃至少会给你一个明确的信号:这里出事了。画布不重绘则更安静,页面没有报错,按钮也还活着,甚至日志里也未必有一行红字。它让人产生一种错觉,仿佛功能已经完成,只是“效果不太明显”。如果这个错觉带进业务页面,后面接手的人会反复改字号、换字体、调颜色,最后在完全错误的方向上花掉半天。

所以我给自己定了一个规矩:凡是命令式绘制的交互,不先讨论画面好不好看,先确认它有没有重新执行绘制命令。先确认“有没有发生”,再讨论“发生得好不好”。

故障现场:按钮变了,画布没动

这个 Demo 固定用工单 WO-6101、84px、900 字重做样本。固定输入不是为了做一个漂亮的展示,而是避免排查时又换文字、又换字号、又换字重,最后根本不知道是什么导致了画面变化。

页面上的 AA 按钮由antialiasEnabled控制。假如点击逻辑只有下面这样:

private toggleAntialias(): void { this.antialiasEnabled = !this.antialiasEnabled; this.antialiasStatus = this.antialiasEnabled ? '抗锯齿:开启' : '抗锯齿:关闭'; }

按钮文案肯定能更新,因为它依赖 ArkUI 的状态。但CanvasRenderingContext2D不会因为一个普通状态字段变化就自己重新执行绘制命令。上一次fillTextstrokefillRect产生的像素还留在画布上,所以用户看到的就是“开关好像失灵了”。

这里容易被页面状态骗到。UI 状态更新,只能证明 UI 状态更新;它不等于 Canvas 已经使用了新配置重画。

当时我是怎么一步步查偏的

一开始,我先怀疑按钮没有进点击事件。这个判断很好验证:连续点两次,按钮能在AA 开AA 关之间来回切换,顶部状态也会变。说明事件进来了,@State也更新了。第一条怀疑可以划掉。

接着我怀疑是抗锯齿差异太细,肉眼在当前缩放比例下看不出来。这个怀疑也很合理。抗锯齿不是换颜色,不会让整个页面突然“变脸”;它更多影响文字、斜线和边框交界处的过渡。所以我把注意力放到了页面里的放大区,固定了文字、字号和字重,避免每一次点按钮时同时改变其他变量。

可问题仍然在:放大区也没有新的变化。到这一步,再继续讨论“效果是否明显”已经没有意义了。因为我们甚至还没确认新设置有没有重新参与绘制。

于是排查顺序改成了更笨、但更可靠的方式:不再问“为什么看起来没差别”,先问“这次点击之后,到底有没有重新画”。这两个问题很像,答案却可能完全不同。前一个容易把人带去设备、屏幕、字体渲染;后一个会把人带回最基本的调用路径。

把故障过程摊开看

在没有重绘调用的旧写法里,流程会在状态更新后断掉。按钮、顶部文本都依赖状态,因此它们先变化;Canvas 不是自动响应式的,它仍然保留上次绘制的像素。

点击 AA 开关

antialiasEnabled 状态取反

按钮文案更新为 AA 开或 AA 关

是否调用 renderCanvas

Canvas 保留上一次像素

现象: 文案变了 画布没变

清空旧画布

读取 currentSnapshot

按最新 antialias 设置绘制

更新 redrawStatus 和放大区

这张图也是这篇文章的主线。它没有把问题说成“AA API 无效”,因为现有代码里真正需要保证的是:状态更新之后,调用链是否抵达renderCanvas()。一旦把问题定义准确,修复通常不复杂;困难的部分只是别让按钮的变化提前替我们下了结论。

我先检查了三个地方

第一个是按钮文本。它会变,因此这不是点击事件失效。

第二个是ctx.antialias是否真的被设置。现有页面把它封装成applyAntialias,原因并不复杂:这个赋值可能因为运行环境不支持而抛出异常。若直接往下执行,就会出现另一种更难排的假象,状态写成“已关闭”,实际绘制上下文仍保持旧设置。

private applyAntialias(ctx: CanvasRenderingContext2D, enabled: boolean): boolean { try { ctx.antialias = enabled; return true; } catch (_error) { this.antialiasStatus = '当前运行环境不支持动态抗锯齿'; return false; } }

第三个才是关键:设置完成后有没有执行renderCanvas()

它负责清空旧画布、读取当前快照、重新画出当前输出、上次快照和边缘放大区。如果这一步没有发生,前两步即使都正确,用户仍然只能看到旧像素。

我在这里才意识到,Canvas 与普通声明式组件的思维方式不一样。普通组件里,状态变了,框架会负责把依赖这个状态的 Text、Button 再次计算出来。Canvas 更像一张白板:你上一次画上去的线,不会因为旁边变量换了值就自动消失。要先擦,再按新参数重画。没有这一步,旧画面会非常诚实地留在那里,只是它诚实地暴露了我们的遗漏。

实际的renderCanvas()并不只是把文字再写一遍。它会先clearRect清掉旧帧,接着画当前输出;如果存在上一轮快照,就画第二块对比面板;最后画边缘放大区。于是,页面里的三个视觉区域不是三张互不相干的图,而是同一次状态快照的三个视角。也正因为这样,快照捕获放错时机时,故障会变得很隐蔽:画面确实重画了,但“当前输出”和“上次快照”取到的是同一个状态,用户看到两块几乎相同的内容,很自然会误判开关没有作用。

这也是我没有只写“调用renderCanvas()就好了”的原因。修复不是往函数尾部随手补一行,而是先弄清这一次点击前后,哪些数据应该属于旧状态,哪些应该属于新状态。Canvas 的问题经常藏在这种先后顺序里。

修复不是多加一行,而是确定调用顺序

最后保留的实现顺序是:先保存旧快照,再修改状态,再向绘制上下文应用设置,最后重绘。若上下文拒绝动态设置,则回退到修改前的状态,再重绘一次,以免按钮和画面各说各话。

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(); }

这段逻辑有两个容易被忽略的点。

一是快照必须在状态反转之前保存。否则“上次快照”和“当前输出”会拿到同一份数据,右侧对比区看起来存在,实际没有比较价值。

二是重绘必须放在成功和失败分支之后。这样不支持动态抗锯齿的环境也会刷新状态区,用户能看到“当前运行环境不支持动态抗锯齿”,而不会停留在某个旧画面上猜发生了什么。

还有一个小细节我后来才重视:renderCanvas()的开头会检查canvasReady。这意味着页面刚创建、Canvas 还没有 ready 时,就算状态先变了,也不能硬画。这个保护避免了拿着未就绪的绘制上下文去调用一串 API。等onReady或尺寸变化回调到来,页面再走一次renderCanvas(),此时使用的是最新状态。

private renderCanvas(): void { if (!this.canvasReady) { return; } // 清空旧像素后,按 currentSnapshot() 的最新状态重新绘制。 }

这也是为什么排查这类问题时,不能只在点击函数里找答案。一次“点开关没效果”至少横跨三个时刻:页面是否准备好、点击时状态是否变更、状态变更后是否重新绘制。把它们揉成一个“开关失败”,日志和代码都会变得很难读。

修复后的调用链

修复后,我会用下面这条链路审阅每一次参数切换。它把正常路径和不支持动态设置的回退路径分开,避免页面表面“成功”,内部却保留旧配置。

renderCanvasCanvas 上下文页面状态用户renderCanvasCanvas 上下文页面状态用户alt[设置成功][设置失败或环境不支持]点击 AA 开关capturePreviousSnapshot()antialiasEnabled 取反applyAntialias(enabled)true更新 antialiasStatusrenderCanvas()清空并重画当前快照、历史快照、放大区更新 redrawStatusfalse恢复 previous 状态applyAntialias(previous)renderCanvas()展示回退后的可解释状态

这里的回退并不是为了把异常藏起来。相反,它是为了避免两个事实冲突:页面说“已经关闭抗锯齿”,Canvas 却还在按“开启抗锯齿”的配置绘制。恢复旧状态后,当前结果至少是一致的;再配合当前运行环境不支持动态抗锯齿这条状态,使用者知道下一步该检查环境,而不是继续点击按钮碰运气。

我怎么确认这次真的走到了重绘

不需要靠肉眼盯着一行文字猜。页面在renderCanvas()末尾会更新redrawStatus

this.redrawStatus = `已渲染 ${current.sampleText} · antialias=${current.antialiasEnabled ? 'true' : 'false'}`;

所以这次检查可以拆成三步:

  1. 点击AA 开AA 关
  2. 看顶部状态是否记录了从开启到关闭,或从关闭到开启的切换。
  3. 看底部是否出现与当前状态一致的已渲染 ... antialias=true/false,并确认上次快照已出现。

前三项都成立,说明当前页面已经完成“状态变更 -> 上下文配置 -> 重绘请求”的本地链路。至于不同设备上边缘究竟有多明显,仍需要在相同尺寸的真机上看放大区域。不能因为 Demo 有一块“平滑过渡/硬边界”的说明区,就把它写成所有机型的实测视觉结论。

一次复测,应该像走一条短路线

我现在复测这类交互,会把动作压缩成一条短路线,不靠“多点几次看看”。

先打开页面,确认初始样本仍是工单 WO-6101、84px、900 字重,按钮显示AA 开。接着只点一次 AA 按钮,不切换样本、不改字号、不改字重。此时应该先出现上次快照,再看到当前状态改为关闭,底部渲染状态也变成antialias=false。最后把视线放到边缘放大区,而不是试图从整页截图里凭感觉找细微差异。

如果其中某一步不对,定位会很快:按钮不变,先查点击事件;按钮变、底部渲染状态不变,查有没有调用renderCanvas();底部状态变、但上次快照没有出现,查快照保存时机;状态和快照都对、视觉仍无差异,再去核对当前设备与真机环境。这样每一步都把问题往更小的范围里收,不会一开始就把锅甩给“系统渲染不稳定”。

测试流程不要从“看起来差不多”开始

这类页面最容易写出一句无效用例:“点击开关,观察画布是否变化。”它的问题是没有固定输入,也没有规定观察点。测试的人只能凭感觉判断,得到的结果很难复现。

我更推荐把测试走成下面的流程。每个菱形都是一个停止点:没有通过时,不要继续往后猜,而是回到对应的代码或环境位置补证。

进入 Canvas Anti-Alias Lab

Canvas 是否 ready

等待 onReady 或记录页面初始化异常

确认固定输入: 工单 WO-6101 84px 900

记录初始 AA 状态与 redrawStatus

只点击一次 AA 开关

按钮与顶部状态是否切换

检查 onClick 与 antialiasEnabled 状态写入

底部 redrawStatus 是否更新

检查 toggleAntialias 是否调用 renderCanvas

上次快照是否出现且保持旧配置

检查 capturePreviousSnapshot 的调用时机

检查边缘放大区和当前 antialias 值

是否需要真机视觉结论

记录本地调用链验证完成

同尺寸真机截图对比并记录设备环境

这张测试图的价值不在于把一次点击搞得很复杂,而是防止“看到一个现象就跳到一个结论”。例如redrawStatus没有更新时,测试到这里就应停止,不能继续评价放大区的像素差异,因为它们都还是上一次绘制留下来的内容。反过来,redrawStatus已更新但真机上看不出差异,也不应回头说“重绘没有发生”;那是另一个问题,需要记录设备、分辨率和截图倍率再比较。

写给接手这个页面的人

如果以后需要在这个页面继续加能力,我建议不要把所有变化都塞进 AA 开关里。比如新增画笔粗细、文本颜色、背景主题或缩放比例时,每个交互都应沿用相同的骨架:先抓取旧快照,再改变一个参数,再在当前环境中应用配置,最后统一重绘。这样做的好处是,所有参数切换都有一致的排查入口,测试人员也能复用上面的流程图。

更重要的是,别为了让演示看起来热闹,同时切换多个变量。一次点击如果既换样本、又改字号、又换 AA 状态,任何视觉变化都无法归因。当前 Demo 把“切换样本”“字号”“字重”和“AA”放成独立按钮,正是为了让每一步可以单独复测。单个操作显得慢一点,问题发生时却快得多。

有时候工程里的文采,并不在于把技术写得多么宏大,而在于把一个原本模糊的故障讲清楚:哪一步看似已经完成,哪一步实际还没有发生,最后怎样让两者重新对齐。这个 Canvas 开关的坑很小,但它提醒我,用户眼里的一次点击,背后往往不是一件事,而是一串需要逐一兑现的承诺。

这不是一个只属于 AA 的问题

这个坑以后还会在别的 Canvas 场景里出现。比如改画笔宽度、主题色、缩放级别、图片滤镜,甚至只是切换一段绘制文本。只要底层是命令式绘制,都会面对同样的分界线:状态有没有更新是一回事,状态有没有被重新消费并画到屏幕上是另一回事。

我更愿意把这次问题记成一句很短的话:状态不是画面,重绘才是画面。它不华丽,但在排查时很管用。以后看到“按钮已经变了,内容却没变”,先别急着怀疑 API,先顺着这句话检查一遍。

这次踩坑留下的三个检查项

  • 改 Canvas 相关状态后,是否明确调用了渲染函数,而不是只更新文字?
  • 配置写入失败时,页面状态是否回退,避免显示一个并未生效的值?
  • 对比数据是否在改变参数之前保存,保证“前后”确实来自两次不同状态?

我现在会把 Canvas 的交互看成两条链路:一条是 ArkUI 状态链,负责按钮和文案;另一条是绘制命令链,负责真实像素。两条链路必须在一次操作里同时走完。只盯着前者,最容易得到“功能看上去完成了,画布其实没更新”的假成功。

本文只讨论现有 Demo 中的状态、调用和重绘路径。不同设备的实际边缘效果仍应结合 API 24 运行环境和同尺寸真机截图复核。

返回列表