FlexUI 屏幕事件(Screen Events)设计

日期: 2026-09-08 | 分支: ai | 涉及: flexui-engine(driver/js/flexui-backend) / packages/flexui-frontend / @openflexui/core 关联: docs/design/flexui-frontend.md §3.10、docs/design/flexui-ohos-runtime-isolation.mddocs/design/js-native-capability-boundary.md §5.1

1. 整体方案(先看这一节)

1.1 要解决的问题

小程序允许业务这样响应"页面显示区域尺寸变化"(基础库 ≥ 2.4.0):

Page({ onResize(res) { res.size.windowWidth /* 新宽 */; res.size.windowHeight /* 新高 */ } })
Component({ pageLifetimes: { resize(res) { /* 组件也能收到同一份尺寸 */ } } })
this.createSelectorQuery().selectViewport().fields({ size: true }, cb).exec() // 主动读当前尺寸

FlexUI 引擎里卡片(FlexView)能随窗口/旋转/悬浮窗真实改变大小,尺寸变化也确实会进 JS(驱动 @media 和布局重排)——但变化从未被翻译成业务能监听的"页面 resize"事件。本方案就是补上这最后一公里。

1.2 核心思路(一句话)

不新增任何"尺寸事件专用通道",而是挂在引擎既有、被媒体查询和布局验证过的容器尺寸推送链的"落地端" (NativeBackendContext.setWindowSize)上,加一个"变化即广播"的出口;再在前端框架里维护"页面根组件注册表", 把广播按 rootId 翻译回小程序的两类页面语义回调(页面根 onResize + 全树 pageLifetimes.resize)。

因为:

  • 引擎里"哪张卡、容器变成多宽多高"这件事已经被既有链路算得准、分得清updateDimension → Dimensions.set 带 scope 路由与 rootId 透传,多卡片不串扰)——重复造一条尺寸事件通道,只会带来两份时序和两套一致性;
  • 唯一缺的是把"尺寸变化"送达业务页面模型:而页面根组件(Page/Root)只存在于前端框架层, 所以分发必须由前端完成;触发/感知则留在引擎侧完成。

1.3 三层职责(各司其职,各只做一件事)

感知层(原生既有,零新增)
  容器真实变化 → ETS updateDimension(rootId,w,h,scopeKey)
    → JS Dimensions.set(带 __rootId__)            # 把"容器尺寸 + 属于哪个 root"推进 JS

分诊层(flexui-backend,唯一引擎改动点)
  NativeBackendContext.setWindowSize(w,h,rootId)   # 每条卡片尺寸推送的收口点
    ├─ 已有:按 rootId 路由、真实变化检测、@media 重评估、重渲染
    └─ 新增:真变化 → 广播 (rootViewId, w, h)       # 只负责"喊一声谁变了、变成多少"

语义层(flexui-frontend,新模块 screen_events)
  页面根注册表 + 广播订阅
    ├─ 按 rootViewId 找到对应卡片页面根(防多卡片串扰)
    └─ 分发成业务回调:
        ① 页面根组件顶层 onResize(res)            # Page 顶层函数 → 组件方法
        ② 全树 triggerPageLifetime('resize',[res]) # 每个组件的 pageLifetimes.resize
  生命周期治理:Root.release 与引擎 destroyInstance 双注销(防泄漏/误分发)

为什么广播放在 NativeBackendContext 而不是 Dimensions.set 全局处? Dimensions.set 是全局广播,所有卡片的 context 都会收到同一条尺寸;而"这尺寸相对本卡是否真的变了、 本卡的权威尺寸是多少(override 容器宽 vs live 全局)"只有每个卡片自己的 context 知道——所以出口必须设在 收口点 setWindowSize(这里同时天然完成了去重:两次到达同一尺寸只触发一次)。

