ArkUI Canvas 重绘机制与高帧率绘制优化:从 invalidate() 误用到 displaySync 稳帧

前言

在鸿蒙应用开发里,Canvas + CanvasRenderingContext2D 是做自定义绘制绕不开的组合:音频波形、股票 K 线、手写笔迹、图表、游戏 HUD、图像标注框……凡是“标准组件表达不了、需要逐像素控制”的场景,几乎都会落到 Canvas 上。它能力足够强,但强能力也意味着更高的性能与正确性责任。

要理解 Canvas 为什么会“偶发不刷新”和“掉帧”,先得把它放进 ArkUI 的整体渲染管线里看。ArkUI 是声明式 UI 框架:你写的 build() 产出一棵组件树,框架做差异比对(diff)后更新渲染树,再经布局(Layout)、绘制(Paint)、合成(Composite)上屏。绝大多数标准组件(Text、Image、List 等)的更新都由框架自动托管——@State 一变,build() 重跑,框架帮你算出“哪几个节点脏了、重绘哪几块”。但 Canvas 是个例外:它内部的像素内容不是由 build() 的声明式结构描述的,而是由你在 CanvasRenderingContext2D 上下发的绘制指令序列描述的。框架知道“这个 Canvas 节点存在”,却不知道“你画了什么、哪些像素变了”。于是重绘的时机、范围、节奏,全都回到了开发者自己手里。这正是 Canvas 既强大又容易出问题的根源:能力自由,责任也完全移交给了你。

不少开发者在实际项目中会遇到这样一组现象:播放或滚动过程中,invalidate() 偶发不刷新——画面偶尔卡在某一帧几百毫秒后才突然跳到最新位置;而当数据点变多、交互变复杂后,帧率直接掉到个位数,Profiler 里 Canvas::DrawFrame 单帧耗时飙到几十毫秒甚至上百毫秒。这两个问题看似独立,底层却都指向同一件事:你没真正理解 ArkUI 的 Canvas 重绘机制——既包括“什么时候画”(渲染时机与触发方式),也包括“画多少”(重绘范围),还包括“以什么节奏画”(帧调度)。

本文面向已经有一两年 ArkUI 经验的开发者,不重复基础语法,而是把重绘时机、掉帧根因、排查方法论与优化手段一次性拆透,并给出两份可直接编译运行的示例:一份是固定 item 高度 + 脏矩形局部重绘的高频重绘页面,另一份是用 displaySync 做与屏幕刷新率对齐的逐帧渲染、并与 setInterval 做正面对比的页面。读完后你应当能建立一套可复用的排查与优化心智模型,而不只是抄到几段代码。

问题描述

先把问题还原成一个可复现的最小场景描述,方便你对照自己的工程:

  • 场景:一个 Canvas 自定义绘制组件,比如音频波形编辑器或实时折线图。画布上既有“相对静态”的内容(背景网格、刻度、选区边界、波形主体),也有“每帧在变”的内容(播放指针、实时采样的尾部)。
  • 现象一(偶发不刷新):在播放过程中,每帧调用一次 invalidate() 试图刷新指针位置。大部分时候正常,但偶尔指针会卡在某一刻不动,持续几百毫秒后突然跳回当前位置。
  • 现象二(掉帧到个位数):当波形/数据点超过 2000 个,缩放和滚动操作明显卡顿。用 DevEco Studio Profiler 抓取后发现,Canvas::DrawFrame 单帧耗时超过 80ms(远低于 60fps 所需的 16.6ms),整体帧率掉到个位数。
  • 现象三(重影):用 Path 画曲线,画完后没有先清屏就再次绘制,画面出现新旧内容叠加的重影。

这三个现象不是三个独立 bug,而是同一套机制在不同侧面的暴露:刷新调度不对、绘制范围不对、清屏语义不对。下面逐一拆解。

为了便于你对照自己的工程,给出一个可复现的最小场景描述:新建一个 @Entry 页面,放一个 Canvas 组件并绑定 CanvasRenderingContext2D;在 onReady 里画一条 2000 点的折线(或一个矩形列表);之后用一个定时器或播放回调,每帧只改一个“指针位置”变量并尝试刷新画布;缩放/滚动时让数据点全部重画。把数据点从 200 逐步加到 2000 以上,你大概率会观察到:指针刷新开始偶尔“粘滞”,缩放时帧率明显下跌,Canvas::DrawFrame 耗时抬升。这个最小场景足以把本文所有根因都暴露出来,也方便你在本地用 Profiler 反复验证优化前后的差异。

需要强调的是,把“偶发不刷新”“掉帧”“重影”放在一篇文章里讲,不是因为它们是同一个错误,而是因为它们共享同一套底层机制:ArkUI 的 Canvas 不替你管理“画了什么、哪些像素变了”,一切重绘的时机、范围、节奏都由你掌控。刷新调度决定“画不画得动”,绘制范围决定“画得多不多”,清屏语义决定“画得干不干净”。任何一环理解偏差,都会以这三种现象之一显现。

