折叠屏适配里,最容易被低估的不是“有没有监听窗口变化”,而是同一轮形态切换会不会把页面推入多次、过时、甚至相互覆盖的布局提交。下面用一个可复现的演示工程 WindowPulse Lab 拆开这个问题。文中的次数与耗时均为演示数据,不冒充真实设备实测;真正上线前仍要在目标机型、分屏、自由窗口和折叠状态下重新采样。

一、异常不在回调,而在回调之后

官方多窗口适配文档给出的主线很清楚:从主窗口读取初始 windowRect,通过 on('windowSizeChange') 接收变化,再把宽高写入状态,让 UI 根据最新窗口尺寸重排。这个方向没有问题。麻烦出现在工程代码把“收到事件”直接等同于“立刻提交完整布局”。折叠、旋转或拖动自由窗口时,一次用户动作可能伴随一串尺寸事件;Ability 重建、页面热切换或重复初始化又可能让旧监听继续存在。

WindowPulse Lab 的演示任务编号固定为 LAYOUT-1042。页面名叫“窗口收敛诊断”,状态流转是 LISTENING → COALESCING → APPLIED。演示序列从 720×1280 px 过渡到 1440×1280 px,输入 6 个回调,最终只允许 2 次布局提交。这里的“2 次”不是通用性能指标,而是为了展示“中间态可以观察,稳定态才进入昂贵重排”的策略。

先区分三个概念。

第一,窗口尺寸是布局真值。设备是否折叠只能描述物理形态,不能替代当前应用窗口的真实可用空间。应用可能仍处在分屏、自由窗口或浮窗中,所以断点选择最终要看窗口宽度。

第二,形态事件可以作为“即将变化”的提示,却不应在回调里同时释放资源、重建页面和改写路由。把多个副作用塞进回调,会让相机、画布、列表和导航分别以不同节奏响应。

第三,订阅必须成对出现。匿名函数看起来短,但很难在 Ability 销毁时精确移除;如果重新进入页面又注册一次,日志里的每个尺寸会出现两遍,随后是四遍。

华为多设备适配入口也强调,横竖屏、全屏和窗口状态切换后应按窗口变化及时重排并恢复状态;模拟器与远程真机用于验证不同设备形态。本文据此只使用公开的 Window Kit 窗口尺寸监听能力,不虚构“自动识别最佳布局”的接口。

二、先把监听的所有权放回 Ability

这段代码解决什么问题:用稳定的回调引用完成注册与反注册,避免 WindowStage 重建后旧监听残留。

import { UIAbility } from '@kit.AbilityKit';
import { window } from '@kit.ArkUI';

export default class EntryAbility extends UIAbility {
  private mainWindow?: window.Window;
  private readonly onWindowSizeChange = (size: window.Size): void => {
    AppStorage.setOrCreate('rawWindowWidth', size.width);
    AppStorage.setOrCreate('rawWindowHeight', size.height);
    AppStorage.setOrCreate('rawWindowAt', Date.now());
  };

  onWindowStageCreate(stage: window.WindowStage): void {
    stage.getMainWindow().then((mainWindow: window.Window) => {
      this.mainWindow = mainWindow;
      const rect = mainWindow.getWindowProperties().windowRect;
      AppStorage.setOrCreate('rawWindowWidth', rect.width);
      AppStorage.setOrCreate('rawWindowHeight', rect.height);
      AppStorage.setOrCreate('rawWindowAt', Date.now());
      mainWindow.on('windowSizeChange', this.onWindowSizeChange);
    });
    stage.loadContent('pages/Index');
  }

  onWindowStageDestroy(): void {
    this.mainWindow?.off('windowSizeChange');
    this.mainWindow = undefined;
  }
}

这里把监听所有权放在 EntryAbility,是因为主窗口对象也在这个生命周期内取得。回调只采集原始宽高与时间戳,不直接操作页面节点。AppStorage.setOrCreate() 让首帧和后续事件走同一条数据通道,页面不需要再猜“初始化值来自哪里”。

