HarmonyOS 7 + ArkUI Canvas-Window Kit:折叠切换中的绘图缓冲重建与旧帧拒绝【鸿蒙心迹】
折叠屏上的图表问题往往不是“布局没有适配”,而是布局已经变宽,Canvas 仍在提交上一形态的帧。典型表现很短暂:展开后先闪过一张被横向拉伸的旧图,接着出现空白,再恢复正常;快速连续折叠时,旧帧甚至会覆盖已经完成的新布局。
本文用 FoldChart 示例拆开这条时序。页面名是 ChartSurfacePage,任务编号是 FOLD-CANVAS-0082,数据量 240 点,选中点为 pt-173,视口固定在 x=120~199。紧凑态窗口示例值为 672×1260 px,展开态为 1180×820 px;Canvas 的绘图区域从 624×1016 变为 1132×620。连续收到 7 次窗口尺寸事件后,页面只提交 1 次缓冲重建,布局代际从 41 进入 42,并拒绝 2 个仍属于代际 41 的旧帧。
这些数值用于说明设计和验收口径,不声称来自某款量产设备的实测。不同设备、窗口模式、显示密度和系统版本都会改变尺寸与时序,项目中必须以实际 windowRect 和组件测量结果为准。

一、空白帧不是一个问题,而是三条时间线撞在一起
把问题只归结为 Canvas 尺寸不对,会漏掉另外两条时间线。
第一条是窗口时间线。折叠动作并不保证只产生一次稳定尺寸事件,系统可能在过渡过程中连续报告中间值。示例里共收到 7 次 windowSizeChange。如果每次都立即清空并重画,用户看到的不是连续动画,而是七次昂贵的缓冲重建。
第二条是布局时间线。Window Kit 告诉应用窗口矩形已经变化,不代表 ArkUI 子树在同一时刻完成最终布局。直接用窗口宽高覆盖 Canvas 绘图尺寸,会忽略安全区域、标题栏、左右边距和详情面板占位。窗口 1180×820 px,最终绘图区域却是 1132×620,二者不能互换。
第三条是绘制时间线。图表路径计算、标签抽稀和离屏数据准备可能异步执行。代际 41 的绘制任务在展开后才完成,如果没有提交校验,它会把旧尺寸帧写到代际 42 的画布上。此时即使尺寸读取完全正确,仍然会出现回退。
所以修复目标不是“监听尺寸变化后调用 redraw”,而是建立一个小型事务:先观察窗口趋稳,再让 ArkUI 完成新布局;确认 Canvas 新尺寸后创建新代际;恢复视口和选择态;最后只允许当前代际的绘制结果提交。
二、窗口事件只负责提出候选尺寸
示例在 UIAbility 获得主窗口后注册 windowSizeChange。监听函数不重画,只把最新矩形写入一个稳定器。连续 120 ms 没有新事件时,稳定器才发布候选尺寸。这个等待值是 FoldChart 的演示参数,不是系统常量,应结合交互延迟和设备事件密度调整。
这段代码解决什么问题:把高频窗口事件合并成一次稳定候选,并保留可成对注销的监听引用。
// entryability/EntryAbility.ets
import { UIAbility } from '@kit.AbilityKit'
import { window } from '@kit.ArkUI'
export default class EntryAbility extends UIAbility {
private mainWindow?: window.Window
private settleTimer: number = -1
private readonly onWindowSizeChange = (data: window.Size): void => {
AppStorage.setOrCreate<number>('candidateWidthPx', data.width)
AppStorage.setOrCreate<number>('candidateHeightPx', data.height)
if (this.settleTimer >= 0) clearTimeout(this.settleTimer)
this.settleTimer = setTimeout(() => {
AppStorage.setOrCreate<number>('windowCommitToken', Date.now())
}, 120)
}
onWindowStageCreate(stage: window.WindowStage): void {
stage.getMainWindow().then((win: window.Window) => {
this.mainWindow = win
const rect = win.getWindowProperties().windowRect
AppStorage.setOrCreate<number>('candidateWidthPx', rect.width)
AppStorage.setOrCreate<number>('candidateHeightPx', rect.height)
win.on('windowSizeChange', this.onWindowSizeChange)
})
}
onWindowStageDestroy(): void {
if (this.settleTimer >= 0) clearTimeout(this.settleTimer)
this.mainWindow?.off('windowSizeChange', this.onWindowSizeChange)
this.mainWindow = undefined
}
}
这里把同一个箭头函数保存为字段,是为了保证 on 和 off 使用相同引用。临时写两个内容相同的匿名函数,注销时并不是同一个监听器。页面销毁后若事件仍然进入旧对象,不仅会多次重建,还可能更新已经不可见的状态。
状态此时只从 STABLE_COMPACT 进入 MEASURING_EXPANDED。候选窗口尺寸进入 AppStorage,并不意味着 Canvas 尺寸已经确认。windowCommitToken 的作用是通知页面“可以开始一次布局提交”,而不是把时间戳当作业务 ID。生产代码可改用单调递增序号,避免同一毫秒多次写入时通知不变。
还有一个容易忽略的边界:分屏拖拽同样会触发窗口尺寸变化,这套机制不能只绑定“折叠态”概念。FoldChart 处理的是任何窗口几何变化;设备姿态只能作为诊断信息,不能成为是否重建的唯一条件。
三、窗口尺寸与绘图尺寸之间必须隔一层布局确认
ChartSurfacePage 使用 @StorageLink 观察提交令牌,触发 ArkUI 根据当前窗口重新排版。顶部工具栏、图例和右侧详情面板都可能改变 Canvas 的实际空间。只有 Canvas 的 onReady 回调和上下文尺寸反映新布局后,才进入 REBUILDING_BUFFER。
这段代码解决什么问题:在布局完成后读取真实绘图区域,创建新代际,并把旧上下文状态清理干净。
// pages/ChartSurfacePage.ets
@Entry
@Component
struct ChartSurfacePage {
private settings: RenderingContextSettings = new RenderingContextSettings(true)
private context: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.settings)
@StorageLink('windowCommitToken') windowCommitToken: number = 0
@State canvasWidth: number = 624
@State canvasHeight: number = 1016
@State layoutEpoch: number = 41
@State phase: string = 'STABLE_COMPACT'
private acceptCanvasLayout(): void {
const nextWidth = Math.round(this.context.width)
const nextHeight = Math.round(this.context.height)
if (nextWidth <= 0 || nextHeight <= 0) return
if (nextWidth === this.canvasWidth && nextHeight === this.canvasHeight) return
this.phase = 'REBUILDING_BUFFER'
this.layoutEpoch += 1
this.canvasWidth = nextWidth
this.canvasHeight = nextHeight
this.context.setTransform(1, 0, 0, 1, 0, 0)
this.context.clearRect(0, 0, nextWidth, nextHeight)
this.restoreViewportAndDraw(this.layoutEpoch)
}
build() {
Column() {
Text(`FoldChart · ${this.phase}`)
Canvas(this.context)
.width('100%')
.layoutWeight(1)
.onReady(() => this.acceptCanvasLayout())
}
.width('100%')
.height('100%')
}
}
这段示例强调的是提交顺序。setTransform(1, 0, 0, 1, 0, 0) 先归一化上下文,避免上一个形态留下缩放或平移矩阵。然后只清理新尺寸区域,再交给恢复函数绘制。若先恢复视口再递增代际,异步任务会携带旧版本号,提交判断就失去意义。
示例中的 canvasWidth 与 canvasHeight 是绘图坐标口径;窗口矩形则是 Window Kit 返回的像素口径。文章刻意分开写,防止把 1180×820 px 直接当成 Canvas 坐标。高密度屏幕上若要追求锐度,还需明确逻辑尺寸、显示密度和像素缓冲的换算策略,并在每次重建后重设线宽与字体,不要只放大路径。
图中的 DevEco Studio 场景展示同一份状态:左侧是 EntryAbility.ets、ChartSurfacePage.ets 和 ChartRenderer.ets;中间代码停在 layoutEpoch += 1;右侧模拟器显示展开态 FoldChart;底部日志写明 7 个事件合并为 1 次提交、代际 41→42、丢弃旧帧 2。它是技术演示配图,不等同于真实 IDE 截图或设备测量记录。