细节解析

Canvas 的渲染时机:onReady 才是可绘制的起点,invalidate() 到底做了什么

先把最基础也最容易被误解的一点讲清楚。

第一,onReady 才是可以安全开始绘制的起点。 Canvas 组件的 onReady 回调在画布就绪、尺寸确定后触发;在它触发之前,画布宽度/高度可能还没确定,此时下发绘制指令要么无效、要么坐标错乱。更关键的是:onReady 在整个组件生命周期里只会触发一次(尺寸变化时会再次触发,触发时画布会被清空)。很多“首帧竞态”问题的根因,就是开发者在 onReady 之前(例如 aboutToAppear 里、或在某个异步回调里)就 schedule 了第一次绘制,结果画布还没准备好,指令被丢弃或尺寸为 0,表现为“第一帧没画出来 / 偶尔不刷新”。

第二,也是本文要重点纠正的误解:CanvasRenderingContext2D 没有 invalidate() 方法,声明式 Canvas 组件本身也没有 invalidate() 方法。 这是官方 FAQ 明确说明的事实。我们在排查时经常看到开发者从 Web 经验迁移过来,误以为“调用 ctx.invalidate() 就能让画布立即重绘”。在 ArkUI 里:

  • invalidate() 是 DrawingRenderingContext(API 12+ 引入的另一种画布上下文)的方法,用于手动把绑定了该上下文的 Canvas 组件标记为需要重新渲染。
  • 如果你用的是最常见的 CanvasRenderingContext2D,它根本不存在 invalidate() 这个成员。即便你“感觉”自己能调到,要么是编译报错,要么是你自己封装了一个同名方法、或混用了 DrawingRenderingContext,语义完全不同。

那么,CanvasRenderingContext2D 下“重绘”到底靠什么?答案是:重新下发绘制指令。当你(在任意线程安全的 UI 上下文里)再次调用 this.ctx.fillRect / stroke / clearRect 等命令时,画布节点会被标记为脏,并在下一次合成(Vsync)时刷新到屏幕。所以“重绘”的驱动力不是某个 invalidate() 调用,而是你的 draw 函数被重新执行了。常见的正确驱动方式有两种:

  1. 用 @State + @Watch:状态变化触发 @Watch 回调,回调里调用 draw() 重新下发指令。
  2. 用帧回调(displaySync 的 on('frame') 或动画回调):每一帧主动调用 draw()。

这里有个初学者常有的疑问:重发绘制指令时,build() 并没有重新执行,画布为什么会刷新?原因在于 CanvasRenderingContext2D 是与 Canvas 节点绑定的有状态实时对象:你调用的每一条绘制命令都会立即作用到这块画布缓冲上,而画布节点在渲染流水线里会在下一帧合成时被重新上传/合成到屏幕。所以“刷新”并不需要 build() 重跑,只要画布缓冲的内容变了、且节点处于可见状态,系统就会在最近的 Vsync 把它上屏。反过来也成立:如果你只在 onReady 里画了一次、之后不再调用任何绘制命令,画布缓冲永远不变,屏幕自然永远停在那一帧——这正是“不刷新”的另一面。理解了“指令即刷新”,你就不再需要、也不应该去幻想一个 ctx.invalidate(),而是把注意力放在“我的 draw 函数有没有被正确、及时地再次调用”上。

这里有一个极易踩的坑:如果你把全部绘制逻辑只写在 onReady 里,那么 onReady 触发一次之后,后续状态变化根本不会重绘——因为没有任何东西再去调用你的绘制函数。这正是“偶发不刷新”里“大部分时候不动、偶尔动一下”的典型来源:那些“偶尔动”的瞬间,往往是因为别的 @State 变化意外触发了 build、顺带把绘制逻辑又跑了一遍,而不是你主动控制的。

两种画布上下文:CanvasRenderingContext2D 与 DrawingRenderingContext 的取舍

在 ArkUI 里,Canvas 组件可以绑定两种上下文,二者机制不同、适用面也不同,先把它们分清楚,后续所有讨论才有了坐标系:

  • CanvasRenderingContext2D:Web 兼容风格的 2D 绘制上下文,API 与前端 Canvas 2D 高度相似(fillRect、stroke、fillText、createLinearGradient、Path2D 等)。它上手快、生态熟悉,是绝大多数业务自定义绘制(图表、波形、标注)的首选。它的“重绘”靠重新下发绘制指令,没有 invalidate()。
  • DrawingRenderingContext(API 12+):基于系统图形引擎(drawing 模块)的上下文,直接操作 Brush/Pen/Canvas 等底层对象,性能上限更高,适合对帧率、绘制量极端敏感的场景(如复杂图形编辑器)。它才拥有 invalidate() 方法,用于手动把绑定它的 Canvas 标记为需要重新渲染。

