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:
@@ -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.js(H5 同步读),launchtype='1'
|
||||
const subDir: string = `${res.paths().urlpath}/${d.dir}`;
|
||||
// 子游戏目录自带 app_data.js(H5 同步读),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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user