四、视口状态不能从旧像素位置恢复
很多“重建后图表跳回开头”的根因,是应用只保存了旧 Canvas 上的平移像素。紧凑态和展开态宽度不同,同一个 translateX=-936 并不代表相同的数据范围。真正需要保存的是数据空间状态:可见索引 120~199、选中点 pt-173、纵轴范围与缩放级别。
这段代码解决什么问题:用数据坐标保存视口快照,在新绘图区域中重新计算比例和选中点位置。
// model/ViewportState.ets
export interface ViewportSnapshot {
startIndex: number
endIndex: number
selectedId: string
yMin: number
yMax: number
}
const viewport: ViewportSnapshot = {
startIndex: 120,
endIndex: 199,
selectedId: 'pt-173',
yMin: 18,
yMax: 86
}
private restoreViewportAndDraw(epoch: number): void {
this.phase = 'RESTORING_VIEWPORT'
const plotLeft = 52
const plotRight = this.canvasWidth - 24
const count = viewport.endIndex - viewport.startIndex + 1
const stepX = (plotRight - plotLeft) / Math.max(1, count - 1)
const selectedIndex = 173 - viewport.startIndex
this.selectedX = plotLeft + selectedIndex * stepX
this.scheduleFrame(epoch, viewport)
}
视口恢复发生在新尺寸确认之后,因此 stepX 使用新绘图宽度计算。数据点总数仍是 240,可见范围仍是 80 个点,选中项仍是 pt-173;变化的只是屏幕坐标。这种分层也让旋转、分屏和自由窗口复用同一套逻辑。
selectedIndex 在示例里为了可读性直接使用 173。真实项目不能假设 ID 的数字部分等于数组下标,应该由稳定 ID 查找当前数据集位置,并处理选中数据已被删除或过滤的情况。恢复失败时,应清空选择态并记录原因,不能把旧索引落到另一条数据上。
页面展开后的运行图显示 1132×620、layoutEpoch=42、视口 120~199、选中点 pt-173,状态为 STABLE_EXPANDED。红色箭头指向代际和选中点,帮助读者核对“尺寸变了、数据语义没变”。状态栏统一为 15:21、Wi‑Fi、5G、信号和 79% 电量。