需要特别提醒:不少“偶发不刷新”的案例,本质是两套机制被混用了——开发者以为自己在用 CanvasRenderingContext2D,却照着 DrawingRenderingContext 的文档去调 invalidate(),结果要么编译期就报错(方法不存在),要么自己封装了一个同名方法、内部语义不清,刷新行为变得不可预测。选型时建议:优先 CanvasRenderingContext2D 满足需求就用它;只有当它的封装开销确实成为瓶颈(如每帧海量图元、需要直接走 Native drawing)时,才切换到 DrawingRenderingContext,并严格按它的 invalidate() 语义来驱动重绘。本文的示例与大多数优化手段都以 CanvasRenderingContext2D 为基准,因为它覆盖了绝大部分业务场景,且其“重发指令”的心智模型同样适用于理解 DrawingRenderingContext 的 invalidate()。

为什么“偶发不刷新”

把上面机制映射到真实代码,偶发不刷新通常由以下几类原因叠加:

  1. 状态变量没有参与 build(),导致 UI 不感知变化。 例如你在一个普通成员变量(private progress: number)上改值,然后指望画布自己刷新。普通成员不参与 ArkUI 的响应式系统,@Watch 不会触发,build() 不会重跑,draw 函数不会被调用——画面自然不动。正确做法是让驱动重绘的变量是 @State(或用 @Watch 观察它),改动后才有机会触发 draw()。

  2. 在非 UI 线程 / 异步回调里改状态,但没走响应式通道。 音频播放进度、网络数据、定时器回调往往发生在非 UI 上下文。如果你在这些回调里直接改成员变量、再想“手动刷一下”,很容易遇到上下文丢失或根本没有触发重绘。更稳妥的模式是:数据侧只维护“最新值”,UI 侧通过一个 @State/@Watch 或帧回调去“消费”这个最新值并下发绘制指令,而不是在数据回调里直接画。

  3. invalidate() 与首帧竞态(针对 DrawingRenderingContext)。 如果你确实用的是 DrawingRenderingContext,invalidate() 的语义是“标记组件状态已变更、请求重新渲染”,它不等价于立即绘制一次。系统会把同一渲染周期内的多次 invalidate() 合并成一次渲染,中间被合并掉的绘制指令可能整段跳过。于是在高频场景里,你会看到“指针卡住几百毫秒后突然跳到最新位置”——跳变就是因为中间若干帧被合并掉了。对 CanvasRenderingContext2D 而言虽然没有 invalidate(),但如果你用“频繁触发 build / 频繁调用 draw”来模拟它,同样会因为“每帧都全量重下发指令”而把绘制指令队列撑爆,最终触发丢帧。

  4. 同一帧内多次调用被合并 / 指令累积。 ArkUI 的 Canvas 采用增量绘制模式:每次重绘会重新执行缓存的历史绘制指令,而不是只画“这一帧新增的内容”。如果你每帧都 invalidate()(或等价地每帧全量重发一遍指令),历史指令会不断累积,单帧绘制耗时随累积量线性增长,一旦超过 Vsync 周期,渲染队列就开始丢帧——这把“偶发不刷新”和“掉帧到个位数”两条问题串起来了。

  5. onReady 里重设画笔属性,与动画帧相互踩踏。 有人在 onReady 里设置 lineWidth、strokeStyle、font 等画笔状态,而动画帧又在自己的 draw 里改这些状态。两者交错时,画笔属性被反复重置,表现为“卡顿式闪烁”。画笔状态的初始化应当放进 draw 函数内部,保证每帧自洽。

为什么帧率掉到个位数

当数据点变多、交互变复杂,单帧耗时暴涨,根因几乎都集中在“每帧在做不该每帧做的事”。逐条拆解:

  1. 每帧重复构造 LinearGradient / Path2D 等对象。 渐变、路径都是重量级对象。如果在 draw 里每帧 new 一个 createLinearGradient(...) 或 new Path2D(),不仅产生大量短生命周期对象(GC 压力),还会把创建 + 上传 GPU 的开销重复进每一帧。正确做法是:不随帧变化的部分(颜色、固定路径、网格)在初始化阶段构造一次并复用;只有真正随帧变化的内容才在 draw 里构建。

  2. fillText 大量调用。 文本绘制比图形绘制贵得多(涉及字体光栅化、字形布局)。2000 个数据点如果每个都配一个 fillText 标签,单帧文本开销就能吃掉十几毫秒。优化方向:降采样后每个像素列只画一个标签、或用离屏静态图层预渲染文本、或干脆在交互停止后再显示密集标签。

  3. 全量重绘,没有做脏矩形。 这是掉帧的第一大元凶。每次都 clearRect(0,0,w,h) 然后重画全部 2000 点,意味着哪怕只改了一根指针,也要为整块画布重新执行 2000 次绘图指令。正确做法是只重绘“发生变化的最小矩形区域”(脏矩形),其余区域保持不动。

  4. ctx.save() / restore() 滥用。 save/restore 会压栈/弹栈保存绘制状态。如果每画一条线都 save/restore 一次,在 2000 次循环里就是 2000 次不必要的栈操作。多数情况下,直接 set 属性(覆盖式赋值)即可,不必 save/restore;只有在确实需要“局部变换后回滚”时才用。

  5. 在 draw 里做同步 IO / 大量对象分配。 draw 函数处在渲染关键路径上,任何同步文件读取、网络、JSON 解析、大数组 map/filter 都会直接拉长单帧耗时。数据准备应当前置到 draw 之外,draw 只做“纯绘制”。

  6. RenderGroup 未开,导致静态子树无法离线合成缓存。 renderGroup(true) 是一个通用属性:开启后,当前组件及其子组件组成的子树会先在离屏画布渲染并缓存结果,后续重绘优先复用缓存。如果你的画布周围有固定不变的内容(标题、图标、静态装饰),不开 RenderGroup 就等于每次都把它们和画布一起重新合成,白白浪费。注意:RenderGroup 适合“内容固定、子组件无独立动画”的场景;如果内部频繁变化,缓存频繁失效反而会变慢。

