review: M5 双 Web 审查后的质量加固(两轮真机验证)

八角度审查双 Web 批次。修确凿问题:

铁律风险(最高)
- network_config:base-config cleartextTrafficPermitted 由 false 改 true。我之前的"白名单收紧"比平台默认更严,
  会拦掉 H5 可能访问的任意第三方明文 http 与本地 127.0.0.1 上传 → 违反 H5 零改动。改回允许明文(对齐原 Android)

正确性
- 子游戏 controller 不再复用:每次进子游戏 new WebviewController()(存 slotS.controller),消除"复用同一 controller
  时 onControllerAttached 不再触发致第二个子游戏建不出桥"的竞态。真机两轮验证 subB 桥正常 ✓
- BridgeController.dispose 置 active=false:显式抑制 dispose 后能力延迟异步 callHandler(不靠 adapter emitter-off 偶然安全)
- WebSlot.resumeLoaded 补发 setPostUrl:覆盖"非激活期渲染崩溃重载后 onFirstProgress 被闸门抑制丢失上传地址"。真机验证返回必带 setPostUrl ✓
- onRenderExited 不可见时延迟重载:非激活槽崩溃标记 needReload,激活时再 reload(官方建议可见后重载);activate 返回是否重载,resumeLoaded 据此避免重复推送
- 容器 aboutToDisappear:先退订 cancels 再 dispose 槽,关闭销毁期事件又建槽窗口
- LocationProvider:权限对话框 await 后加 disposed 守卫(防 onDestroy 后再注册监听泄漏+打已弃桥)
- AudioProvider:createAVPlayer.then 加 disposed 守卫(创建期已销毁则直接 release,防孤儿播放器泄漏)
- NetworkProvider.onForeground:重订阅后补播当前网络(覆盖非激活期网络变化,避免 H5 状态陈旧)

研判驳回:network_config 需 metadata 关联(官方文档证实 profile 路径自动识别);app 前后台只发 activeSlot(非激活大厅已收 appservice'2',正确)。
延期:WeChatApi 单例陈旧回调(随微信集成)。

