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.md、docs/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 的完整旅程(读这一段就能复述整个方案)
- 用户旋转设备 / 切悬浮窗 / 宿主改 FlexView 宽度 → 原生容器尺寸变化;
- ETS
updateDimension按 scope 把新容器尺寸推给 JS,并带上本卡__rootId__(感知层,生产既有); - 卡片自己的
NativeBackendContext.setWindowSize(w, h, rootId)收口:先做既有动作(@media重评估、 样式失效、重渲染),检测到真实宽高变化后,把(rootViewId, 新宽, 新高)放进__GLOBAL__.__flexuiScreenResizeListeners广播(白名单通道,同 Dimensions/colorMode 模式); - 前端
screen_events的注册表里存着"页面根组件 ↔ 它的 backend",backend 暴露 rootViewId—— 广播到达后按 rootId 精确匹配,只有发生变化的这张卡的页面根被命中(其它卡片不误触发); - 命中的页面根先调自身的顶层
onResize(res)(Page 顶层函数字段在注册时已成为组件方法,未声明则 no-op), 再triggerPageLifetime('resize', [res])递归整棵页面树,让每个声明了pageLifetimes.resize的组件 (含页面根自己)收到回调; - 回调里的
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 由 FuncArrsafeCallback兜底;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-resize11 断言全过;test-engine all= PASS 44 / FAIL 0; 多 scope 与重建用例无回归;apidemoscreen-resize-demo(CardTester 容器宽度 100/80/60/40 切换)UI 实时刷新。
7. 改动文件清单
- 引擎 JS:
flexui-backend/.../NativeBackendContext.ts(广播出口) - 前端:
packages/flexui-frontend/src/screen_events.ts(新)、backend.ts、index.ts - 单测:
packages/flexui-frontend/tests/screen_events.test.ts、screen_events_destroy.test.ts - 引擎测试:
EngineTestModule.ets(pushCardSize)、engine-tests/cards/screen-resize/、manifest.json、README.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/hide(pageLifetimes.show/hide、顶层onShow/onHide)目前无生产触发方,可复用本方案 "引擎事件 → 白名单通道/总线 → 前端注册表分发" 的同一套接线; - 浏览器预览自动接线:
window.resize → notifyScreenResize()。