把上述六点放在一张图上理解,它们最终都汇聚成两个放大器:GC 压力与过度绘制(overdraw)。每帧 new 大量临时对象,会让 GC 在渲染关键路径上周期性地“卡一下”,表现为偶发的长帧;而全量重绘 + 大量图元,则让 GPU 在每个像素上反复覆盖绘制,填充率(fillrate)被无意义地吃满。掉到个位数 fps,通常是“单帧指令量 × 单指令成本 × GC 抖动”三者叠加的结果,而不是某一个孤立原因。因此优化也必须从“减少每帧指令量”(脏矩形/离屏)和“降低单指令成本”(复用对象/降采样)两端同时下手,再顺手把节奏(displaySync)对齐,才能稳定回到可用的帧率区间。

排查方法论:先定位“卡在哪一环”

拿到“掉帧到个位数”的现场,不要上来就改代码,先按工具链定位瓶颈在哪一层:

  1. DevEco Studio Profiler —— Frame / Launch 视图。 用 Profiler 抓一段 Frame Trace,看卡住的那几帧,UI 线程到底在干什么:是 JS 主线程被你的 draw 占满(CPU 侧瓶颈),还是 GPU/合成侧耗时高(绘制指令过多、过度绘制)。Canvas::DrawFrame 这一项的耗时直接反映你每帧画了多少东西。

  2. hitrace / SmartPerf-Host 抓帧。 系统级工具能看整条渲染流水线(应用 -> 渲染服务 -> 合成 -> 上屏)各阶段耗时,区分是“应用提交太慢”还是“系统合成太慢”。

  3. console 打点定位单帧耗时。 在 draw 函数首尾用 console.time / console.timeEnd 包裹,或输出 Date.now() 差值,快速判断单帧绘制本身是否超标(>16.6ms 就意味着稳不住 60fps)。

  4. @ohos.hiTraceMeter(hiTraceMeter.startTrace / finishTrace) 做更规范的打点,配合 Systrace/hitrace 在系统视图里看到你这段绘制的时间区间,便于和别的应用线程对齐分析。

一个实用的排查顺序:先用 Profiler 看 Canvas::DrawFrame 是否超标 → 超标的化用 console/hiTraceMeter 把 draw 内部各段(构造对象 / 路径 / 文本 / 清屏)拆开计时 → 确认是“全量重绘”还是“对象重复创建” → 最后再回头看刷新调度(是否每帧全量重发指令、是否有合并丢帧)。

具体到 DevEco Studio Profiler 的实操:新建一个 Frame 录制会话,复现掉帧操作(例如快速缩放/滚动波形),停止录制后在时间轴上选中一段明显卡顿的区间。重点看两栏:一是 Actual Timeline(实际帧时间线),正常应贴着 16.6ms(60fps)或 8.3ms(120fps)的参考线,偏离越大说明掉得越狠;二是 CPU 调用栈 / 函数耗时,顺着调用栈找到你的 draw / onFrame 函数,看它内部是卡在“指令下发(如循环里的 stroke)”还是“对象创建(如每帧 new 渐变)”。如果 Canvas::DrawFrame 单项就占了十几毫秒以上,基本可以断定瓶颈在“你每帧画了太多东西”,接下来用 console 打点把 draw 内部再切成“构造对象 / 路径 / 文本 / 清屏”四段,哪段最肥就先优化哪段。若 Profiler 显示 UI 线程并不忙、耗时集中在合成/上屏,则更可能是过度绘制或离屏合成失效,此时应检查 RenderGroup、图层数量与混合模式。

优化手段:从“重绘范围”和“帧节奏”两头发力

1. 脏矩形局部重绘。 维护一个脏区域集合,每次只 clearRect 发生变化的最小矩形并重新绘制那一块,而不是整块画布全量重绘。固定 item 高度(或固定列桶)能让脏矩形的定位变成 O(1) 的乘法,是高频列表/波形类组件的基础优化。