五、旧帧拒绝必须放在提交点,而不是任务起点
仅在启动绘制任务时检查代际是不够的。代际 41 的任务可能在检查时合法,执行期间窗口进入代际 42,最终仍把旧结果交回来。正确位置是“即将写入当前 Canvas”之前再次比较。
FoldChart 把路径准备结果封装为 PreparedFrame,其中包含启动时的 epoch、绘图尺寸和数据范围。准备阶段可以异步,真正的 Canvas API 调用留在 UI 线程的提交函数里。这样既避免后台任务直接持有 UI 上下文,也能集中执行最后一道代际门禁。
这段代码解决什么问题:在异步结果提交前校验布局代际与绘图尺寸,拒绝迟到的旧帧。
// render/ChartRenderer.ets
interface PreparedFrame {
epoch: number
width: number
height: number
points: number[]
}
private commitFrame(frame: PreparedFrame): void {
const staleEpoch = frame.epoch !== this.layoutEpoch
const staleSize = frame.width !== this.canvasWidth || frame.height !== this.canvasHeight
if (staleEpoch || staleSize) {
this.droppedFrames += 1
console.info(`[FoldChart] task=FOLD-CANVAS-0082 drop epoch=${frame.epoch}`)
return
}
this.context.save()
try {
this.context.clearRect(0, 0, frame.width, frame.height)
this.drawAxes(frame)
this.drawSeries(frame)
this.drawSelection('pt-173')
} finally {
this.context.restore()
}
this.phase = 'STABLE_EXPANDED'
console.info(`[FoldChart] epoch=${frame.epoch} commit=1 dropped=${this.droppedFrames}`)
}
代际和尺寸同时判断是有必要的。极端情况下,两次布局可能恰好得到同样尺寸,但数据范围、主题或密度配置已经变了;只比尺寸会放过语义过期帧。反过来,代际正确而尺寸不一致,说明布局确认与任务快照的边界仍有漏洞,也不应提交。
save() 与 restore() 必须成对放在 try/finally 中。绘制标签时若抛出异常,遗漏 restore() 会把裁剪区、透明度或变换矩阵泄漏到下一帧,形成很难与折叠动作关联的后续故障。页面销毁时也要取消仍可取消的计算任务;不能取消的任务至少会在提交门禁处被拒绝,并且不得继续持有页面引用。
示例诊断页把完整时序列出来:15:21:04.112 收到第一个变化事件,15:21:04.238 收到第七个事件,15:21:04.358 稳定窗口到期,15:21:04.371 创建代际 42,随后两个代际 41 帧被拒绝,15:21:04.389 只提交一次新帧。页面同时显示“空白帧 4→0”和“提交次数 1”。这些值是验收样例,真实项目应通过埋点和录屏确认。