devecocli build + scripts/test.sh 通过;模拟器两轮(大厅↔subA↔大厅↔subB↔大厅)无回归。
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
lanterngamescn
2026-06-26 00:13:44 +08:00
parent c0addf8ec2
commit 9be1a23635
8 changed files with 62 additions and 27 deletions
@@ -22,7 +22,8 @@ export struct BridgeGameContainer {
pathStack: NavPathStack = new NavPathStack();
params: BridgeGameParams = { entryUrl: '', launchType: '0' };
private controllerL: webview.WebviewController = new webview.WebviewController();
private controllerS: webview.WebviewController = new webview.WebviewController();
// 子游戏 controller 不复用:每次进子游戏新建(随 slotS),避免复用同一 controller
// onControllerAttached 不再触发导致第二个子游戏建不出桥(review 发现)。存于 slotS.controller。
private uploadServer: LocalUploadServer = new LocalUploadServer();
private hostCtx: common.UIAbilityContext | undefined = undefined;
private config: ConfigManager | undefined = undefined;
@@ -41,12 +42,13 @@ export struct BridgeGameContainer {
}
aboutToDisappear(): void {
this.slotS?.dispose();
this.slotL?.dispose();
// 先退订事件,再 dispose——避免销毁期 SWITCH_GAME/FOREGROUND 等事件又触发建槽/回调
for (const cancel of this.cancels) {
cancel();
}
this.cancels = [];
this.slotS?.dispose();
this.slotL?.dispose();
this.uploadServer.stop();
this.slotS = undefined;
this.slotL = undefined;
@@ -134,7 +136,7 @@ export struct BridgeGameContainer {
const values: AppDataValues = AppDataInjector.buildValues(cfg.getLocal(), `${res.localVersion()}`, '1');
AppDataInjector.inject(subDir, values);
this.slotL?.deactivate();
this.slotS = new WebSlot('subgame', this.controllerS, true, this.uploadServer);
this.slotS = new WebSlot('subgame', new webview.WebviewController(), true, this.uploadServer);
this.applyOrientation(d.webtype !== '2'); // "3" 横 / "2" 竖
this.subgameUrl = `file://${subDir}/index.html?Launchtype=1`; // 触发子游戏 Web 渲染 → setupSubgame
}
@@ -192,8 +194,8 @@ export struct BridgeGameContainer {
.onRenderExited((event: OnRenderExitedEvent) => this.slotL?.onRenderExited(event.renderExitReason))
// 子游戏 Web(临时,subgameUrl 非空时存在)
if (this.subgameUrl !== '') {
Web({ src: this.subgameUrl, controller: this.controllerS })
if (this.subgameUrl !== '' && this.slotS !== undefined) {
Web({ src: this.subgameUrl, controller: this.slotS.controller })
.javaScriptAccess(true).domStorageAccess(true).fileAccess(true)
.mixedMode(MixedMode.All).cacheMode(CacheMode.None)
.geolocationAccess(true).zoomAccess(false)
+32 -7
View File
@@ -29,6 +29,7 @@ export class WebSlot {
private firstDone: boolean = false;
private active: boolean;
private disposed: boolean = false;
private needReload: boolean = false;
constructor(role: string, controller: webview.WebviewController, active: boolean, uploadServer: LocalUploadServer) {
this.role = role;
@@ -89,12 +90,22 @@ export class WebSlot {
if (this.disposed) {
return;
}
WebSlot.log.w(`[${this.role}] render exited (reason=${reason}); reloading`);
WebSlot.log.w(`[${this.role}] render exited (reason=${reason})`);
this.firstDone = false;
if (this.active) {
this.doReload();
} else {
// 不可见(非激活/隐藏)时不立即重载,标记待激活时重载(官方建议:可见后再 reload)
this.needReload = true;
}
}
private doReload(): void {
this.needReload = false;
try {
this.controller.refresh();
} catch (e) {
WebSlot.log.e(`[${this.role}] refresh after render exit failed: ${(e as BusinessError).message}`);
WebSlot.log.e(`[${this.role}] reload after render exit failed: ${(e as BusinessError).message}`);
}
}
@@ -113,8 +124,8 @@ export class WebSlot {
this.flushPending();
}
/** 激活(onActive + 桥放行 + 能力前台)。页面已加载的大厅返回时另调 resumeLoaded 补发 appservice。 */
activate(): void {
/** 激活(onActive + 桥放行 + 能力前台)。返回是否触发了延迟重载(非激活期渲染崩溃 → 此刻重载)。 */
activate(): boolean {
this.active = true;
try {
this.controller.onActive();
@@ -123,12 +134,26 @@ export class WebSlot {
}
this.bridgeCtrl?.setActive(true);
this.registrar?.forEachForeground();
if (this.needReload) {
this.doReload(); // 重载后由 onFirstProgress 补 appservice/setPostUrl
return true;
}
return false;
}
/** 大厅从子游戏返回(页面仍在、无新 100%):激活并直接补发 appservice('1') + 待发 getWebdata。 */
/** 大厅从子游戏返回(页面仍在、无新 100%):激活并补发 appservice('1') + setPostUrl + 待发 getWebdata。
* 补发 setPostUrl:覆盖"非激活期渲染崩溃重载后 onFirstProgress 被闸门抑制而丢失上传地址"的情形。 */
resumeLoaded(): void {
this.activate();
this.bridgeCtrl?.callHandler(OutboundHandlers.AppService, '1');
const reloaded: boolean = this.activate();
if (reloaded) {
// 页面正在重载(非激活期崩溃恢复):由重载后的 onFirstProgress 补发 appservice/setPostUrl/getWebdata
return;
}
const b = this.bridgeCtrl;
if (b !== undefined) {
b.callHandler(OutboundHandlers.AppService, '1');
b.callHandler(OutboundHandlers.SetPostUrl, this.uploadServer.baseUrl());
}
this.flushPending();
}