M5(T-M5-01/02): 双 Web 大厅/子游戏架构 + WebSlot 激活协议(真机验证通过)

- 新增 entry/web/WebSlot:封装单 Web 的桥+能力+激活生命周期(setup/onLoadIntercept/onPageEnd/
  onFirstProgress/activate/resumeLoaded/deactivate/onAppForeground/onAppBackground/dispose)
- BridgeGameContainer 重写为 Stack 双 Web:大厅常驻(始终在 Stack,subgame 时 Hidden+onInactive)、
  子游戏临时(subgameUrl 非空时渲染,onControllerAttached 各自 setup);替换 M2/M4 单 WebView loadUrl
- 激活协议:进子游戏 deactivate(大厅 appservice'2'+forEachBackground+setActive(false)+onInactive)→
  注入子游戏 app_data(LT'1')→建子游戏 slot→onFirstProgress(appservice'1'+setPostUrl);
  返回 dispose 子游戏→大厅 resumeLoaded(appservice'1'+getWebdata)
- 每槽独立 BridgeController+buildCapabilities(结构隔离,接口不串);nav/前后台事件路由到 activeSlot
- 内置测试包升级:gamehall+subA 自动驱动页(大厅→SwitchOverGameData→子游戏→backgameData→大厅)

真机验证(Pura 90 全新装):LOBBY LT=0 appservice1→SwitchOverGameData→LOBBY appservice2→
SUB LT=1 appservice1+独立 getTime→backgameData→LOBBY appservice1+getWebdata=fromSub。
大厅无二次 ready=未重载=常驻保活 ✓;接口不串 ✓;只一个激活 ✓。无 registration failed/RenderExited。
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
lanterngamescn
2026-06-25 23:32:18 +08:00
parent a865ac766b
commit d7148a72e6
3 changed files with 268 additions and 131 deletions
+109 -131
View File
@@ -2,138 +2,104 @@ import { webview } from '@kit.ArkWeb';
import { common } from '@kit.AbilityKit';
import { window } from '@kit.ArkUI';
import { BusinessError } from '@kit.BasicServicesKit';
import { BridgeController, WebviewControllerAdapter, BridgeJsLoader } from 'feature_bridge';
import { CapabilityContext, CapabilityRegistrar } from 'feature_capabilities';
import { ConfigManager, ResourceManager, AppDataInjector, AppDataValues } from 'domain_resource';
import { KvStore, LocalUploadServer } from 'platform';
import { OutboundHandlers } from 'contracts';
import { AppEnv, EventBus, EventPayload, NavEvents, OpenGenericWebPayload, SwitchGamePayload,
BackGamePayload, Logger } from 'common';
import { BridgeGameParams, GenericWebParams, GenericWebResult, RouteName, AppEvents } from '../routes/AppRoutes';
import { buildCapabilities } from '../di/AppModule';
import { WebSlot } from '../web/WebSlot';
/**
* 大厅/子游戏容器(对应 webviewActivity,框架 §7.1)。
* 挂 Web + 接桥 + 经组装根注册全部能力 + 首次进度 100% 推 appservice/setPostUrl
* 子游戏切换(路径 A)/打开通用容器(路径 B)/返回大厅 经 NavEvents 由 NavProvider 触发、本容器执行。
* 大厅/子游戏容器(对应 webviewActivity,框架 §7.1/§7.5)。**双 Web 槽模型**
* - 大厅 Web 常驻(始终在 Stack);子游戏 Web 进入创建/返回销毁(subgameUrl 空/非空驱动)
* - 同时只有一个激活:进子游戏 → 大厅 deactivate(appservice'2'+停能力+桥关+onInactive+隐藏)、子游戏 activate
* 返回大厅 → 销毁子游戏、大厅 resumeLoaded(appservice'1'+getWebdata)。
* - 每槽独立桥+能力(结构隔离,接口不串);cookie 全局共享、localStorage file:// 同源共享(已验证)。
*/
@Component
export struct BridgeGameContainer {
pathStack: NavPathStack = new NavPathStack();
params: BridgeGameParams = { entryUrl: '', launchType: '0' };
private controller: webview.WebviewController = new webview.WebviewController();
private adapter: WebviewControllerAdapter | undefined = undefined;
private bridge: BridgeController | undefined = undefined;
private registrar: CapabilityRegistrar | undefined = undefined;
private controllerL: webview.WebviewController = new webview.WebviewController();
private controllerS: webview.WebviewController = new webview.WebviewController();
private uploadServer: LocalUploadServer = new LocalUploadServer();
private hostCtx: common.UIAbilityContext | undefined = undefined;
private config: ConfigManager | undefined = undefined;
private kv: KvStore | undefined = undefined;
private resource: ResourceManager | undefined = undefined;
private firstProgressDone: boolean = false;
private pendingWebdata: string | undefined = undefined;
private slotL: WebSlot | undefined = undefined;
private slotS: WebSlot | undefined = undefined;
private cancels: Array<() => void> = [];
/** 非空 → 子游戏 Web 显示(路径 A 切换)。 */
@State private subgameUrl: string = '';
aboutToAppear(): void {
// 远程调试仅 debug 包开启,release 自动关闭(CLAUDE.md 附录 B 红线)
if (AppEnv.isDebug()) {
webview.WebviewController.setWebDebuggingAccess(true);
}
// 事件订阅移到 setupBridge(桥就位后),避免冷启动事件早于桥到达被丢
}
aboutToDisappear(): void {
this.registrar?.destroyAll();
this.bridge?.dispose();
this.adapter?.dispose();
this.slotS?.dispose();
this.slotL?.dispose();
for (const cancel of this.cancels) {
cancel();
}
this.cancels = [];
this.uploadServer.stop();
this.registrar = undefined;
this.bridge = undefined;
this.adapter = undefined;
this.slotS = undefined;
this.slotL = undefined;
this.hostCtx = undefined;
this.config = undefined;
this.kv = undefined;
this.resource = undefined;
}
/** 页面进度首次 100% 时下发 appservice/setPostUrl+子游戏返回大厅待发的 getWebdata)。幂等。
* firstProgressDone 在子游戏切换/返回大厅时复位,使重载后再次下发。 */
private firstReadyPush(): void {
if (this.firstProgressDone) {
/** 懒初始化共享依赖(两槽共用 config/kv/resource+ 订阅事件。 */
private ensureDeps(): void {
if (this.hostCtx !== undefined) {
return;
}
this.firstProgressDone = true;
const b = this.bridge;
if (b !== undefined) {
b.callHandler(OutboundHandlers.AppService, '1');
b.callHandler(OutboundHandlers.SetPostUrl, this.uploadServer.baseUrl());
if (this.pendingWebdata !== undefined) {
b.callHandler(OutboundHandlers.GetWebData, this.pendingWebdata);
this.pendingWebdata = undefined;
}
}
}
/** onControllerAttached:建桥 + 经组装根注册全部能力(框架 §A.1)。 */
private setupBridge(): void {
const hostCtx: common.UIAbilityContext = this.getUIContext().getHostContext() as common.UIAbilityContext;
this.hostCtx = hostCtx;
// 🔴 V2 file:// 跨域(框架 §7.3):资源根加入"允许跨域访问"白名单(单一来源取自 ResourceManager
try {
this.controller.setPathAllowingUniversalAccess([ResourceManager.resourceRoot(hostCtx)]);
} catch (e) {
Logger.tag('BridgeGameContainer').e(`setPathAllowingUniversalAccess failed: ${(e as Error).message}`);
}
// 🔴 先建桥并就位:能力注册/配置加载抛错也不致桥失效(未注册 handler 落 DefaultHandler,铁律兜底)
const adapter = new WebviewControllerAdapter(this.controller);
const bridge = new BridgeController(adapter);
this.adapter = adapter;
this.bridge = bridge;
try {
bridge.setBridgeJs(BridgeJsLoader.load(hostCtx));
const config: ConfigManager = ConfigManager.load(hostCtx);
const kv: KvStore = KvStore.create(hostCtx);
this.config = config;
this.resource = new ResourceManager(hostCtx, config.getLocal(), kv);
const ctx: CapabilityContext = {
uiAbilityContext: hostCtx,
config,
kv,
uploadServer: this.uploadServer,
log: Logger.tag('Capability'),
};
const registrar = new CapabilityRegistrar(buildCapabilities());
registrar.registerAll(bridge, ctx);
this.registrar = registrar;
} catch (e) {
Logger.tag('BridgeGameContainer').e(`capability registration failed (bridge still usable): ${(e as Error).message}`);
}
this.subscribeEvents();
this.config = ConfigManager.load(hostCtx);
this.kv = KvStore.create(hostCtx);
this.resource = new ResourceManager(hostCtx, this.config.getLocal(), this.kv);
this.uploadServer.start();
this.subscribeEvents();
}
private resourceRoot(): string {
return ResourceManager.resourceRoot(this.hostCtx as common.UIAbilityContext);
}
private setupLobby(): void {
this.ensureDeps();
if (this.slotL === undefined) {
this.slotL = new WebSlot('lobby', this.controllerL, true, this.uploadServer);
}
this.slotL.setup(this.hostCtx!, this.config!, this.kv!, this.resourceRoot());
}
private setupSubgame(): void {
this.ensureDeps();
this.slotS?.setup(this.hostCtx!, this.config!, this.kv!, this.resourceRoot());
}
private activeSlot(): WebSlot | undefined {
return this.subgameUrl !== '' ? this.slotS : this.slotL;
}
/** 订阅前后台 + 导航事件(桥就位后)。 */
private subscribeEvents(): void {
this.cancels.push(EventBus.on(AppEvents.FOREGROUND, () => {
this.registrar?.forEachForeground();
this.bridge?.callHandler(OutboundHandlers.AppService, '1');
}));
this.cancels.push(EventBus.on(AppEvents.BACKGROUND, () => {
this.registrar?.forEachBackground();
this.bridge?.callHandler(OutboundHandlers.AppService, '2');
}));
this.cancels.push(EventBus.on(AppEvents.FOREGROUND, () => this.activeSlot()?.onAppForeground()));
this.cancels.push(EventBus.on(AppEvents.BACKGROUND, () => this.activeSlot()?.onAppBackground()));
this.cancels.push(EventBus.on(NavEvents.OPEN_GENERIC_WEB, (p?: EventPayload) => this.openGenericWeb(p)));
this.cancels.push(EventBus.on(NavEvents.SWITCH_GAME, (p?: EventPayload) => this.switchGame(p)));
this.cancels.push(EventBus.on(NavEvents.BACK_GAME, (p?: EventPayload) => this.backToLobby(p)));
}
/** 路径 B:打开通用网页容器;关闭回传 → 上层 callHandler('getWebdata')。 */
/** 路径 B:打开通用网页容器;关闭回传 → 当前激活槽 callHandler('getWebdata')。 */
private openGenericWeb(p?: EventPayload): void {
if (p === undefined) {
return;
@@ -142,41 +108,45 @@ export struct BridgeGameContainer {
const params: GenericWebParams = { url: d.url, title: d.title, data: d.data, orientation: d.orientation };
this.pathStack.pushPathByName(RouteName.GENERIC_WEB, params, (info: PopInfo) => {
const result = info.result as GenericWebResult;
this.bridge?.callHandler(OutboundHandlers.GetWebData, result !== undefined ? result.data : '');
this.activeSlot()?.bridge()?.callHandler(OutboundHandlers.GetWebData, result !== undefined ? result.data : '');
}, false);
}
/** 路径 A同容器切换子游戏(loadUrl + 重注入 app_data + 方向 + 复位首屏标志)。 */
/** 路径 A大厅 → 子游戏(大厅去激活并保活,创建子游戏 Web)。 */
private switchGame(p?: EventPayload): void {
if (p === undefined) {
if (p === undefined || this.subgameUrl !== '') {
return;
}
const d = p as SwitchGamePayload;
const dir: string = d.dir;
const webtype: string = d.webtype;
const res = this.resource;
const cfg = this.config;
if (dir === '' || res === undefined || cfg === undefined) {
if (d.dir === '' || res === undefined || cfg === undefined) {
return;
}
const subDir: string = `${res.paths().urlpath}/${dir}`;
// 子游戏目录自带 app_data.jsH5 同步读),launchtype='1'
const subDir: string = `${res.paths().urlpath}/${d.dir}`;
// 子游戏目录自带 app_data.jsH5 同步读),launchtype='1'
const values: AppDataValues = AppDataInjector.buildValues(cfg.getLocal(), `${res.localVersion()}`, '1');
AppDataInjector.inject(subDir, values);
this.firstProgressDone = false; // 复位:切换后重推 appservice/setPostUrl
this.applyOrientation(webtype !== '2'); // "3" 横 / "2" 竖
this.controller.loadUrl(`file://${subDir}/index.html?Launchtype=1`);
this.slotL?.deactivate();
this.slotS = new WebSlot('subgame', this.controllerS, true, this.uploadServer);
this.applyOrientation(d.webtype !== '2'); // "3" 横 / "2" 竖
this.subgameUrl = `file://${subDir}/index.html?Launchtype=1`; // 触发子游戏 Web 渲染 → setupSubgame
}
/** 子游戏带数据返回大厅:重载大厅入口,加载完成后出站 getWebdata。 */
/** 子游戏 → 大厅(销毁子游戏 Web,大厅恢复并带回数据)。 */
private backToLobby(p?: EventPayload): void {
if (this.params.entryUrl === '' || p === undefined) {
if (this.subgameUrl === '') {
return;
}
this.pendingWebdata = (p as BackGamePayload).data;
this.firstProgressDone = false;
this.applyOrientation(true); // 大厅横屏
this.controller.loadUrl(this.params.entryUrl);
const data: string = p !== undefined ? (p as BackGamePayload).data : '';
this.slotS?.dispose();
this.slotS = undefined;
this.subgameUrl = ''; // 移除子游戏 Web
this.applyOrientation(true); // 大厅横屏
if (this.slotL !== undefined) {
this.slotL.pendingWebdata = data;
this.slotL.resumeLoaded();
}
}
private applyOrientation(landscape: boolean): void {
@@ -189,42 +159,50 @@ export struct BridgeGameContainer {
.catch((e: BusinessError) => Logger.tag('BridgeGameContainer').w(`orientation failed: ${e.message}`));
}
/** 入口 URL:params 非空用之,否则回退最小回显页(V1)。 */
private webSrc(): string | Resource {
/** 大厅入口 URL:params 非空用之,否则回退最小回显页(V1)。 */
private lobbySrc(): string | Resource {
return this.params.entryUrl !== '' ? this.params.entryUrl : $rawfile('test_echo.html');
}
build() {
NavDestination() {
Web({ src: this.webSrc(), controller: this.controller })
.javaScriptAccess(true)
.domStorageAccess(true)
.fileAccess(true)
.mixedMode(MixedMode.All)
.cacheMode(CacheMode.None)
.geolocationAccess(true)
.zoomAccess(false)
.width('100%')
.height('100%')
.onControllerAttached(() => {
this.setupBridge();
})
.onLoadIntercept((event: OnLoadInterceptEvent) => {
const url: string = event.data.getRequestUrl();
const b = this.bridge;
return b !== undefined ? b.onLoadIntercept(url) : false;
})
.onPageEnd(() => {
this.bridge?.onPageEnd();
})
.onProgressChange((event: OnProgressChangeEvent) => {
// 首次/每次切换后页面到 100% → 推 appservice(前台)+setPostUrl(对齐 Android onProgressChanged==100)。
// 用 100%(而非更早的 onPageEnd):确保 H5 在 WebViewJavascriptBridgeReady 里已注册 appservice/setPostUrl
// 等 handler,避免出站早于注册被丢。loadUrl 切子游戏/返回大厅会再次产生 100% 回调(已复位 firstProgressDone)。
if (event.newProgress === 100) {
this.firstReadyPush();
}
})
Stack() {
// 大厅 Web(常驻)
Web({ src: this.lobbySrc(), controller: this.controllerL })
.javaScriptAccess(true).domStorageAccess(true).fileAccess(true)
.mixedMode(MixedMode.All).cacheMode(CacheMode.None)
.geolocationAccess(true).zoomAccess(false)
.width('100%').height('100%')
.visibility(this.subgameUrl !== '' ? Visibility.Hidden : Visibility.Visible)
.onControllerAttached(() => this.setupLobby())
.onLoadIntercept((event: OnLoadInterceptEvent) =>
this.slotL !== undefined ? this.slotL.onLoadIntercept(event.data.getRequestUrl()) : false)
.onPageEnd(() => this.slotL?.onPageEnd())
.onProgressChange((event: OnProgressChangeEvent) => {
if (event.newProgress === 100) {
this.slotL?.onFirstProgress();
}
})
// 子游戏 Web(临时,subgameUrl 非空时存在)
if (this.subgameUrl !== '') {
Web({ src: this.subgameUrl, controller: this.controllerS })
.javaScriptAccess(true).domStorageAccess(true).fileAccess(true)
.mixedMode(MixedMode.All).cacheMode(CacheMode.None)
.geolocationAccess(true).zoomAccess(false)
.width('100%').height('100%')
.onControllerAttached(() => this.setupSubgame())
.onLoadIntercept((event: OnLoadInterceptEvent) =>
this.slotS !== undefined ? this.slotS.onLoadIntercept(event.data.getRequestUrl()) : false)
.onPageEnd(() => this.slotS?.onPageEnd())
.onProgressChange((event: OnProgressChangeEvent) => {
if (event.newProgress === 100) {
this.slotS?.onFirstProgress();
}
})
}
}
.width('100%').height('100%')
}
.hideTitleBar(true)
}
+159
View File
@@ -0,0 +1,159 @@
import { webview } from '@kit.ArkWeb';
import { common } from '@kit.AbilityKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { BridgeController, WebviewControllerAdapter, BridgeJsLoader } from 'feature_bridge';
import { CapabilityContext, CapabilityRegistrar } from 'feature_capabilities';
import { ConfigManager } from 'domain_resource';
import { KvStore, LocalUploadServer } from 'platform';
import { OutboundHandlers } from 'contracts';
import { Logger } from 'common';
import { buildCapabilities } from '../di/AppModule';
/**
* 单个 Web 槽(M5 双 Web 架构,框架 §7.5)。封装一个 Web 的桥 + 能力 + 激活生命周期。
* 大厅槽常驻;子游戏槽进入创建/返回销毁。每槽独立 BridgeController + 能力(结构隔离,接口不串)。
*
* controller 由容器持有并绑定到对应 Web 组件;本类在 onControllerAttached 后 setup()。
*/
export class WebSlot {
private static readonly log: Logger = Logger.tag('WebSlot');
readonly role: string;
readonly controller: webview.WebviewController;
/** 子游戏返回大厅时待下发给大厅的数据(getWebdata)。 */
pendingWebdata: string | undefined = undefined;
private readonly uploadServer: LocalUploadServer;
private adapter: WebviewControllerAdapter | undefined = undefined;
private bridgeCtrl: BridgeController | undefined = undefined;
private registrar: CapabilityRegistrar | undefined = undefined;
private firstDone: boolean = false;
private active: boolean;
constructor(role: string, controller: webview.WebviewController, active: boolean, uploadServer: LocalUploadServer) {
this.role = role;
this.controller = controller;
this.active = active;
this.uploadServer = uploadServer;
}
bridge(): BridgeController | undefined {
return this.bridgeCtrl;
}
isActive(): boolean {
return this.active;
}
/** onControllerAttached:建桥 + 经组装根注册能力(每槽独立一套)。 */
setup(hostCtx: common.UIAbilityContext, config: ConfigManager, kv: KvStore, resourceRoot: string): void {
try {
this.controller.setPathAllowingUniversalAccess([resourceRoot]);
} catch (e) {
WebSlot.log.e(`[${this.role}] setPathAllowingUniversalAccess failed: ${(e as Error).message}`);
}
const adapter = new WebviewControllerAdapter(this.controller);
const bridge = new BridgeController(adapter);
this.adapter = adapter;
this.bridgeCtrl = bridge;
bridge.setActive(this.active);
try {
bridge.setBridgeJs(BridgeJsLoader.load(hostCtx));
const ctx: CapabilityContext = {
uiAbilityContext: hostCtx,
config,
kv,
uploadServer: this.uploadServer,
log: Logger.tag(`Cap-${this.role}`),
};
const registrar = new CapabilityRegistrar(buildCapabilities());
registrar.registerAll(bridge, ctx);
this.registrar = registrar;
} catch (e) {
WebSlot.log.e(`[${this.role}] capability registration failed (bridge usable): ${(e as Error).message}`);
}
}
onLoadIntercept(url: string): boolean {
const b = this.bridgeCtrl;
return b !== undefined ? b.onLoadIntercept(url) : false;
}
onPageEnd(): void {
this.bridgeCtrl?.onPageEnd();
}
/** 页面首次 100%:下发 appservice('1') + setPostUrl+ 返回大厅待发 getWebdata)。 */
onFirstProgress(): void {
if (this.firstDone) {
return;
}
this.firstDone = true;
const b = this.bridgeCtrl;
if (b === undefined) {
return;
}
b.callHandler(OutboundHandlers.AppService, '1');
b.callHandler(OutboundHandlers.SetPostUrl, this.uploadServer.baseUrl());
this.flushPending();
}
/** 激活(onActive + 桥放行 + 能力前台)。页面已加载的大厅返回时另调 resumeLoaded 补发 appservice。 */
activate(): void {
this.active = true;
try {
this.controller.onActive();
} catch (e) {
WebSlot.log.w(`[${this.role}] onActive failed: ${(e as BusinessError).message}`);
}
this.bridgeCtrl?.setActive(true);
this.registrar?.forEachForeground();
}
/** 大厅从子游戏返回(页面仍在、无新 100%):激活并直接补发 appservice('1') + 待发 getWebdata。 */
resumeLoaded(): void {
this.activate();
this.bridgeCtrl?.callHandler(OutboundHandlers.AppService, '1');
this.flushPending();
}
/** 去激活(先发 appservice('2'),再停能力、关桥、onInactive)。 */
deactivate(): void {
this.bridgeCtrl?.callHandler(OutboundHandlers.AppService, '2'); // 彼时仍激活,可下发
this.registrar?.forEachBackground();
this.bridgeCtrl?.setActive(false);
this.active = false;
try {
this.controller.onInactive();
} catch (e) {
WebSlot.log.w(`[${this.role}] onInactive failed: ${(e as BusinessError).message}`);
}
}
/** App 前后台 → 当前激活槽下发 appservice。 */
onAppForeground(): void {
this.registrar?.forEachForeground();
this.bridgeCtrl?.callHandler(OutboundHandlers.AppService, '1');
}
onAppBackground(): void {
this.registrar?.forEachBackground();
this.bridgeCtrl?.callHandler(OutboundHandlers.AppService, '2');
}
/** 销毁(子游戏返回大厅时)。 */
dispose(): void {
this.registrar?.destroyAll();
this.bridgeCtrl?.dispose();
this.adapter?.dispose();
this.registrar = undefined;
this.bridgeCtrl = undefined;
this.adapter = undefined;
}
private flushPending(): void {
if (this.pendingWebdata !== undefined) {
this.bridgeCtrl?.callHandler(OutboundHandlers.GetWebData, this.pendingWebdata);
this.pendingWebdata = undefined;
}
}
}