2. OffscreenCanvas / 离屏缓存静态图层。 把“相对静态”的内容(背景网格、刻度、波形主体)预渲染到一张离屏画布(或缓存为 PixelMap),之后每帧只 drawImage 这张缓存图,动态内容(指针、实时尾部)叠在上面单独重绘。这样 2000 点的重绘成本从“每帧逐点重画”降为“每帧一次贴图”,通常可显著降低单帧耗时。静态内容只在数据/视口变化时重渲染一次即可。

3. RenderGroup(true) 缓存静态子树。 对画布周围固定不变的内容开启 renderGroup(true),让系统把子树离线合成并缓存,减少重复合成负载。

4. 路径与样式对象复用。 渐变、固定 Path2D、字体与颜色在初始化阶段构造一次;draw 里只引用,不重建。把对象分配移出渲染关键路径。

5. 用 displaySync / AnimatorResult 驱动逐帧渲染,替代 setInterval。 这是高频动画最关键的取舍:

  • setInterval(fn, 16) 跑在 JS 定时器队列里,和屏幕 Vsync 没有对齐关系,回调时机漂移、容易一帧内多次触发或跨帧空转,且页面隐藏时仍在跑、空耗 CPU;在重载场景里它是掉帧重灾区。

  • displaySync(来自 @kit.ArkGraphics2D)的 on('frame') 回调与屏幕刷新率对齐,还能通过 setExpectedFrameRateRange 设置期望/最小/最大帧率,把动画节奏交给系统调度,单帧抖动明显更小。

  • 对属性动画类需求,也可以用 animateTo / animator(AnimatorResult)驱动,让系统托管插值,而非手动逐帧算。

  • 生命周期约束:页面切到后台、组件被销毁或 Canvas 不可见时,务必调用 displaySync.stop() / clearInterval 停止帧回调。官方提醒过,隐藏的 Canvas 仍会持续累积绘制指令,回调若不停,不仅空耗 CPU,还会让指令队列越积越多,等页面再次可见时一次性爆发式重绘,反而造成更严重的卡顿。换句话说,“帧回调要在可见时才跑”是一条硬性纪律。

6. 降采样(按像素列聚合)。 横向屏幕一个像素列对应多个数据点时,按列桶取这一桶的最小值/最大值画包络,而不是每点都画一条线。这一步要在可见区间和缩放变化时重新计算,并注意缓存失效。需要强调一个常见算法坑:不能简单地“每列取一个代表点”。比如 step = floor(数据量 / 画布宽),再 idx = x * step 抽一个点——这种写法即使能整除,也只会取列内某一个瞬时值,列内若有比它更高的尖峰就会被整个漏掉,视觉上波形“缺剌”。正确的做法是:先按当前缩放/滚动算出可见采样区间 [start, end],再把区间切成不重叠且完整覆盖的列桶,第 x 列对应索引区间 [floor(x*N/W), floor((x+1)*N/W)),对桶内所有点取最小/最大值,画成一条上下包络线,这样桶内的峰值范围才得以保留。预聚合结果可以缓存,但数据或可见范围变化时必须显式失效重算,否则会画出过期波形。

易错点正误对比(表格)

下表把高频踩坑点做成正误对照,发布前自检一遍即可避免大半问题:

易错点❌ 错误做法✅ 正确做法说明
重绘触发调用 ctx.invalidate()(以为 CanvasRenderingContext2D 有此方法)用 @State+@Watch 或帧回调重新执行 draw;若用 DrawingRenderingContext 才调其 invalidate()CanvasRenderingContext2D 与声明式 Canvas 组件都没有 invalidate()
首帧绘制在 aboutToAppear / 异步回调里提前绘制在 onReady 里开始首帧绘制onReady 才代表尺寸就绪、可安全绘制,且只触发一次
绘制放置全部绘制逻辑只写在 onReady 内首帧在 onReady,后续重绘放在独立 draw() 由 @Watch/帧回调调用onReady 不重复触发,后续变化需主动重发指令
清屏语义直接重画不清屏,导致重影每帧先 clearRect(必要时先 resetTransform);高频重绘配合 reset() 清指令缓存clearRect 清像素,reset() 还会清历史指令缓存,二者侧重点不同
重绘范围每次 clearRect 全画布后全量重画维护脏矩形,只重绘变化区域全量重绘是掉帧第一大元凶
对象创建draw 内每帧 new LinearGradient / Path2D静态对象初始化构造一次并复用避免每帧分配与 GPU 上传开销
文本绘制每个数据点都 fillText 标签降采样后稀疏绘制 / 离屏预渲染文本文本绘制远贵于图形绘制
帧驱动setInterval(fn,16) 手动逐帧displaySync 对齐 Vsync,或 animateTo/AnimatorResultsetInterval 与屏幕刷新不对齐,是掉帧重灾区
静态子树关闭 renderGroup对固定内容开启 renderGroup(true)离屏缓存子树,减少重复合成(内容频繁变化时不适用)
状态驱动改普通成员变量指望刷新驱动重绘的变量用 @State 并配合 @Watch普通成员不参与响应式,UI 不感知