示例在销毁时使用 off('windowSizeChange') 移除该类事件的监听。实际项目如果同一个窗口有多个模块共同订阅,不要让任一模块粗暴清空别人的监听;应按所用 SDK 版本核对对应重载,或集中到一个窗口事件总线统一分发。这里的关键不是某种封装写法,而是注册和释放必须由同一个所有者控制。

最容易犯的错有两个:一是在 onWindowStageCreate() 之外又让页面自行注册一次;二是只清理定时器,却忘了窗口监听仍会在后台继续写状态。前者产生重复提交,后者让已离开的页面被动保活。

三、把“收到尺寸”改成“提交稳定快照”

这段代码解决什么问题:把短时间内的尺寸抖动合并成稳定快照,并用序号阻止旧任务覆盖新尺寸。

type LayoutMode = 'COMPACT' | 'MEDIUM' | 'EXPANDED';

interface LayoutSnapshot {
  seq: number;
  widthPx: number;
  heightPx: number;
  mode: LayoutMode;
  state: 'COALESCING' | 'APPLIED';
}

export class WindowCommitter {
  private timer: number = -1;
  private seq: number = 0;
  private appliedSeq: number = 0;

  submit(widthPx: number, heightPx: number,
    apply: (snapshot: LayoutSnapshot) => void): void {
    const currentSeq = ++this.seq;
    AppStorage.setOrCreate('layoutState', 'COALESCING');
    if (this.timer >= 0) {
      clearTimeout(this.timer);
    }
    this.timer = setTimeout(() => {
      if (currentSeq < this.seq || currentSeq <= this.appliedSeq) {
        return;
      }
      const widthVp = px2vp(widthPx);
      const mode: LayoutMode = widthVp < 600 ? 'COMPACT' :
        (widthVp < 840 ? 'MEDIUM' : 'EXPANDED');
      this.appliedSeq = currentSeq;
      apply({ seq: currentSeq, widthPx, heightPx, mode, state: 'APPLIED' });
      AppStorage.setOrCreate('layoutState', 'APPLIED');
    }, 80);
  }

  dispose(): void {
    if (this.timer >= 0) clearTimeout(this.timer);
    this.timer = -1;
  }
}

80 ms 是 WindowPulse Lab 的演示参数,不是 HarmonyOS 建议值。真正参数要根据窗口拖动的交互连续性、列表重排成本和页面动画来定。过短几乎等于不合并,过长会让界面跟手性变差。对只改变列数的轻页面,可以不做防抖;对需要重建图表、Canvas 缓冲或媒体预览的页面,收敛层才有意义。

序号比单纯防抖更重要。异步资源准备可能在尺寸已经更新后才返回。如果没有 seq,旧快照的结果会覆盖新窗口。代码只允许当前最新序号提交,并记录 appliedSeq,同一快照重复到达也不会二次生效。

断点使用 vp,原始窗口事件仍保留 px。这能避免把不同像素密度下的物理像素直接当成 ArkUI 的布局尺度。实际工程还要处理有效内容区、系统栏与避让区,不能只凭宽度决定所有布局。

图 02 是与本文字段一致的演示配图,不是真实 IDE 截图或性能证据。右侧模拟器显示任务 LAYOUT-1042,底部日志把 6 个输入回调与 2 次 APPLIED 分开,红色标记只指向稳定回调引用和最后一次提交。

四、页面只消费结果,不参与事件竞争

这段代码解决什么问题:让 ArkUI 页面订阅原始尺寸,但只用收敛后的快照切换布局,并在离开页面时释放协调器。

@Entry
@Component
struct Index {
  @StorageLink('rawWindowWidth') rawWidth: number = 720;
  @StorageLink('rawWindowHeight') rawHeight: number = 1280;
  @StorageLink('layoutState') state: string = 'LISTENING';
  @State mode: LayoutMode = 'COMPACT';
  @State appliedText: string = '720×1280 px';
  private committer: WindowCommitter = new WindowCommitter();