六、调试时不要只盯着最终截图
最终截图正常,不代表切换过程没有错误。折叠适配需要观察事件密度、布局确认、代际创建和帧提交四个节点。建议每条日志都带 taskId、epoch、窗口尺寸、Canvas 尺寸、视口范围和耗时,才能把一次短促闪烁还原成时序。
FoldChart 的演示验收口径是:7 个窗口事件只产生 1 次稳定提交;布局代际 41→42;旧帧丢弃数为 2;240 点数据不重载;视口仍是 120~199;选中点仍是 pt-173;最终 Canvas 为 1132×620;诊断中的空白帧计数从基线样例 4 降为 0。
“丢弃旧帧 2”不是越少越好或越多越好,它证明门禁捕获了迟到结果。如果长期大量丢帧,说明准备任务过重或窗口稳定判断过早,应进一步减少重复计算,而不是把丢帧当成正常吞吐。
还要把冷启动与运行中切换分开。冷启动时 context.width 可能尚未稳定,onReady 应允许首次建立代际;运行中切换则需要保留视口快照。两者若共用“尺寸不变就直接返回”的分支,要确保首次绘制不会被意外跳过。
七、适配边界:什么时候不该重建
不是每次 windowSizeChange 都值得重建。如果变化只影响窗口位置而不改变绘图区域,Canvas 无需清空;如果宽高变化低于项目设定的容差,并且布局结果保持不变,也可以复用当前代际。但容差必须建立在实际视觉误差上,不能为了减少重建随意吞掉尺寸变化。
当应用进入后台或页面不可见时,可以记录最新候选尺寸,延迟到恢复可见后再构建,避免后台连续绘制。恢复时不要重放每个历史事件,只使用最后一个稳定快照。多窗口场景还要让状态按窗口实例隔离,不能让一个全局 layoutEpoch 同时管理两个可见窗口。
图表若包含图片纹理、渐变或复杂 Path2D 缓存,尺寸变化后要逐项确认哪些资源可以复用。数据模型通常可复用,屏幕空间路径、裁剪区、文本测量和像素缓冲通常需要重建。把它们混在一个“大缓存”里,会导致要么全部重算,要么错误复用。
最后是版本边界。本文只使用 Window Kit 的窗口矩形与尺寸变化监听,以及 ArkUI Canvas 的上下文、onReady 和标准绘图能力。事件名称、导入方式和可用范围应以项目当前 SDK 的官方文档与类型提示为准。应用层的 120 ms 稳定器、layoutEpoch 和旧帧拒绝协议是工程设计,不是系统提供的自动事务。
八、把结果写成可以复查的结论
FoldChart 的关键变化不是把一行 redraw() 换成更多代码,而是明确了所有权:Window Kit 只提供窗口事实;ArkUI 布局决定真实绘图区域;视口模型保存数据语义;渲染器准备帧;代际门禁决定谁能提交。
这套分工让问题具备可验证结果。窗口从 672×1260 px 进入 1180×820 px 时,Canvas 从 624×1016 变为 1132×620;7 个事件合并为 1 次提交;代际 41 的两个迟到帧被拒绝;视口与 pt-173 保持不变。即使未来换成分屏拖拽或横竖屏切换,判断标准仍然成立。
真正上线前,应在目标设备矩阵上分别覆盖慢速折叠、快速往返、后台恢复、分屏拖拽和数据更新与折叠同时发生。只有这些场景都通过,才能把“最终画面正确”提升为“整个转换过程可控”。
九、测试矩阵要同时覆盖空间和时间
普通 UI 测试习惯在布局稳定后截图比对,这对 Canvas 折叠问题不够。缺陷恰好发生在两个稳定画面之间,最终截图完全正确,用户仍可能看见一帧旧图或短暂空白。因此测试矩阵必须包含空间断言和时间断言。
空间断言检查新形态的几何事实:窗口矩形、Canvas 绘图区、图例位置、轴标签裁剪区和选中点坐标是否匹配。FoldChart 的样例要求展开后窗口为 1180×820 px,Canvas 为 1132×620,视口为 120~199,选中点仍是 pt-173。如果只检查宽高而不检查视口,图表跳回第一个数据点也可能被误判为通过。
时间断言则检查过程:七个候选尺寸事件是否只产生一次布局提交;代际 42 建立后,代际 41 的结果是否全部拒绝;当前代际是否只提交一次完整帧;状态是否按 MEASURING_EXPANDED → REBUILDING_BUFFER → RESTORING_VIEWPORT → STABLE_EXPANDED 前进。状态若出现倒退,例如从 RESTORING_VIEWPORT 回到 MEASURING_EXPANDED,说明稳定窗口内又收到了新事件,应取消当前提交而不是强行完成。
自动化测试可以注入一个可控渲染器,让代际 41 的任务故意延迟到代际 42 之后返回。预期 droppedFrames 增加,Canvas 提交次数不变。另一个用例让当前代际任务抛出异常,验证 restore() 仍然执行、页面进入可重试状态,并且下一帧的变换矩阵没有被污染。
录屏仍然有价值。可以用高帧率录屏逐帧检查空白和拉伸,但它应与结构化日志配对。仅凭肉眼看到“没有闪”很难覆盖快速往返和低性能设备;仅凭日志也无法证明最终视觉正确。两者结合,才能把“空白帧 4→0”从一句描述变成可复查的验收结果。
十、性能优化不能破坏事务边界
Canvas 重建后,最自然的优化是缓存路径和标签测量结果。但缓存键必须包含会影响屏幕空间的全部因素:布局代际、绘图区尺寸、显示密度、字体尺度、数据范围和主题。只用数据版本做键,会在数据未变而窗口已变时复用错误路径。
更稳妥的分层是把原始数据、数据空间简化结果和屏幕空间路径分开。240 个数据点及其业务 ID 可以跨形态保留;按视口抽稀后的数据也可能复用;由 stepX、纵轴比例和字体测量生成的屏幕坐标必须随代际失效。这样既不会每次重新加载数据,也不会让旧像素几何混入新布局。
绘制耗时应拆成准备和提交两段。准备阶段可计算可见点、标签集合和路径数组,提交阶段只做 Canvas 状态设置与绘制。若日志只记录总耗时,无法判断卡顿来自数据计算还是 UI 线程绘制。FoldChart 的诊断页不把某个毫秒数包装成普遍基准,而是重点展示提交次数和旧帧拒绝数,因为它们更能验证事务是否成立。
稳定窗口也不是越长越省性能。等待太短会重复重建,等待太长会让展开后长时间保持旧画面。可以记录事件间隔分布,在目标设备上选择覆盖大多数过渡事件、又不明显增加感知延迟的值。120 ms 只是本文的可读示例,项目应通过数据决定,并为拖拽窗口这类持续变化场景设计不同策略。
如果产品需要在拖拽过程中实时预览,可以使用低成本临时帧:减少标签、降低采样密度或暂时隐藏阴影;稳定后再提交完整帧。但临时帧也必须带代际,不能绕过旧帧门禁。优化策略可以降低质量,不能改变“只有当前代际能提交”的不变量。
十一、无障碍与交互状态也属于恢复范围
图表通常不只有像素。选中点可能关联无障碍描述、详情面板、触摸命中区和键盘焦点。Canvas 重建后如果只恢复视觉高亮,却没有同步这些状态,读屏用户听到的仍是旧数据,触摸也可能命中旧坐标。
因此,ViewportSnapshot 最好只保存稳定业务标识,例如 selectedId='pt-173',不要保存旧的 selectedX。新布局完成后,用业务 ID 在当前数据集中查找位置,重新计算视觉坐标、命中区域和无障碍文本。详情面板若在展开态从底部移动到右侧,也应保持同一个选中数据,而不是重新创建默认选择。
手势进行到一半时发生折叠更棘手。旧形态中的拖拽起点不能直接带到新坐标系。比较安全的策略是取消当前手势,保留折叠前已经确认的视口快照,待新代际稳定后重新接受输入。若产品坚持连续手势,就需要把指针位置转换到数据空间,再映射到新布局;这会显著增加复杂度,必须有专门测试。
焦点恢复同样要有限度。若用户焦点位于折叠后仍存在的“查看详情”按钮,可以按稳定 ID 恢复;若控件在新形态中被合并或隐藏,应把焦点移动到语义上最近的容器,而不是创建一个不可见焦点。图形缓冲事务和交互恢复事务可以共享 layoutEpoch,但应分别记录结果,避免视觉成功掩盖交互失败。
十二、参考资料
- 华为开发者联盟:多窗口适配指南,https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/multi-window-adaptation
- 华为开发者联盟:多设备自适应布局,https://developer.huawei.com/consumer/cn/multidevice/adaptive-apps/
- 华为开发者联盟:ArkTS Canvas 绘制指南,https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/arkts-drawing-customization-on-canvas
- 华为开发者联盟:Window Kit 窗口开发指导,https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/window-overview
更多推荐



所有评论(0)