最近我把平板/PC 共用的调试面板改成了独立子窗。需求看起来很简单:主窗口右上角悬着一个 inspector_panel,用户拖动主窗口大小时,它始终保持“距右侧 24 vp、距顶部 96 vp”。

真正跑到自由窗口里以后,问题马上出来了。拖拽过程中 windowSizeChange 会连续触发,子窗每次都立刻 moveWindowTo(),肉眼能看到抖动;从 MEDIUM 跨到 WIDE 时,页面本身也在重排,子窗又抢着定位,一段尺寸变化可能执行七八次移动。

这次我不讨论“怎么弹一个子窗”,而是专门处理三个工程点:尺寸事件怎样收敛成一次布局决定、断点与子窗锚点怎样使用同一份尺寸、页面销毁时监听和窗口怎样成对释放。

Demo 叫 SubWindowAnchorLab。最终主窗口 1180 × 760 vp,断点 WIDE,子窗 inspector_panel,右边距 24 vp、顶部 96 vp。一次拖拽收到 7 次尺寸事件,真正执行 2 次 reposition,5 次被防抖合并。

一、独立子窗先用正确的创建入口

API 26 起,可以通过 createSubWindowWithOptions() 配合 zLevelAboveParentLoosened: true 创建独立子窗。

private async createInspector(
  windowStage: window.WindowStage
): Promise<void> {
  const options: window.SubWindowOptions = {
    title: 'Inspector Panel',
    decorEnabled: true,
    zLevelAboveParentLoosened: true
  }

  this.subWindow =
    await windowStage.createSubWindowWithOptions(
      'inspector_panel',
      options
    )

  await this.subWindow.resize(
    this.uiContext.vp2px(320),
    this.uiContext.vp2px(420)
  )

  await this.subWindow.setUIContent(
    'pages/InspectorPanel'
  )
  await this.subWindow.showWindow()
}

我没有开启 setFollowParentWindowLayoutEnabled(true),因为 inspector 需要自己控制 size 和 position。

二、主窗口尺寸变化先进入状态桥,不要直接改子窗

windowSizeChange 是整个适配链的真实输入。每次回调只做三件事:累计 resizeEvents,把最新宽高写入 AppStorage,再把同一份 size 交给 scheduleReposition()。

ArkUI 页面通过 @StorageLink 读取宽高决定响应式布局;子窗锚点则由 Coordinator 处理。这样页面重排和子窗移动共享同一份窗口尺寸,却不会互相直接调用。

三、连续 resize 事件先用 120 ms 防抖收敛

用户拖动自由窗口边框时,尺寸事件会连续到来。每一个事件都移动子窗,视觉上一定会抖。

private resizeTimer: number = -1

private scheduleReposition(
  size: window.Size
): void {
  if (this.resizeTimer !== -1) {
    clearTimeout(this.resizeTimer)
    this.debounced++
  }

  this.resizeTimer = setTimeout(() => {
    this.resizeTimer = -1
    this.applyWindowState(size)
  }, 120)
}

120 ms 是业务参数,不是系统规定值。本次最终 Resize Events=7、Debounced=5、Applied Repositions=2,大部分原始事件被合并成稳定布局。

四、断点和锚点必须使用同一套 vp 尺寸

尺寸事件给的是 px,而响应式布局更适合用 vp;如果页面按 vp 判断断点,子窗却直接拿 px 做边距,不同密度设备上就会错位。

所以我先统一转成 vp,再决定断点和位置:

private async applyWindowState(
  size: window.Size
): Promise<void> {
  const widthVp =
    this.uiContext.px2vp(size.width)
  const heightVp =
    this.uiContext.px2vp(size.height)

  const breakpoint =
    widthVp >= 960
      ? 'WIDE'
      : widthVp >= 720
        ? 'MEDIUM'
        : 'COMPACT'

  AppStorage.setOrCreate(
    'windowBreakpoint',
    breakpoint
  )

  await this.repositionInspector(
    widthVp,
    heightVp
  )
}

本次最终宽度 1180 vp,所以落在 WIDE。

正式项目里,断点应该与设计系统统一。

五、右上角锚点只从“当前窗口矩形”重新计算

inspector_panel 宽度固定 320 vp,右边距 24 vp,顶部 96 vp。最终 x 位置就是:

widthVp - 320 - 24

然后统一转回 px 调 moveWindowTo()。

我不基于上一次 x/y 做增量补偿,而是每次从当前窗口矩形重算绝对位置,避免误差累积。

如果窗口小到 COMPACT,应隐藏 inspector、缩小面板或改成页面内 overlay。

六、独立子窗的“独立”并不等于不用清理

zLevelAboveParentLoosened 让独立子窗在自由窗口下层级更灵活,但页面监听、timer 和 Window 引用仍要主动处理。

async dispose(): Promise<void> {
  this.mainWindow.off(
    'windowSizeChange',
    this.onWindowSizeChange
  )

  if (this.resizeTimer !== -1) {
    clearTimeout(this.resizeTimer)
    this.resizeTimer = -1
  }

  if (this.subWindow) {
    await this.subWindow.destroyWindow()
    this.subWindow = undefined
  }
}

这三步分别回收监听器、延迟任务和窗口对象,避免下一轮重建出现重复回调。

七、调试页只看“原始事件多少、真正执行多少”

工程只保留 EntryAbility.ets、主页面、InspectorPanel.ets 和 SubWindowAnchorCoordinator.ets。

HiLog 里固定输出:

windowSizeChange=860x760、windowSizeChange=1180x760、breakpoint=WIDE、move inspector_panel x=812vp y=96vp、repositionCount=2、State: RESIZING -> ANCHORED。

八、最终运行结果验证的是“7 次事件只移动 2 次”

最终页面显示:

  • Session:subwin_anchor_20261001_16
  • State:ANCHORED
  • Main Window:1180 × 760 vp
  • Breakpoint:WIDE
  • SubWindow:inspector_panel
  • Anchor Right:24 vp
  • Anchor Top:96 vp
  • Resize Events:7
  • Applied Repositions:2
  • Debounced:5
  • Independent:true
  • Last Reposition:16:36:18

预览图里 inspector_panel 始终贴在右上角,24 vp 与 96 vp 两个边距保持一致。

九、正式产品里还要处理几个边界

第一,COMPACT 下要有明确降级策略。第二,拖到不同显示器后密度可能变化,必须基于当前 UIContext 做 px/vp 转换。第三,如果允许用户手动拖动子窗,要区分“自动锚定”和“手动位置”。第四,Ability / 页面异常退出也要覆盖监听与子窗清理。

十、这次真正优化的是“尺寸事件怎样变成一次布局决定”

最后状态链路是:

RESIZING → DEBOUNCING → RECALCULATING → ANCHORED

自由窗口真正麻烦的是变化连续发生。把每次原始事件都直接映射成 UI 和子窗动作,应用一定会抖。更稳妥的方式是先收敛成一个稳定尺寸,再让页面断点和独立子窗锚点一起计算。

Logo

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

更多推荐