1.4 一次 resize 的完整旅程(读这一段就能复述整个方案)

  1. 用户旋转设备 / 切悬浮窗 / 宿主改 FlexView 宽度 → 原生容器尺寸变化;
  2. ETS updateDimension 按 scope 把新容器尺寸推给 JS,并带上本卡 __rootId__(感知层,生产既有);
  3. 卡片自己的 NativeBackendContext.setWindowSize(w, h, rootId) 收口:先做既有动作(@media 重评估、 样式失效、重渲染),检测到真实宽高变化后,把 (rootViewId, 新宽, 新高) 放进 __GLOBAL__.__flexuiScreenResizeListeners 广播(白名单通道,同 Dimensions/colorMode 模式);
  4. 前端 screen_events 的注册表里存着"页面根组件 ↔ 它的 backend",backend 暴露 rootViewId—— 广播到达后按 rootId 精确匹配,只有发生变化的这张卡的页面根被命中(其它卡片不误触发);
  5. 命中的页面根先调自身的顶层 onResize(res)(Page 顶层函数字段在注册时已成为组件方法,未声明则 no-op), 再 triggerPageLifetime('resize', [res]) 递归整棵页面树,让每个声明了 pageLifetimes.resize 的组件 (含页面根自己)收到回调;
  6. 回调里的 res.size 用的是广播时从 getWindowWidth()/getWindowHeight() 取的权威尺寸——和该卡 selectViewport() 读到的是同一对数字,语义一致。

1.5 一句话回答"改了什么"

引擎侧只加了 setWindowSize 里一个"真变化→广播"的出口;前端新增了一个"页面根注册表 + 广播→页面回调分发" 模块并在 Root 上接线;业务侧无需任何新 API——在卡片里按小程序写法声明 onResize / pageLifetimes.resize 即可,selectViewport() 本来就能读到当前尺寸。

2. 分层实现细节

2.1 感知层(复用,无需改动)

FlexUIRootView.onAreaChange → updateDimension(rootId, w, h, scopeKey) → Dimensions.set(map 携带 __rootId__,px→dp 统一)是既有的、被 @media/布局验证过的推送链,多 scope 按 scopeKey 路由、多卡片按 rootId 透传。

2.2 分诊层:NativeBackendContext.setWindowSize 的广播出口

if (widthChanged || heightChanged) {
  cssManager.setMediaEnvironment({ ... })          // @media 宽度/方向断点
  if (widthChanged) { invalidateAllStyles(); scheduleRender() }
  NativeBackendContext._notifyScreenResize(rootViewId, getWindowWidth(), getWindowHeight())
}
  • 触发条件:与既有 @media 重评估同一个 widthChanged||heightChanged——真变化才广播; 首次播种(rootId=0 且已 cached)、无变化推送、模块/补丁双通道重复送达同尺寸全部天然去重;
  • 载荷rootViewId + getWindowWidth()/getWindowHeight()(override 容器尺寸优先,回退 live)—— 与 selectViewport() 同源、dp 单位;
  • 通道__GLOBAL__.__flexuiScreenResizeListeners(白名单内可运行期访问;监听器数组由前端在首次注册页面根时 push, 广播侧逐个 try/catch,单个失败不阻断)。

2.3 语义层:packages/flexui-frontend/src/screen_events.ts

模块职责与内部结构:

内部件 做什么
registeredRoots: {backend, root}[] 已挂载页面根注册表;Root 构造注册 / release 注销
attachEngineChannel() 首个注册时向 __GLOBAL__ 通道 push 一个分发监听器(模块内一次性)
subscribeEngineDestroy() 订阅 FlexUI 总线 destroyInstance(rootId):引擎卸载不会Root.release(),按 rootId 反向注销
dispatchResize(rootId,w,h) 快照遍历注册表 → rootId 匹配 → 逐根:callMethod('onResize', res) + triggerPageLifetime('resize',[res])
notifyScreenResize(rootId,w,h) 无原生通道宿主/测试的手动入口(rootId=0 全广播)

分发语义与工程约束:

  • res 逐目标新建(防前回调改写后回调);快照遍历(分发回调中注销其它根不跳项/不重复);
  • onResize 异常 → flexui.dispatchError(捕获+上报、不 rethrow);pageLifetimes 由 FuncArr safeCallback 兜底;
  • FlexUI 总线在模块顶层 capture(保护化后 globalThis.FlexUI 是抛错代理,浏览器/DOM 无则跳过);
  • 双注销路径幂等。