  onPageShow(): void {
    this.committer.submit(this.rawWidth, this.rawHeight, (snapshot) => {
      this.mode = snapshot.mode;
      this.appliedText = `${snapshot.widthPx}×${snapshot.heightPx} px`;
    });
  }

  onDidBuild(): void {
    this.committer.submit(this.rawWidth, this.rawHeight, (snapshot) => {
      this.mode = snapshot.mode;
      this.appliedText = `${snapshot.widthPx}×${snapshot.heightPx} px`;
    });
  }

  aboutToDisappear(): void {
    this.committer.dispose();
  }

  build() {
    Column({ space: 16 }) {
      Text('窗口收敛诊断').fontSize(24).fontWeight(FontWeight.Bold)
      Text('任务 LAYOUT-1042').fontColor('#5B6472')
      Text(`${this.state} · ${this.mode}`)
      Text(this.appliedText).fontSize(30)
      if (this.mode === 'EXPANDED') {
        Row() { this.Summary(); this.EventList() }.width('100%')
      } else {
        Column() { this.Summary(); this.EventList() }.width('100%')
      }
    }.padding(24).width('100%').height('100%')
  }

  @Builder Summary() { Text('输入 6 次 / 提交 2 次') }
  @Builder EventList() { Text('LISTENING → COALESCING → APPLIED') }
}

这段示例为了集中说明状态流,使用 onDidBuild() 触发演示提交。生产代码不要无条件在每次构建后再写会引发重建的状态,否则容易形成更新环。更稳妥的做法是由统一状态模型观察原始宽高变化,再调用 submit();或者把协调器放在可测试的 ViewModel 中。文章保留这段代码,是为了明确“页面只消费稳定快照”的边界,而不是把生命周期回调当成万能监听器。

状态变化也应可见。LISTENING 表示监听已经建立;新尺寸进入时切到 COALESCING;稳定快照应用后才到 APPLIED。如果页面只显示最终宽度,重复注册、旧任务回写和长时间收敛都很难被发现。

图 03 对应折叠前的紧凑布局:720×1280 px、COMPACT、任务 LAYOUT-1042,状态为 LISTENING。它只展示 Demo 页面,不代表某台具体设备的真实截图。

五、诊断页要回答“丢了什么”和“为什么丢”

窗口适配调试不能只看最终 UI 是否漂亮。至少要记录原始事件序号、接收时间、宽高、推导断点、是否被合并以及最终提交序号。这样遇到“偶尔卡在单栏”时,才能区分是系统没有发事件、监听已经丢失、事件被错误去重,还是旧异步任务覆盖了新状态。

WindowPulse Lab 的诊断页把 6 个输入列成时间线:前四个处于过渡区,被标记为 MERGED;第 5 个提交 MEDIUM,第 6 个稳定为 EXPANDED。最终卡片显示 1440×1280 px、APPLIED、提交计数 2。这个数据集只是可讲解的固定样本,读者运行时应该替换成自己采集的日志。

图 04 与图 03 明显不同:它承担解释作用,展示 COALESCING → APPLIED、6→2 的合并结果和最终 1440×1280 px。红圈标的是被合并的中间事件,不是错误日志。

如果诊断里出现同一序号多次提交,先查监听是否重复注册;如果序号持续增长但页面不变,查状态是否写错作用域;如果新窗口先出现、随后又退回旧宽度,查异步任务是否缺少序号或取消机制;如果 Ability 销毁后仍有日志,查 off() 与定时器释放。

六、测试不是多折几次,而是构造事件顺序

手工把设备折起、展开,看到页面最终正常,只能说明主路径没有立刻暴露问题。回调竞争最麻烦的地方是顺序不稳定:尺寸 A 的异步工作可能比尺寸 B 更晚完成,页面退出可能刚好发生在防抖定时器触发前,WindowStage 重建也可能让新旧监听短暂重叠。因此测试要针对顺序,而不是只针对几个静态尺寸。

