review: M0/M1 成果审查后的质量加固

三路独立审查:桥引擎(对照 lzyzsd 逐字)与契约 SSOT(对照原 Android 44 handler)
两个命门维度【零问题】;以下为架构/质量改进项的修复:

- [安全红线] BridgeGameContainer setWebDebuggingAccess 加 AppEnv.isDebug() 守卫
  (common 新增 AppEnv,用 bundleManager.appInfo.debug 运行时判断,release 自动关闭,
  消除 CLAUDE.md 附录B 红线违反)
- [生命周期] BridgeController 加 dispose() 清回执表/注册表/启动队列;HandlerRegistry 加
  clear();容器 aboutToDisappear 先 dispose 桥再 dispose adapter 并置空,防残留命中/泄漏
- [事件总线] EventBus.on/once 返回取消函数支持 per-subscriber 精确退订;off→offAll 并
  标注谨慎(避免 M3 多容器订阅 appservice 时误注销他人回调)
- [单测] 加强 escape 断言覆盖内层 " 加倍(命门);新增 dispose 用例;16 用例全通过,
  桥核心覆盖率 89.0%
- [记录] Index.pageMap param as 运行期不校验,M4 外部 scheme 唤起时需补校验(已注释)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
lanterngamescn
2026-06-25 13:16:37 +08:00
co-authored by Claude Opus 4.8
parent 74b302e131
commit bdb920f61e
8 changed files with 90 additions and 15 deletions
+1
View File
@@ -6,3 +6,4 @@ export { BridgeTracer } from './src/main/ets/log/BridgeTracer';
export { EventBus, EventPayload } from './src/main/ets/event/EventBus'; export { EventBus, EventPayload } from './src/main/ets/event/EventBus';
export { DIContainer, Factory } from './src/main/ets/di/DIContainer'; export { DIContainer, Factory } from './src/main/ets/di/DIContainer';
export { ErrorCenter, ErrorStrategy, ErrorSink } from './src/main/ets/error/ErrorCenter'; export { ErrorCenter, ErrorStrategy, ErrorSink } from './src/main/ets/error/ErrorCenter';
export { AppEnv } from './src/main/ets/env/AppEnv';
+25
View File
@@ -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;
}
}
+19 -10
View File
@@ -11,18 +11,27 @@ import { emitter } from '@kit.BasicServicesKit';
export type EventPayload = Record<string, Object>; export type EventPayload = Record<string, Object>;
export class EventBus { export class EventBus {
/** 持续订阅。 */ /** 持续订阅。返回取消函数——调用它仅注销本次订阅(per-subscriber,不影响其他订阅者)。
static on(eventId: string, callback: (payload?: EventPayload) => void): void { * 多容器/多订阅者共享同一 eventId(如 appservice 前后台)时务必用返回值退订,勿用 off(eventId)。 */
emitter.on(eventId, (ev: emitter.EventData) => { static on(eventId: string, callback: (payload?: EventPayload) => void): () => void {
const wrapper = (ev: emitter.EventData) => {
callback(ev.data); callback(ev.data);
}); };
emitter.on(eventId, wrapper);
return () => {
emitter.off(eventId, wrapper);
};
} }
/** 单次订阅,触发后自动取消。 */ /** 单次订阅,触发后自动取消。返回取消函数(触发前可主动退订)。 */
static once(eventId: string, callback: (payload?: EventPayload) => void): void { static once(eventId: string, callback: (payload?: EventPayload) => void): () => void {
emitter.once(eventId, (ev: emitter.EventData) => { const wrapper = (ev: emitter.EventData) => {
callback(ev.data); callback(ev.data);
}); };
emitter.once(eventId, wrapper);
return () => {
emitter.off(eventId, wrapper);
};
} }
/** 发布事件。 */ /** 发布事件。 */
@@ -31,8 +40,8 @@ export class EventBus {
emitter.emit(eventId, data); emitter.emit(eventId, data);
} }
/** 取消该事件的全部订阅。 */ /** 取消该事件的全部订阅(粗粒度)。多订阅者场景请改用 on() 返回的取消函数。 */
static off(eventId: string): void { static offAll(eventId: string): void {
emitter.off(eventId); emitter.off(eventId);
} }
} }
@@ -1,5 +1,6 @@
import { webview } from '@kit.ArkWeb'; import { webview } from '@kit.ArkWeb';
import { BridgeController, WebviewControllerAdapter, BridgeJsLoader } from 'feature_bridge'; import { BridgeController, WebviewControllerAdapter, BridgeJsLoader } from 'feature_bridge';
import { AppEnv } from 'common';
import { BridgeGameParams } from '../routes/AppRoutes'; import { BridgeGameParams } from '../routes/AppRoutes';
/** /**
@@ -17,15 +18,24 @@ export struct BridgeGameContainer {
private bridge: BridgeController | undefined = undefined; private bridge: BridgeController | undefined = undefined;
aboutToAppear(): void { aboutToAppear(): void {
// 开发期开启远程调试;M5 加固时用 BuildProfile.DEBUG 守卫、release 必关(附录 B // 远程调试仅 debug 包开启,release 自动关闭(CLAUDE.md 附录 B 红线
webview.WebviewController.setWebDebuggingAccess(true); if (AppEnv.isDebug()) {
webview.WebviewController.setWebDebuggingAccess(true);
}
} }
aboutToDisappear(): void { aboutToDisappear(): void {
// 释放:先清桥(回执表/注册表/启动队列),再断 UI 线程通道,最后置空防残留命中
const b = this.bridge;
if (b !== undefined) {
b.dispose();
}
const a = this.adapter; const a = this.adapter;
if (a !== undefined) { if (a !== undefined) {
a.dispose(); a.dispose();
} }
this.bridge = undefined;
this.adapter = undefined;
} }
private setupBridge(): void { private setupBridge(): void {
+2
View File
@@ -17,6 +17,8 @@ struct Index {
this.pathStack.pushPathByName(RouteName.SPLASH, '', false); this.pathStack.pushPathByName(RouteName.SPLASH, '', false);
} }
// 注:param as T 为编译期断言、运行期不校验。本期由 Splash 固定传对象,安全;
// M4 接入外部 scheme 唤起(gamepaywelcome 等深链)时,需在此对 param 做运行期类型校验后再下发。
@Builder @Builder
pageMap(name: string, param: object) { pageMap(name: string, param: object) {
if (name === RouteName.SPLASH) { if (name === RouteName.SPLASH) {
+18 -3
View File
@@ -34,12 +34,17 @@ export default function bridgeTest() {
}); });
it('escape_quotes_backslash', 0, () => { it('escape_quotes_backslash', 0, () => {
// JSON {"data":"a\"b"} → 结构引号转义、内部 \" 加倍 // data='a"b' → toJson 得 {"data":"a\"b"}(内部引号被 JSON 转义为 \"
const mm = new Message(); const mm = new Message();
mm.data = 'a"b'; 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(); expect(escaped.includes('\\"data\\"')).assertTrue();
// 第一步(命门):内部已转义的 \" 的反斜杠被加倍 → a 与 b 之间出现 \\\"(反斜杠×2+引号)
expect(escaped.includes('a\\\\\\"b')).assertTrue();
}); });
it('toArray_parses_queue', 0, () => { it('toArray_parses_queue', 0, () => {
@@ -173,6 +178,16 @@ export default function bridgeTest() {
expect(resp).assertEqual('1700'); 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, () => { it('default_handler_unregistered_no_throw', 0, () => {
const fake = new FakeWebController(); const fake = new FakeWebController();
const bridge = new BridgeController(fake); const bridge = new BridgeController(fake);
@@ -41,6 +41,14 @@ export class BridgeController {
this.bridgeJs = js; this.bridgeJs = js;
} }
/** 释放:清空回执表/注册表/启动队列,断开与已注册能力闭包的引用,防泄漏。
* 容器 aboutToDisappear 调用(注意:IWebController 由容器另行 dispose)。 */
dispose(): void {
this.responseCallbacks.clear();
this.registry.clear();
this.startupMessages = null;
}
// —— H5 → 原生:在 Web().onLoadIntercept 调用,返回 true 表示拦截、阻断真实导航 —— // —— H5 → 原生:在 Web().onLoadIntercept 调用,返回 true 表示拦截、阻断真实导航 ——
onLoadIntercept(rawUrl: string): boolean { onLoadIntercept(rawUrl: string): boolean {
let url: string; let url: string;
@@ -39,6 +39,11 @@ export class HandlerRegistry {
this.handlers.delete(name); this.handlers.delete(name);
} }
/** 清空全部已注册 handler(容器销毁时防泄漏;默认兜底 handler 保留)。 */
clear(): void {
this.handlers.clear();
}
has(name: string): boolean { has(name: string): boolean {
return this.handlers.has(name); return this.handlers.has(name);
} }