diff --git a/common/Index.ets b/common/Index.ets index 0855031..b3e28f4 100644 --- a/common/Index.ets +++ b/common/Index.ets @@ -6,3 +6,4 @@ export { BridgeTracer } from './src/main/ets/log/BridgeTracer'; export { EventBus, EventPayload } from './src/main/ets/event/EventBus'; export { DIContainer, Factory } from './src/main/ets/di/DIContainer'; export { ErrorCenter, ErrorStrategy, ErrorSink } from './src/main/ets/error/ErrorCenter'; +export { AppEnv } from './src/main/ets/env/AppEnv'; diff --git a/common/src/main/ets/env/AppEnv.ets b/common/src/main/ets/env/AppEnv.ets new file mode 100644 index 0000000..2e0f5fa --- /dev/null +++ b/common/src/main/ets/env/AppEnv.ets @@ -0,0 +1,25 @@ +/** + * 运行环境判断(生产加固,对应 CLAUDE.md 附录 B 红线)。 + * + * isDebug():是否为 debug 包(debug 签名)。用于守卫仅 debug 可开的能力, + * 如 setWebDebuggingAccess——release 包 appInfo.debug 为 false,自动关闭远程调试。 + * 等价 BuildProfile.DEBUG 的意图,但用运行时签名信息判断、不依赖构建期字段注入。 + */ +import { bundleManager } from '@kit.AbilityKit'; + +export class AppEnv { + private static debugCache: boolean | undefined = undefined; + + static isDebug(): boolean { + if (AppEnv.debugCache === undefined) { + try { + const info = bundleManager.getBundleInfoForSelfSync( + bundleManager.BundleFlag.GET_BUNDLE_INFO_WITH_APPLICATION); + AppEnv.debugCache = info.appInfo.debug; + } catch (e) { + AppEnv.debugCache = false; // 取不到时保守按 release(不开调试) + } + } + return AppEnv.debugCache; + } +} diff --git a/common/src/main/ets/event/EventBus.ets b/common/src/main/ets/event/EventBus.ets index 046adf9..30ac6bd 100644 --- a/common/src/main/ets/event/EventBus.ets +++ b/common/src/main/ets/event/EventBus.ets @@ -11,18 +11,27 @@ import { emitter } from '@kit.BasicServicesKit'; export type EventPayload = Record; export class EventBus { - /** 持续订阅。 */ - static on(eventId: string, callback: (payload?: EventPayload) => void): void { - emitter.on(eventId, (ev: emitter.EventData) => { + /** 持续订阅。返回取消函数——调用它仅注销本次订阅(per-subscriber,不影响其他订阅者)。 + * 多容器/多订阅者共享同一 eventId(如 appservice 前后台)时务必用返回值退订,勿用 off(eventId)。 */ + static on(eventId: string, callback: (payload?: EventPayload) => void): () => void { + const wrapper = (ev: emitter.EventData) => { callback(ev.data); - }); + }; + emitter.on(eventId, wrapper); + return () => { + emitter.off(eventId, wrapper); + }; } - /** 单次订阅,触发后自动取消。 */ - static once(eventId: string, callback: (payload?: EventPayload) => void): void { - emitter.once(eventId, (ev: emitter.EventData) => { + /** 单次订阅,触发后自动取消。返回取消函数(触发前可主动退订)。 */ + static once(eventId: string, callback: (payload?: EventPayload) => void): () => void { + const wrapper = (ev: emitter.EventData) => { callback(ev.data); - }); + }; + emitter.once(eventId, wrapper); + return () => { + emitter.off(eventId, wrapper); + }; } /** 发布事件。 */ @@ -31,8 +40,8 @@ export class EventBus { emitter.emit(eventId, data); } - /** 取消该事件的全部订阅。 */ - static off(eventId: string): void { + /** ⚠ 取消该事件的【全部】订阅(粗粒度)。多订阅者场景请改用 on() 返回的取消函数。 */ + static offAll(eventId: string): void { emitter.off(eventId); } } diff --git a/entry/src/main/ets/pages/BridgeGameContainer.ets b/entry/src/main/ets/pages/BridgeGameContainer.ets index 3a4b83d..34c7940 100644 --- a/entry/src/main/ets/pages/BridgeGameContainer.ets +++ b/entry/src/main/ets/pages/BridgeGameContainer.ets @@ -1,5 +1,6 @@ import { webview } from '@kit.ArkWeb'; import { BridgeController, WebviewControllerAdapter, BridgeJsLoader } from 'feature_bridge'; +import { AppEnv } from 'common'; import { BridgeGameParams } from '../routes/AppRoutes'; /** @@ -17,15 +18,24 @@ export struct BridgeGameContainer { private bridge: BridgeController | undefined = undefined; aboutToAppear(): void { - // 开发期开启远程调试;M5 加固时用 BuildProfile.DEBUG 守卫、release 必关(附录 B) - webview.WebviewController.setWebDebuggingAccess(true); + // 远程调试仅 debug 包开启,release 自动关闭(CLAUDE.md 附录 B 红线) + if (AppEnv.isDebug()) { + webview.WebviewController.setWebDebuggingAccess(true); + } } aboutToDisappear(): void { + // 释放:先清桥(回执表/注册表/启动队列),再断 UI 线程通道,最后置空防残留命中 + const b = this.bridge; + if (b !== undefined) { + b.dispose(); + } const a = this.adapter; if (a !== undefined) { a.dispose(); } + this.bridge = undefined; + this.adapter = undefined; } private setupBridge(): void { diff --git a/entry/src/main/ets/pages/Index.ets b/entry/src/main/ets/pages/Index.ets index d9a4e06..057a4cd 100644 --- a/entry/src/main/ets/pages/Index.ets +++ b/entry/src/main/ets/pages/Index.ets @@ -17,6 +17,8 @@ struct Index { this.pathStack.pushPathByName(RouteName.SPLASH, '', false); } + // 注:param as T 为编译期断言、运行期不校验。本期由 Splash 固定传对象,安全; + // M4 接入外部 scheme 唤起(gamepaywelcome 等深链)时,需在此对 param 做运行期类型校验后再下发。 @Builder pageMap(name: string, param: object) { if (name === RouteName.SPLASH) { diff --git a/entry/src/test/Bridge.test.ets b/entry/src/test/Bridge.test.ets index fc2dc8f..f30c08e 100644 --- a/entry/src/test/Bridge.test.ets +++ b/entry/src/test/Bridge.test.ets @@ -34,12 +34,17 @@ export default function bridgeTest() { }); it('escape_quotes_backslash', 0, () => { - // JSON {"data":"a\"b"} → 结构引号转义、内部 \" 加倍 + // data='a"b' → toJson 得 {"data":"a\"b"}(内部引号被 JSON 转义为 \") const mm = new Message(); mm.data = 'a"b'; - const escaped = MessageCodec.escape(MessageCodec.toJson(mm)); - // 结构引号被转义为 \"(前面非反斜杠) + const json = MessageCodec.toJson(mm); + // toJson 内部:a 与 b 之间是 反斜杠+引号 + expect(json.includes('a\\"b')).assertTrue(); + const escaped = MessageCodec.escape(json); + // 第二步:结构引号(前面非反斜杠)被转义为 \" expect(escaped.includes('\\"data\\"')).assertTrue(); + // 第一步(命门):内部已转义的 \" 的反斜杠被加倍 → a 与 b 之间出现 \\\"(反斜杠×2+引号) + expect(escaped.includes('a\\\\\\"b')).assertTrue(); }); it('toArray_parses_queue', 0, () => { @@ -173,6 +178,16 @@ export default function bridgeTest() { expect(resp).assertEqual('1700'); }); + it('dispose_clears_registry_and_callbacks', 0, () => { + const fake = new FakeWebController(); + const bridge = new BridgeController(fake); + bridge.registerHandler('a', (_d, cb) => cb('A')); + expect(bridge.getRegistry().has('a')).assertTrue(); + bridge.dispose(); + // dispose 清空注册表(默认兜底保留) + expect(bridge.getRegistry().has('a')).assertFalse(); + }); + it('default_handler_unregistered_no_throw', 0, () => { const fake = new FakeWebController(); const bridge = new BridgeController(fake); diff --git a/feature_bridge/src/main/ets/core/BridgeController.ets b/feature_bridge/src/main/ets/core/BridgeController.ets index de4c2e9..ca64c13 100644 --- a/feature_bridge/src/main/ets/core/BridgeController.ets +++ b/feature_bridge/src/main/ets/core/BridgeController.ets @@ -41,6 +41,14 @@ export class BridgeController { this.bridgeJs = js; } + /** 释放:清空回执表/注册表/启动队列,断开与已注册能力闭包的引用,防泄漏。 + * 容器 aboutToDisappear 调用(注意:IWebController 由容器另行 dispose)。 */ + dispose(): void { + this.responseCallbacks.clear(); + this.registry.clear(); + this.startupMessages = null; + } + // —— H5 → 原生:在 Web().onLoadIntercept 调用,返回 true 表示拦截、阻断真实导航 —— onLoadIntercept(rawUrl: string): boolean { let url: string; diff --git a/feature_bridge/src/main/ets/core/HandlerRegistry.ets b/feature_bridge/src/main/ets/core/HandlerRegistry.ets index 55fa954..6dc52a1 100644 --- a/feature_bridge/src/main/ets/core/HandlerRegistry.ets +++ b/feature_bridge/src/main/ets/core/HandlerRegistry.ets @@ -39,6 +39,11 @@ export class HandlerRegistry { this.handlers.delete(name); } + /** 清空全部已注册 handler(容器销毁时防泄漏;默认兜底 handler 保留)。 */ + clear(): void { + this.handlers.clear(); + } + has(name: string): boolean { return this.handlers.has(name); }