性能数据对照(以下为示意数据,非实测结论,用于建立量级直觉):

优化项示意前(全量/误用)示意后(优化)备注
2000 点每帧重绘单帧 ~80ms(个位数 fps)脏矩形只重绘变更行:单帧通常可降到很低量级取决于脏区域占比,非固定值
帧驱动方式setInterval(16) 抖动大、隐藏页仍跑displaySync 对齐 Vsync,单帧抖动通常更小节奏由系统调度
渐变/路径对象每帧 new 一次初始化构造一次复用减少 GC 与上传
静态子树合成未开 RenderGroup开启后优先复用缓存内容变化时缓存失效,需权衡
文本标签每点一个 fillText按像素列稀疏绘制文本是重绘成本大户

示例代码

下面两份示例均可直接编译(CanvasRenderingContext2D、RenderingContextSettings、Path2D 等来自 @kit.ArkUI,无需额外 import;displaySync 使用 @kit.ArkGraphics2D)。

读这两份示例时,建议带着两个对照维度:第一份解决“重绘范围”问题(怎样少画),第二份解决“帧节奏”问题(怎样画得稳)。它们解决的是不同维度,实战中通常要组合起来用——先用脏矩形把每帧要画的东西压到最小,再用 displaySync 把节奏对齐到屏幕刷新率。示例里刻意保留了大量中文注释,方便你直接拷进工程改参数验证。

示例一:固定 item 高度 + 脏矩形局部重绘的 Canvas 高频重绘

要点:把绘制拆成“首帧全量”和“后续只重绘脏行”;变化只标记对应行号为脏,驱动 @Watch 重新执行 draw,draw 内只 clearRect 脏行区域并重画该行。

// 示例一:固定 item 高度 + 脏矩形局部重绘的 Canvas 高频重绘
// CanvasRenderingContext2D / RenderingContextSettings 来自 @kit.ArkUI(无需显式 import)
@Entry
@Component
struct DirtyRectCanvas {
  private settings: RenderingContextSettings = new RenderingContextSettings(true);
  private ctx: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.settings);

  // 固定 item 高度与画布参数(真实项目从 onReady 读尺寸)
  private readonly ITEM_H: number = 40;
  private canvasW: number = 360;
  private canvasH: number = 480;

  // 业务数据(这里用数值模拟,真实场景可能是波形/列表)
  private values: number[] = new Array(12).fill(0).map((_, i) => i * 8);
  // 脏行集合:仅记录需要重绘的行号(普通成员变量,不参与 UI diff)
  private dirtyRows: Set<number> = new Set<number>();

  // 重绘令牌:@State 变化触发 @Watch 回调,从而驱动 drawDirty()
  @State @Watch('onRedraw') redrawToken: number = 0;

  onRedraw(): void {
    this.drawDirty();
  }

  // 仅清空并绘制若干脏行,而不是整块画布全量重绘
  private drawDirty(): void {
    if (this.dirtyRows.size === 0) {
      return;
    }
    for (const row of this.dirtyRows) {
      const y = row * this.ITEM_H;
      // 脏矩形:只清理这一行对应的区域
      this.ctx.clearRect(0, y, this.canvasW, this.ITEM_H);
      this.drawRow(row);
    }
    this.dirtyRows.clear();
  }

  // 绘制单行(固定高度,便于用脏矩形精确定位)
  private drawRow(row: number): void {
    const y = row * this.ITEM_H;
    const v = this.values[row];
    // 背景(隔行变色,纯示例)
    this.ctx.fillStyle = (row % 2 === 0) ? '#F5F7FA' : '#FFFFFF';
    this.ctx.fillRect(0, y, this.canvasW, this.ITEM_H);
    // 进度条
    this.ctx.fillStyle = '#3391FF';
    this.ctx.fillRect(12, y + 12, Math.min(v, this.canvasW - 24), 16);
    // 文本
    this.ctx.fillStyle = '#333333';
    this.ctx.font = '14px sans-serif';
    this.ctx.fillText(`Item ${row}: ${v}`, 12, y + 28);
  }

  // 首帧全量绘制
  private drawAll(): void {
    this.ctx.clearRect(0, 0, this.canvasW, this.canvasH);
    for (let r = 0; r < this.values.length; r++) {
      this.drawRow(r);
    }
  }

  build() {
    Column({ space: 12 }) {
      Canvas(this.ctx)
        .width(this.canvasW)
        .height(this.canvasH)
        .backgroundColor('#FFFFFF')
        .onReady(() => {
          // onReady 是“可以开始绘制”的起点,此时才能拿到可靠尺寸
          this.drawAll();
        })
      Row({ space: 12 }) {
        Button('随机改 1 行')
          .onClick(() => {
            const row = Math.floor(Math.random() * this.values.length);
            this.values[row] = Math.floor(Math.random() * (this.canvasW - 24));
            this.dirtyRows.add(row); // 只标记这一行脏
            this.redrawToken++;      // 触发 @Watch -> drawDirty,只重绘脏行
          })
        Button('随机改 5 行')
          .onClick(() => {
            for (let i = 0; i < 5; i++) {
              const row = Math.floor(Math.random() * this.values.length);
              this.values[row] = Math.floor(Math.random() * (this.canvasW - 24));
              this.dirtyRows.add(row);
            }
            this.redrawToken++;
          })
      }
    }
    .padding(16)
    .width('100%')
  }
}

