折叠屏上的图表问题往往不是“布局没有适配”,而是布局已经变宽,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
Logo

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

更多推荐