卡片级定时器管控:runscript 默认放行 + 卡片按 rootId 特批(Timer Policy)
状态:设计稿(管控体系的前提实现之二) 运行时:JSH(JSVM),单 Env 关联文档:
- js-native-call-origin.md——来源管控主方案(本文仅复用其 'timer' 场景打标:回调内 has.* 策略;注册动作不再经 ETS,不涉及第 7 参来源传输)
- js-native-call-entry-guard.md——执行入口禁止(复用其受保护注册表机制:特批入口真身入
__protectedRegistry,业务不可达)- js-native-capability-boundary.md——白名单保护化与「加载时 capture」约束(框架代码须在加载期 capture 真身,运行期不得解析 globalThis)
需求(已确认,v2):定时器管控粒度从 scope 级升级为卡片级(rootId):
- runscript(
runScriptFromUri执行的业务脚本)默认放行——宿主主动执行的脚本视为可信,不做管控;- FlexView 卡片默认拒绝,需要时由 ETS 侧按卡片特批;
- 失败管控:卡片被拒 = 受控失败——初始化阶段(loadInstance)被拒 → 卡片加载失败回调 + onJsException 双回调;运行期被拒 → 同步抛错走 onJsException。
旧 scope 级
FlexUIEngineContext.setTimerPermission(permitted)与EngineInitParams.allowTimer(init 参数)已废弃移除。
1. 背景:为什么从 scope 粒度升级到卡片粒度
1.1 旧机制(scope 级,已废弃)
业务 JS → global.setTimeout(检查包装,protect() 时安装)
→ 查 entry_guard.js 模块闭包权限标志 _timerPermitted(scope 级单布尔)
→ deny → throw 'setTimeout:fail permission denied (timer not permitted)'
→ allow → internalBinding('TimerModule') → C++ TimerModule(零改动)
- JS context 按 scope 隔离(C++ Scope ↔ JsDriver ↔
FlexUIEngineContextImpl),_timerPermitted随 scope 的 Env 实例独立; - 问题:同一 scope 内多张卡片(multi-rootView 模式,多 rootView 共享一个 JS context/globalThis)共享同一个开关,卡片之间无法独立控制;runscript 与卡片 bundle 同走 C++ RunScript,也无从区分。
1.2 新机制的粒度模型
| 执行入口 | 定时器策略 |
|---|---|
runscript(runScriptFromUri) |
默认放行(脚本模式,无卡片上下文) |
| 卡片 bundle 顶层代码(模块级,与 runscript 同走 C++ RunScript) | 按脚本模式放行(已知边界,见 §3.4) |
卡片实例化(loadInstance)及之后全部业务执行(事件/渲染/定时器回调) |
默认拒绝,按卡片 rootId 特批 |
2. 关键架构事实(决定方案走向)
- 检查点是「创建时」:业务代码同步调
setTimeout时判定,不需要跟踪触发时刻 → 只需知道「当前同步执行属于哪张卡(还是脚本模式)」; - 框架 JS 已有按 rootId 区分卡片的机制:
FlexUIBridge.activeRootId/setActiveRoot、NativeBackendContext.rootViewId——上下文跟踪有现成基础; - 业务 JS 执行边界可枚举:runscript 顶层 / 卡片 bundle 顶层 /
__loadInstance__(Event.js,id即 rootId)/ 事件回调(C++ 直调 listener)/ 定时器回调 / 渲染 flush; - 多卡片共享 scope context 是受支持的用法(demo 所有 FlexView 共用
SCOPE_KEY),因此不能用「scope 单开关」表达卡片权限。
3. 设计:卡片执行上下文跟踪 + per-card 标志
3.1 检查点(entry_guard.js)
卡片业务 JS → global.setTimeout(检查包装)
→ ① 查卡片执行上下文栈 _rootContextStack:
空(脚本模式,runscript/bundle 顶层)→ 放行
栈顶 rootId → 查 _perCardTimerPermitted[rootId](默认拒绝)
→ ② deny → throw 'xxx:fail permission denied (timer not permitted, card=<rootId>)'
(错误对象带 timerPermissionDenied 标记,供失败管控识别)
→ ③ allow → 回调包上卡片上下文(_withRootContext)→ 原路径 internalBinding('TimerModule')
→ 到点 C++ 直调 JS 回调(回调内再建定时器/执行业务代码,上下文已恢复,归属本卡片)
- 标志:
_perCardTimerPermitted = { rootId: boolean }(模块闭包,业务不可达); - 上下文栈:
_rootContextStack = [rootId, ...](空 = 脚本模式 = 放行); - 框架内部入口
pushRootContext/popRootContext/withRootContext/clearCardTimerPermission经globalThis.__flexuiTimerPolicy暴露(加载期 capture;protect() 后不在白名单 → 替换为抛错代理,业务不可达——见 js-native-capability-boundary.md §5.1); - ETS 特批入口
__timer_policy.setTimerPermission仍注册进__protectedRegistry(native2js.js callJsModule 优先查真身,业务不可达),参数升级为{rootId, permitted}。
3.2 卡片执行边界挂点(框架 JS,加载期 capture 真身)
| 边界 | 挂点 | 动作 |
|---|---|---|
| 卡片实例化 | lib/global/Event.js __loadInstance__ |
push/pop(id 即 rootId),包住 receiveNativeEvent(['@hp:loadInstance']) + appRegister[name].run(params);被拒异常 → 上报 ETS 卡片加载失败(见 §3.3),异常在此终止不外抛 |
| 卡片销毁 | lib/global/Event.js __unloadInstance__ |
clearCardTimerPermission(id)(rootId 复用不串权)+ NativeBackendContext.destroy 兜底清理 |
| 事件回调 | NativeBackendContext.receiveComponentEvent(C++ 事件 → 业务 handler 的唯一入口) |
push/pop rootViewId(tap/click/scroll 等 handler 内的定时器归属正确) |
| 渲染 flush | NativeBackendContext.flushAndBuild(已有 setActiveRoot) |
push/pop rootViewId(computed/watch/render 内业务代码归属正确) |
| 定时器回调 | 检查包装内 | 创建时 withRootContext(rootId, cb)(C++ 到点直调 JS 回调时上下文已恢复) |
为什么事件挂点在
receiveComponentEvent而不是FlexUIBridge.addEventListener:native 注册的是稳定的 per-node 框架回调(NativeBackendElement.setListenerStats的cb),真正的业务 handler 在EventDispatcher内分发;在NativeBackendContext.receiveComponentEvent包夹一次即可覆盖全部业务事件 handler,且不存在 add/remove 包装函数身份匹配问题。
3.3 失败管控(卡片维度)
规则(CLAUDE.md「JS 框架代码异常处理规范」):所有 JS 异常捕获 + 上报,不外抛。
- deny 一律同步 throw(fail 语义,业务可 catch——如 canvas-demo 的图表重试降级已有 catch 先例);
- 统一捕获点:框架回调兜底
safeCallback(@openflexui/corefunc_arr.ts,生命周期/事件/observer 统一入口)捕获所有业务 JS 异常 →dispatchError上报(console 日志 +globalOptions.onError扩展点)→ 不 rethrow(不中断回调链、不造成组件半初始化); - 初始化阶段(
loadInstance同步代码)若 deny 异常冒泡到入口(非 safeCallback 路径):__loadInstance__catch 识别timerPermissionDenied→callNative('FlexUIEngineInternal', 'cardLoadFailed', rootId, reason)→ ETSFlexUIEngineModule→ 引擎按 rootId 查cardSessions→notifyModuleLoaded(STATUS_ERR_INSTANCE, reason, session)→FlexViewClient.onLoadCompleted失败码;异常在此终止(不 rethrow,避免二次未捕获异常);- 非受控异常保持既有行为:冒泡到 C++
Scope::LoadInstanceTryCatch →HandleException("uncaughtException")→ ETSonJsException(引擎「捕获 + 上报」通道,J-03);
- 运行期(事件/回调内)deny → 同步抛错 → safeCallback 捕获 + 上报(日志 + onError),卡片不中断;
- 业务自行 try/catch 拦截 → 按业务逻辑处理(不触发加载失败上报)。
3.4 已知边界与取舍(已确认)
| 边界 | 策略 | 说明 |
|---|---|---|
| 卡片 bundle 顶层代码 | 脚本模式放行 | index.pack.js 模块级与 runscript 同走 C++ RunScript,JS 层无法区分。实际业务定时器几乎都在实例化/回调内创建,风险极低。若要求顶层也受控,需把卡片 bundle 执行改经 JS 分发层(runCardScript 包装)或 C++ 加卡片标记——改动面更大,一期不做 |
ETS→JS 模块调用(callJavaScriptModule) |
脚本模式放行 | 宿主显式发起的调用(sendEvent、__ascfCallbacks 等),非卡片自主行为。若需归属卡片,可复用来源归因机制(origin 文档)按 callId 反查——一期不做 |
UIManagerModule 命令式回调(callUIFunction 的 callback) |
脚本模式放行 | C++ 经 callbackId 直调业务闭包,无 JS 分发层。同属低频边界,一期不做 |
3.5 ETS 接口与数据流
ETS:engine.getScopeContext(scopeKey)?.setCardTimerPermission(rootId, permitted)
→ callJavaScriptModule('__timer_policy', 'setTimerPermission', JSON.stringify({rootId, permitted}))
→ native2js 'callJsModule' → __protectedRegistry 真身(业务不可达)
→ 写入 entry_guard.js _perCardTimerPermitted[rootId]
- 静态声明:
FlexView.allowTimer?: boolean(默认 false)→loadCard(..., allowTimer?)→ModuleLoadParams.allowTimer→CardSession.allowTimer→loadJsModule在runBundle之前经__timer_policy.setTimerPermission下发(早于 loadInstance,覆盖实例化及之后全部业务执行); - 动态切换:
FlexUIEngineContext.setCardTimerPermission(rootId, permitted)(任意时刻可调); - 收回语义:
setCardTimerPermission(rootId, false)只阻断新注册;存量活跃定时器自然到期(业务可用 clear 类自行清理)。如需强收回(清掉存量),后续可加 C++ 清表接口,本期不做; - 清理:卡片销毁(
__unloadInstance__)清除该 rootId 标志,rootId 复用不串权; - runscript:无需任何接口(脚本模式恒放行)。
4. 与原方案/旧实现的差异
| 项 | 旧实现(scope 级,v1) | 本方案(卡片级,v2) |
|---|---|---|
| 粒度 | scope 单布尔 _timerPermitted |
卡片 _perCardTimerPermitted[rootId] + 脚本模式恒放行 |
| runscript | 受 scope 开关管控 | 默认放行(无需特批) |
| FlexView 卡片 | 随 scope 开关(同 scope 卡片无法独立控制) | 默认拒绝,按 rootId 特批(FlexView.allowTimer / setCardTimerPermission) |
| 失败语义 | deny 同步抛错(无加载失败信号) | deny 同步抛错 + 初始化阶段转加载失败回调(STATUS_ERR_INSTANCE)+ onJsException 双回调 |
| ETS 特批 API | setTimerPermission(permitted)(废弃) |
setCardTimerPermission(rootId, permitted)(新增) |
| init 参数 | EngineInitParams.allowTimer(废弃移除) |
FlexView.allowTimer(卡片参数)替代 |
| 框架挂点 | 无(单一检查点) | __loadInstance__/__unloadInstance__/receiveComponentEvent/flushAndBuild/定时器回调 wrap |
| JS→ETS 通道 | 无 | 新增引擎内置 native 模块 FlexUIEngineInternal.cardLoadFailed |
5. 安全性
- 标志与上下文栈存于 entry_guard.js 模块闭包(bootstrap require 独立函数作用域,业务拿不到);
- 特批入口真身只在
__protectedRegistry(jsModuleList / globalThis 不暴露);框架内部入口__flexuiTimerPolicy不在白名单,protect() 后替换为抛错代理——业务均无法自开开关或伪造卡片上下文; - 业务改
global.setTimeout、删 entry_guard 白名单均无效(检查包装安装于 protect() 时,业务只能看到检查包装)。
6. 影响面清单
| 层 | 文件 | 改动 |
|---|---|---|
| JS | lib/global/ohos/entry_guard.js |
_perCardTimerPermitted + _rootContextStack + _setTimerPermission({rootId, permitted}) + push/pop/withRootContext + __flexuiTimerPolicy 暴露 + 检查包装(deny 带 timerPermissionDenied 标记、allow 时回调 wrap);移除 _timerPermitted/allowTimer 读取 |
| JS | lib/global/Event.js |
__loadInstance__ push/pop + deny 上报 FlexUIEngineInternal.cardLoadFailed(不外抛);__unloadInstance__ 清理 per-card 标志 |
| JS | packages/flexui/src/func_arr.ts |
safeCallback 明确为统一异常捕获点:捕获所有业务异常 → dispatchError 上报(日志 + globalOptions.onError)→ 不外抛(规则见 CLAUDE.md「JS 框架代码异常处理规范」) |
| JS | packages/flexui-backend/src/native-backend/engine-timer-policy.ts(新增) |
enterCardContext/leaveCardContext/withCardContext/clearCardTimerPermission(加载期 capture __flexuiTimerPolicy) |
| JS | packages/flexui-backend/src/native-backend/NativeBackendContext.ts |
receiveComponentEvent/flushAndBuild push/pop;destroy 兜底清理 |
| ETS | FlexUIEngineContext.ets(接口)+ FlexUIEngineContextImpl.ets |
setCardTimerPermission(rootId, permitted)(替换 setTimerPermission);reportCardLoadFailed + setCardLoadFailedCallback |
| ETS | FlexUIEngine.ets(framework):EngineInitParams 删 allowTimer;ModuleLoadParams 加 allowTimer;ModuleLoadStatus 加 STATUS_ERR_INSTANCE = -205 |
参数下沉/状态码 |
| ETS | FlexUIBridgeManagerImpl.ets |
删 mAllowTimer/构造参数/global config allowTimer 推送 |
| ETS | FlexUIModuleManagerImpl.ets + modules/native/FlexUIEngineModule.ets(新增) |
注册引擎内置 native 模块 FlexUIEngineInternal(cardLoadFailed → 引擎路由) |
| ETS | FlexUIEngineManagerImpl.ets |
CardSession.allowTimer;loadModuleWithListener 注册失败路由回调;loadJsModule bundle 加载前下发 per-card 权限 |
| ETS | flexui/FlexUIEngine.ets(facade)、FlexView.ets、FlexViewBuilder.ets |
loadCard(..., allowTimer?);FlexView.allowTimer;FlexViewBuilder.setAllowTimer;删 FlexUIEngineConfig.allowTimer |
| 文档 | docs/design/js-native-call-timer-policy.md、docs/flexui-engine/flexui-engine-arkts-api.md(2026-09 自 docs/learn/ 迁移)、docs/design/js-native-call-origin.md |
粒度升级、runscript 放行、失败管控语义、API 变更 |
注意:driver/js/lib/** 改动后需 cd flexui-engine/driver/js && npm run buildcore;driver/js/packages/** 改动后需 ./build.sh build-jsfwk(或 build-engine)。
7. 验证清单
- runscript(
runScriptFromUri)默认可建定时器,无需任何特批; - 卡片默认拒绝:卡片 JS
setTimeout/setInterval/requestIdleCallback/requestAnimationFrame→ 同步抛permission denied (timer not permitted, card=<rootId>); - 卡片初始化阶段(loadInstance)未捕获 deny →
onLoadCompleted(STATUS_ERR_INSTANCE)+onJsException双回调; - 卡片运行期(事件/回调)deny →
onJsException,不额外产生加载失败回调; FlexView.allowTimer=true/setCardTimerPermission(rootId, true)后 → 正常注册、C++ 到点回调正常执行;回调内再建定时器归属本卡片;setCardTimerPermission(rootId, false)收回 → 新注册拒绝、存量定时器自然到期;clear 类可清理残留 timerId;- 同 scope 多卡片不同权限互不影响;runscript 与卡片权限互不影响;
- 卡片销毁后 rootId 复用:per-card 标志已清理,新卡片默认拒绝;
- 业务尝试自开开关(枚举 jsModuleList 找
setTimerPermission、覆盖global.setTimeout、访问__flexuiTimerPolicy、删 entry_guard 白名单)→ 全部无效; - 框架内部定时器(bootstrap/flexui-backend)不受检查影响;
- 全量回归(卡片渲染、agent 全流程、多 scope 场景、引擎内部定时器路径)。