示例一里有几个刻意的设计决定,值得点明:其一,固定 item 高度(或固定列桶宽度)是脏矩形能高效落地的关键——因为行号到 y 坐标只是一句乘法 row * ITEM_H,脏区域的定位是 O(1),不依赖任何布局测量;如果你的 item 高度不固定,就需要额外维护一棵“偏移量索引”来定位脏区,复杂度会上去。其二,dirtyRows 用的是普通 Set 成员而非 @State,因为它只服务于 draw 内部的“本次要画哪几行”的记账,不需要触发 UI diff;真正参与响应式、用来“敲门”的是 redrawToken 这个 @State——它变化触发 @Watch('onRedraw'),进而调用 drawDirty()。其三是首帧在 onReady 全量画、后续只画脏行的两段式结构,避免把全量重绘塞进每一帧。把这套结构迁移到波形/列表场景时,把“行”换成“可见数据区间”即可。

示例二:用 displaySync 做与屏幕刷新率对齐的逐帧渲染,并与 setInterval 对比

要点:displaySync.create() 后通过 setExpectedFrameRateRange 设置期望帧率,on('frame') 注册每帧回调,start() 必须在 UI 上下文中执行(用 getUIContext().runScopedTask 包一层)。反例 setInterval 仅用于对比,真实项目不推荐。

// 示例二:用 displaySync 做与屏幕刷新率对齐的逐帧渲染,并与 setInterval 对比
import { displaySync } from '@kit.ArkGraphics2D';

@Entry
@Component
struct DisplaySyncCanvas {
  private settings: RenderingContextSettings = new RenderingContextSettings(true);
  private ctx: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.settings);
  private canvasW: number = 360;
  private canvasH: number = 200;

  // 指针位置(0~1),由帧回调更新
  private progress: number = 0;

  // DisplaySync 实例:用于按屏幕刷新节奏驱动逐帧回调
  private displaySyncInst: displaySync.DisplaySync = displaySync.create();
  // 对比用的定时器句柄
  private timerId: number = -1;

  aboutToAppear(): void {
    const range: displaySync.ExpectedFrameRateRange = {
      expected: 60, // 期望帧率
      min: 30,      // 最小帧率
      max: 120      // 最大帧率
    };
    this.displaySyncInst.setExpectedFrameRateRange(range);
    this.displaySyncInst.on('frame', (frameInfo: displaySync.IntervalInfo) => {
      // 每帧回调:与 Vsync 对齐,这里只消费“最新”的进度,不补画历史帧
      this.progress += 0.005;
      if (this.progress > 1) {
        this.progress = 0;
      }
      this.drawFrame();
      // 如需打点单帧耗时(示意,需引入 hiTraceMeter):
      // hiTraceMeter.startTrace('drawFrame', 1);
      // ... 绘制 ...
      // hiTraceMeter.finishTrace('drawFrame', 1);
    });
  }

  private drawFrame(): void {
    this.ctx.clearRect(0, 0, this.canvasW, this.canvasH);
    // 背景刻度线
    this.ctx.fillStyle = '#F0F2F5';
    this.ctx.fillRect(0, this.canvasH / 2 - 2, this.canvasW, 4);
    // 指针
    const x = this.progress * this.canvasW;
    this.ctx.fillStyle = '#FF4D4F';
    this.ctx.fillRect(x - 1, 0, 2, this.canvasH);
  }

  // 启动:必须用 UI 上下文执行 start,否则在非 UI 异步回调中可能拿不到上下文
  private startDisplaySync(): void {
    const uiContext = this.getUIContext();
    uiContext?.runScopedTask(() => {
      this.displaySyncInst.start();
    });
  }

  // 反例:用 setInterval 驱动逐帧绘制(掉帧重灾区,仅用于对比说明,不推荐)
  private startSetInterval(): void {
    this.timerId = setInterval(() => {
      this.progress += 0.005;
      if (this.progress > 1) {
        this.progress = 0;
      }
      this.drawFrame();
    }, 16);
  }

  build() {
    Column({ space: 12 }) {
      Canvas(this.ctx)
        .width(this.canvasW)
        .height(this.canvasH)
        .backgroundColor('#FFFFFF')
        .onReady(() => {
          this.drawFrame();
        })
      Row({ space: 12 }) {
        Button('displaySync 启动').onClick(() => this.startDisplaySync());
        Button('displaySync 停止')
          .onClick(() => this.displaySyncInst.stop());
        Button('setInterval 启动').onClick(() => this.startSetInterval());
        Button('setInterval 停止')
          .onClick(() => {
            if (this.timerId >= 0) {
              clearInterval(this.timerId);
              this.timerId = -1;
            }
          });
      }
    }
    .padding(16)
    .width('100%')
  }
}