2.4 Root 接线(packages/flexui-frontend/src/backend.ts

Root 增加 _$backendContext;构造末尾注册、release() 先注销再走既有 detach。配合 2.3 的 destroyInstance 订阅形成"正常释放 + 引擎卸载"双保险。

3. 契约与边界

  • res 形状{ size: { windowWidth, windowHeight } }(dp,与 selectViewport() 同源);
  • 触发:容器真实宽高变化(旋转/悬浮窗/折叠/宿主改 FlexView 尺寸);首次播种、无变化不触发;
  • 已知偏差:context 构造期消费尺寸与首个真实容器推送存在浮点差时,挂载瞬间可能触发一次 onResize (demo 启动即 count=1)。常规卡片(pre-loadCard 先到、构造期消费)不受影响;引擎测试按"可能多一次初始广播"容错。 如需严格对齐小程序"仅变化回调",可加"与构造基线比对抑制"(见 §8);
  • 多 scope/多卡片隔离:Scope/Context 严格 1:1,flexui_jsfwk.js(前端模块闭包、__GLOBAL__FlexUI 总线) 每 context 独立 → 注册表/通道/destroy 订阅天然 per-scope;尺寸推送按 scopeKey 路由到本 scope JS,rootId 在 scope 内唯一 → 分发不跨卡片串扰(全量回归 multi-scope-event-dispatch 实证通过);
  • 无原生通道宿主:用 notifyScreenResize(rootId, w, h) 手动驱动同一分发(无 rootViewId 的 DOM backend 接收任意事件)。

4. 设计推理(为什么这么分,而不是别的分法)

决策点 选择 理由
事件来源 复用 updateDimension → Dimensions.set,不新增专用尺寸事件通道 真实性、scope/root 路由、单位换算、去重已被既有层解决;专用通道引入两份时序与一致性负担(且现状 EventDispatcher.onSizeChanged JS 模块本就无注册,是存量 no-op)
广播出口 NativeBackendContext.setWindowSize(每卡收口点) "相对本卡是否真变 + 本卡权威尺寸"只在这里收敛;Dimensions.set 只是全局转发,谁变只有 context 知道
分发位置 前端 screen_events 维护页面根注册表 页面根组件(Page/Root)只存在于前端框架层;广播是 rootId→事件,语义回调需要 rootId→组件树的映射,只能在前端做
多卡片防串扰 广播携带 rootViewId,按 backend 匹配分发 同 scope 多卡共享推送,只有发生变化的卡该收到
生命周期 Root.release() + destroyInstance 双注销 引擎卸载不调用 Root.release();只靠其一都会泄漏已销毁页面根
语义对齐 页面根 onResize + 全树 pageLifetimes.resize 两条独立表面 与小程序一致;根不重复收、组件不遗漏(含根自身注册的 pageLifetimes)

5. 工程约束落实

  • 异常规范:业务回调一律"捕获 + 上报(dispatchError / safeCallback)不 rethrow";
  • capture 规范(§5.1)FlexUI 总线仅模块顶层 capture,运行期不解析受保护标识符;
  • 性能:resize 低频;无 pageLifetimes 的组件浅查即过,不建表;无监听者时广播零开销。

6. 测试与验证

  • 单测(flexui-frontend):分发载荷 / onResize no-op / rootViewId 路由 / 引擎通道接线 / destroyInstance 注销与重建恢复(jest.isolateModules);
  • 引擎 E2E(card-screen-resize,feature):自断言卡片经 EngineTestModule.pushCardSize 走生产 updateDimension 链路推送两次尺寸,断言到达次数、数值 > 0、两通道逐次一致、尺寸随推送变化;
  • 真机结果:card-screen-resize 11 断言全过;test-engine all = PASS 44 / FAIL 0; 多 scope 与重建用例无回归;apidemo screen-resize-demo(CardTester 容器宽度 100/80/60/40 切换)UI 实时刷新。

7. 改动文件清单

  • 引擎 JS:flexui-backend/.../NativeBackendContext.ts(广播出口)
  • 前端:packages/flexui-frontend/src/screen_events.ts(新)、backend.tsindex.ts
  • 单测:packages/flexui-frontend/tests/screen_events.test.tsscreen_events_destroy.test.ts
  • 引擎测试:EngineTestModule.ets(pushCardSize)、engine-tests/cards/screen-resize/manifest.jsonREADME.md
  • Demo:apidemos/components/screen-resize-demo/(+ 子组件 screen-resize-sensor)、apidemos/mcp.json
  • 文档:docs/design/flexui-frontend.md §3.10、本文

8. 扩展方向

  • wx.onWindowResize/offWindowResize 类全局 API:在广播出口再加一层全局监听者注册即可暴露给 has.*/ASAPIs;
  • 抑制首次挂载广播(与构造基线比对);
  • 页面 show/hidepageLifetimes.show/hide、顶层 onShow/onHide)目前无生产触发方,可复用本方案 "引擎事件 → 白名单通道/总线 → 前端注册表分发" 的同一套接线;
  • 浏览器预览自动接线:window.resize → notifyScreenResize()