第一组用例是突发输入。连续送入 720×1280、860×1280、1030×1280、1180×1280、1320×1280、1440×1280,间隔都小于演示窗口 80 ms。期望不是硬性“只提交一次”,而是最后一次提交必须对应序号 6、尺寸 1440×1280,任何序号小于 6 的异步结果都不能在它之后改写页面。如果团队允许中间提交,报告就要说明哪些序号被应用以及原因。

第二组用例是慢速拖动。每个事件间隔都大于收敛窗口,界面应逐步响应,而不是等到用户停止很久才变化。这个用例能发现把“防抖”误写成“节流后永不补最后一帧”的实现。多窗口拖动是一种持续交互,过度合并会让内容突然跳变,视觉上比重复布局更糟。

第三组用例是逆序完成。给序号 5 的资源准备故意增加 200 ms 延迟,让序号 6 先完成。最终页面必须保留序号 6。如果序号 5 随后把模式改回 MEDIUM,说明防抖只挡住了事件入口,却没有保护异步出口。序号校验应该放在真正提交状态之前,而不是只放在创建任务时。

第四组用例是生命周期中断。在 COALESCING 阶段立即离开页面,期望定时器被清理,页面不再写入局部状态;随后重新进入,首帧应从当前主窗口重新读取尺寸,而不是沿用离开前的快照。对于保存在 AppStorage 的原始窗口值,还要判断它是否属于当前窗口实例。多窗口环境里,单一全局键可能被另一个窗口覆盖,复杂应用应按窗口标识隔离。

第五组用例是重复初始化。连续调用两次注册路径,然后只制造一个尺寸变化。如果日志出现两条相同事件,说明监听所有权仍不清晰。修复思路不应是“日志去重”,而是让注册动作本身具备幂等性:保存已绑定窗口,发现同一实例已注册就直接返回;窗口实例改变时先释放旧实例,再绑定新实例。

这些测试完全可以在协调器层用假时钟完成,不必每次都启动模拟器。窗口 API 只负责把尺寸送入 WindowCommitter,其余逻辑是纯状态转换。把系统回调和业务收敛分开之后,submit()、dispose()、过时序号拒绝都能通过单元测试验证。真机验证仍然必要,但它的任务变成确认系统事件与可用区表现,而不是承担所有竞态发现工作。

七、别让日志本身制造卡顿

诊断阶段容易走向另一个极端:每个尺寸事件都打印完整对象、组件树和耗时,结果日志 IO 反过来影响窗口拖动。建议把日志分成三层。默认层只记任务号、序号、宽高、状态和结果;调试层增加时间间隔与断点推导;详细层才记录环境与资源重建信息,并且只在开发构建打开。

同一事件应贯穿一个关联标识。本文用 LAYOUT-1042 表示一次诊断任务,用 seq 区分其中的窗口事件。日志格式保持短而稳定,例如 task=LAYOUT-1042 seq=6 state=APPLIED size=1440x1280 mode=EXPANDED。这样既能在 HiLog 中搜索,也方便导出后按字段统计,不必从自然语言里猜含义。

耗时也要拆开。事件接收到提交之间的时间包含收敛等待,不应全部算成“布局耗时”;真正的布局与资源重建要单独计时。否则把 80 ms 等待加到重排耗时里,会得到一个看似严重、实际含义错误的性能数字。反过来,只看重排函数耗时又会忽略用户感知到的整体延迟。

日志中不要写入与窗口适配无关的用户内容。窗口尺寸、序号和模式通常足够定位问题;页面标题、文档名称或账号信息没有必要进入这条链路。用于外部分享的诊断截图,也应像图 04 一样只保留技术字段。

八、布局状态和业务状态不能绑死