补充说明:示例二中 displaySync 的 start() 会将对象关联到当前 UI 上下文与窗口。若在 aboutToAppear 等尚未完全绑定 UI 的时机或异步回调里直接 start(),可能拿不到正确上下文导致回调不执行、期望帧率不生效。因此推荐像示例那样用 this.getUIContext()?.runScopedTask(() => { ... }) 明确 UI 上下文。setExpectedFrameRateRange 的 expected/min/max 取值按你的业务与设备刷新率合理设置,例如希望把重载动画限在 30fps 以省电时,把 expected 设成 30。

关于离屏缓存(静态图层)的落地思路:你可以 new OffscreenCanvas(w, h) 取得离屏上下文并在其上预渲染背景/网格/波形主体,主画布每帧用 drawImage 把这张离屏图贴上去,动态指针再单独叠加。这样把“逐点重画 2000 点”降为“每帧一次贴图”,是复杂场景下的关键优化之一。具体离屏上下文类型与 drawImage 的源类型请以当前 API 版本的官方文档为准。

总结

把上面所有内容收敛成一份可直接照做的排查顺序清单和优化优先级:

排查顺序清单(先查刷新调度,再查单帧耗时,最后查重绘范围):

  1. 先确认绘制入口:onReady 是否作为首帧起点?后续重绘是否由 @State/@Watch 或帧回调驱动?是否误以为 CanvasRenderingContext2D 有 invalidate()(它没有)?
  2. 确认状态驱动正确:驱动重绘的变量是不是 @State?改的是不是普通成员导致 UI 不感知?是否在非 UI 上下文改了状态却没走响应式通道?
  3. 用 DevEco Profiler 的 Frame 视图看 Canvas::DrawFrame 单帧耗时是否超标(>16.6ms 稳不住 60fps,>33ms 稳不住 30fps)。
  4. 用 console / hiTraceMeter 把 draw 内部各段(对象创建、路径、文本、清屏)拆开计时,定位具体瓶颈。
  5. 最后查重绘范围:是不是每次都全量 clearRect + 全量重画?有没有做脏矩形?静态子树有没有开 RenderGroup?有没有把同步 IO / 大对象分配留在 draw 里?

优化优先级(投入产出比从高到低):

  • P0 —— 重绘范围:脏矩形局部重绘、离屏缓存静态图层。这是把“个位数 fps”拉回可用区间最有效的手段。
  • P0 —— 帧节奏:用 displaySync(或 animateTo/AnimatorResult)替换 setInterval 手动逐帧,对齐 Vsync、消除抖动。
  • P1 —— 对象复用:渐变、固定 Path2D、字体颜色初始化构造一次,移出 draw 关键路径。
  • P1 —— 清屏语义:每帧 clearRect,高频重绘配合 reset() 清历史指令缓存,消除重影与指令累积。
  • P2 —— 静态合成:对固定内容开启 RenderGroup(true),减少重复合成。
  • P2 —— 降采样:按像素列聚合(取列桶最值画包络),减少不可见细节的绘制量。

补一个架构决策问题:什么时候该升级到 DrawingRenderingContext? 本文示例与绝大多数优化都以 CanvasRenderingContext2D 为基准,因为它对 90% 的业务绘制已经足够,且心智模型统一。但如果你做了脏矩形、离屏缓存、displaySync 对齐之后,单帧耗时仍卡在高位,且 Profiler 明确显示瓶颈在“绘制指令本身的封装开销”(即 ArkUI 对 Native drawing 的多层封装),那就该考虑切换到 DrawingRenderingContext + 系统 drawing 的 Brush/Pen/Canvas 直接绘制——这是官方推荐的极致性能路径,能绕开中间封装层。切换时要同步把刷新逻辑改为它的 invalidate() 语义,并重新评估清屏方式(用 canvas.clear(color) 而非 clearRect)。换言之,先在不换上下文的前提下把“范围”和“节奏”优化到位,仍不够时再换底层上下文,避免过早优化带来的复杂度成本。

最后再强调一次本文最关键的认知:CanvasRenderingContext2D 没有 invalidate(),invalidate() 只属于 DrawingRenderingContext;声明式 Canvas 组件本身也没有 invalidate() 方法。 ArkUI 下 CanvasRenderingContext2D 的“重绘”,本质是“让 draw 函数被重新执行、重新下发绘制指令”,驱动力来自响应式状态或帧回调。把这条机制想通,前面那些“偶发不刷新”和“掉帧到个位数”的问题,就会从玄学变成可定位、可优化的工程问题。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