折叠或分屏时,布局可以从单栏变双栏,但用户正在编辑的文本、选中的条目和滚动锚点不应因此重置。工程上常见的问题是 if (mode === 'EXPANDED') 分支创建另一套组件,两个分支各自持有局部状态;断点跨越时旧组件销毁,新组件以默认值重建,于是看起来像窗口回调丢数据。

更稳的做法是把业务状态放在稳定的 ViewModel 或页面级状态中,布局分支只决定这些状态如何呈现。列表选中项使用业务 ID,而不是可见索引;滚动位置保存锚点 ID 与偏移,而不是绝对像素;输入草稿在布局切换前已经写入共享状态。这样 COMPACT 与 EXPANDED 只是两种投影,不是两个互不相干的页面。

资源类对象也要单独管理。Canvas 缓冲、媒体预览或图表缓存可以随可用尺寸重建,但旧对象必须在新对象接管后释放,或者在序号失效时立即丢弃。不要先销毁当前可用资源,再等待新尺寸任务完成,否则快速折叠时用户会看到明显空白。双缓冲或“新成功后替换旧”的策略通常更平滑,不过会暂时增加内存,需要结合页面成本取舍。

当窗口过窄时,有些功能可能无法完整呈现。此时应提供降级布局或滚动容器,而不是把按钮挤出屏幕。官方多设备适配入口专门列出短屏、软键盘避让和窗口交互等场景,这也提醒我们:折叠屏不是唯一变量。只为“展开/折叠”写两个固定页面,往往经不起自由窗口和键盘弹出的组合。

九、边界比断点表更值得写进评审单

这套收敛层并非越复杂越好。纯文本页面的重排成本很低,直接响应窗口尺寸通常更自然。只有当变化会触发昂贵副作用,或项目已经出现回调风暴、过时结果覆盖、监听泄漏时,才需要引入合并与序号。

不要用折叠状态代替窗口尺寸。不要在尺寸回调中直接销毁并重建所有资源。不要把 80 ms 复制到所有页面。不要把示例的 6→2 当作性能承诺。也不要忘记多窗口、横竖屏、软键盘避让和系统栏都会改变实际可用区域。

上线前的检查顺序可以很朴素:先确认监听只注册一次,再确认销毁后不再产生日志;随后拖动自由窗口观察布局是否跟手;再做折叠、展开、旋转、分屏组合;最后给异步资源任务加入旧序号拒绝。每一步都看状态时间线,而不是只看最终截图。

评审时还可以追问四个问题。窗口对象更换后,旧监听由谁释放;稳定快照到达前,页面保留什么可用内容;新旧资源同时存在的短时间内,内存上限是否可接受;应用进入后台再回来时,是继续未完成任务还是重新读取当前尺寸。只要其中一个问题没有明确答案,偶现错位就仍可能躲在生命周期交界处。

对于多个 Ability 或多个应用窗口,单一 AppStorage 键也不够。每个窗口应有独立标识和快照域,页面只订阅自己所属窗口的数据。否则主窗口调整大小,子窗口可能收到同一份宽高并错误换栏。简单 Demo 用全局键便于说明,生产架构则要把作用域提升为一等概念。

最后,别把“没有崩溃”当成适配完成。折叠后焦点是否仍在原输入框、读屏顺序是否随布局变化、放大字体后关键按钮是否可见、动画过程中是否出现短暂不可操作,都是连续体验的一部分。窗口事件收敛只是地基,它负责让上层拿到稳定事实;真正的多形态质量还需要交互、无障碍和资源管理共同完成。

如果团队只能先做一项改造,就先把监听注册、稳定提交和资源释放三处日志串上同一个任务号。它不会立刻解决全部适配问题,却能让下一次偶现错位留下足够证据,也能确认修复是否真的减少了重复提交。

官方参考:

把窗口事件当成输入流,而不是布局命令,是这篇文章最核心的判断。尺寸监听负责提供事实,收敛层负责拒绝过时事实,页面负责消费稳定事实。三层职责分开后,折叠屏适配才不必靠“多加几个延时”维持表面稳定。

Logo

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

更多推荐