Files
youle_app_ohos/entry/src/main/ets/pages/BridgeGameContainer.ets
T
lanterngamescnandClaude Opus 4.8 db206068c0 修复大厅启动页→H5"白闪+二次显示 splash":启动图覆盖层上移到根层
现象:大厅资源下载完成、splash 进度满后,进入 H5 前先闪一下白屏、又重新
显示一次 splash 背景图,才进 H5(真机 nova 14 复现)。

根因:原启动图视觉随 SplashPage 渲染,replacePathByName 切到
BridgeGameContainer 时,旧页销毁与新页 Web 首帧之间存在导航空隙(白闪);
而 BridgeGameContainer 自带的大厅 cover 又在切换后重新挂载,看起来像"第二次
splash"。两者皆因视觉绑定在会被切换/重建的页面上、与时序耦合。

修法(H5 零改动,纯原生):
- 启动图 + 金色进度条整体上移到 Index 根层 Stack 覆盖层,由 AppStorage
  KEY_SPLASH_VISIBLE 控制,从 app 启动持续显示到大厅首帧上屏(onPageEnd)才撤。
- SplashPage 退化为纯启动逻辑载体(透明 NavDestination),仅把进度/文案/
  阻断写入 AppStorage 供根层覆盖层呈现。
- BridgeGameContainer 移除自带大厅 cover,onPageEnd 置 splashVisible=false。

SplashPage→BridgeGameContainer 的页面切换由此发生在常驻覆盖层之下,时序无关,
彻底消除导航空隙白闪与二次 splash,大厅就绪即直接显示 H5。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 08:57:30 +08:00

404 lines
18 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { webview } from '@kit.ArkWeb';
import { common } from '@kit.AbilityKit';
import { window } from '@kit.ArkUI';
import { BusinessError } from '@kit.BasicServicesKit';
import { ConfigManager, ResourceManager, AppDataInjector, AppDataValues, RemoteConfig, VersionDecision } from 'domain_resource';
import { KvStore, LocalUploadServer } from 'platform';
import { OutboundHandlers } from 'contracts';
import { AppEnv, EventBus, EventPayload, NavEvents, OpenGenericWebPayload, SwitchGamePayload,
BackGamePayload, ShareEvents, SharePanelRequest, SharePanelResult, Logger } from 'common';
import { BridgeGameParams, GenericWebParams, GenericWebResult, RouteName, AppEvents } from '../routes/AppRoutes';
import { WebSlot } from '../web/WebSlot';
import { SharePanel } from '../components/SharePanel';
import { KEY_SPLASH_VISIBLE } from './Index';
/**
* 大厅/子游戏容器(对应 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 controllerL: 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;
private kv: KvStore | undefined = undefined;
private resource: ResourceManager | undefined = undefined;
private slotL: WebSlot | undefined = undefined;
private slotS: WebSlot | undefined = undefined;
private cancels: Array<() => void> = [];
/** 非空 → 子游戏 Web 显示(路径 A 切换)。 */
@State private subgameUrl: string = '';
/** 子游戏资源下载中(按需下载期间显示启动页同款进度条)。 */
@State private subgameDownloading: boolean = false;
/** 子游戏下载进度(0~100)。 */
@State private subgamePercent: number = 0;
/** 子游戏首帧是否已绘制:下载完到子游戏 H5 上屏间用启动图盖住,消除白屏。 */
@State private subgamePainted: boolean = false;
/** 远程配置 10 分钟缓存(对齐 Android:避免每次进子游戏都重拉 config)。 */
private cachedRemote: RemoteConfig | undefined = undefined;
private cachedRemoteAt: number = 0;
private static readonly REMOTE_TTL: number = 10 * 60 * 1000;
/** 分享面板显隐(叠在 Stack 顶层,对齐 Android SharePanelHelper)。 */
@State private sharePanelVisible: boolean = false;
/** 当前分享面板的一次性回投事件名(用户选定平台后 emit 回 ShareProvider)。 */
private shareResultEvent: string = '';
aboutToAppear(): void {
if (AppEnv.isDebug()) {
webview.WebviewController.setWebDebuggingAccess(true);
}
}
aboutToDisappear(): void {
// 先退订事件,再 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;
this.hostCtx = undefined;
this.config = undefined;
this.kv = undefined;
this.resource = undefined;
}
/** 懒初始化共享依赖(两槽共用 config/kv/resource+ 订阅事件。 */
private ensureDeps(): void {
if (this.hostCtx !== undefined) {
return;
}
const hostCtx: common.UIAbilityContext = this.getUIContext().getHostContext() as common.UIAbilityContext;
this.hostCtx = hostCtx;
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.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)));
this.cancels.push(EventBus.on(ShareEvents.SHOW_PANEL, (p?: EventPayload) => this.showSharePanel(p)));
}
/** 收到 ShareProvider 的 SHOW_PANEL:记下一次性回投事件名并弹出面板。 */
private showSharePanel(p?: EventPayload): void {
if (p === undefined) {
return;
}
// 已有面板未关闭:先按取消回投旧请求,避免上一个 once 永不触发致 ShareProvider 悬挂。
if (this.sharePanelVisible && this.shareResultEvent !== '') {
this.emitShareResult('cancel');
}
const req = p as SharePanelRequest;
this.shareResultEvent = req.resultEvent;
this.sharePanelVisible = true;
}
/** 用户选定平台/取消:回投并关闭面板。 */
private emitShareResult(platform: string): void {
const ev: string = this.shareResultEvent;
this.shareResultEvent = '';
this.sharePanelVisible = false;
if (ev !== '') {
const result: SharePanelResult = { platform };
EventBus.emit(ev, result);
}
}
/** 路径 B:打开通用网页容器;关闭回传 → 当前激活槽 callHandler('getWebdata')。 */
private openGenericWeb(p?: EventPayload): void {
if (p === undefined) {
return;
}
const d = p as OpenGenericWebPayload;
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.activeSlot()?.bridge()?.callHandler(OutboundHandlers.GetWebData, result !== undefined ? result.data : '');
}, false);
}
/** 路径 A:大厅 → 子游戏(必要时先按需下载,再大厅去激活并保活、创建子游戏 Web)。 */
private async switchGame(p?: EventPayload): Promise<void> {
if (p === undefined) {
return;
}
if (this.subgameDownloading) {
return; // 正在下载某子游戏,忽略重复触发
}
// 拓扑约束:仅 大厅↔子游戏,无 子游戏→子游戏。已在子游戏中时忽略——
// 游戏逻辑须先 backgameData 回大厅、再由大厅发 SwitchOverGameData 进新子游戏。
if (this.subgameUrl !== '') {
Logger.tag('BridgeGameContainer').w('SwitchOverGameData ignored: already in subgame (route via lobby)');
return;
}
const d = p as SwitchGamePayload;
const res = this.resource;
const cfg = this.config;
if (d.dir === '' || res === undefined || cfg === undefined) {
return;
}
// §11.1:子游戏 H5 按需下载——目录缺失先下载解压;已装则比对远程版本,更高才更新。
const ready: boolean = await this.prepareSubgame(d, res, cfg);
if (!ready) {
return; // 下载失败/无地址:留在大厅(已记录日志/弹提示)
}
const subDir: string = `${res.paths().urlpath}/${d.dir}`;
// 子游戏目录自带 app_data.jsH5 同步读),launchtype='1'
const values: AppDataValues = AppDataInjector.buildValues(cfg.getLocal(), `${res.localVersion()}`, '1', res.localGameName());
AppDataInjector.inject(subDir, values);
this.slotL?.deactivate();
this.slotS = new WebSlot('subgame', new webview.WebviewController(), true, this.uploadServer);
this.applyOrientation(d.webtype !== '2'); // "3" 横 / "2" 竖
this.subgamePainted = false; // 子游戏首帧前用启动图盖住,避免 下载完→子游戏上屏 间白屏
this.subgameUrl = `file://${subDir}/index.html?Launchtype=1`; // 触发子游戏 Web 渲染 → setupSubgame
}
/**
* 确保子游戏资源就绪(复刻 Android NewwebviewActivity 进入时的 updategamezip):
* - 目录缺失:经分层配置解析 game_zip → 下载解压(首装)。
* - 目录已装:比对远程 game_version 与本地 version.xml,更高才更新;否则直接用。
* - 取配置/下载失败时:已装则容忍离线用旧版,未装则提示并放弃(留在大厅)。
* 返回 true 表示可加载子游戏。
*/
private async prepareSubgame(d: SwitchGamePayload, res: ResourceManager, cfg: ConfigManager): Promise<boolean> {
const installed: boolean = res.subgameInstalled(d.dir);
let remote: RemoteConfig;
try {
remote = await this.remoteConfig(cfg);
} catch (e) {
Logger.tag('BridgeGameContainer').w(`subgame config fetch failed: ${(e as Error).message}`);
if (installed) {
return true; // 离线容忍:已装直接用
}
this.toast('网络异常,无法下载游戏');
return false;
}
// gameid:已装优先用本地 version.xml 的 gameid(对齐 downloadgame3),否则用 H5 传来的 gamedownloadurl
const localGameId: string = installed ? res.subgameLocalGameId(d.dir) : '';
const gameId: string = localGameId !== '' ? localGameId : d.gameId;
const decision: VersionDecision = cfg.resolveGame(remote, gameId);
const localVer: number = installed ? res.subgameLocalVersion(d.dir) : -1;
if (installed && decision.gameVersion <= localVer) {
return true; // 已是最新
}
if (decision.gameDownload === '') {
if (installed) {
return true; // 无下载地址但已装:用旧的
}
Logger.tag('BridgeGameContainer').w(`subgame ${d.dir} no game_zip (gameId=${gameId})`);
this.toast('未找到该游戏的下载地址');
return false;
}
// 下载(缺目录=首装 / 远端更高=更新)
this.subgamePercent = 0;
this.subgameDownloading = true;
try {
await res.updateSubgame(d.dir, decision.gameDownload, (pct: number) => {
if (pct >= 0) {
this.subgamePercent = pct;
}
});
return true;
} catch (e) {
Logger.tag('BridgeGameContainer').w(`subgame ${d.dir} download failed: ${(e as Error).message}`);
if (installed) {
return true; // 更新失败但旧版可用
}
this.toast('游戏下载失败,请重试');
return false;
} finally {
this.subgameDownloading = false;
}
}
/** 远程配置(10 分钟缓存):对齐 Android 的 gameconfigtime 节流,避免每次进子游戏都重拉。 */
private async remoteConfig(cfg: ConfigManager): Promise<RemoteConfig> {
const now: number = Date.now();
const cached = this.cachedRemote;
if (cached !== undefined && now - this.cachedRemoteAt < BridgeGameContainer.REMOTE_TTL) {
return cached;
}
const remote: RemoteConfig = await cfg.fetchRemote();
this.cachedRemote = remote;
this.cachedRemoteAt = now;
return remote;
}
private toast(msg: string): void {
try {
this.getUIContext().getPromptAction().showToast({ message: msg, duration: 2000 });
} catch (e) {
Logger.tag('BridgeGameContainer').w(`toast failed: ${(e as Error).message}`);
}
}
/** 子游戏 → 大厅(销毁子游戏 Web,大厅恢复并带回数据)。 */
private backToLobby(p?: EventPayload): void {
if (this.subgameUrl === '') {
return;
}
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 {
const ctx = this.hostCtx;
if (ctx === undefined) {
return;
}
const target: window.Orientation = landscape ? window.Orientation.LANDSCAPE : window.Orientation.PORTRAIT;
window.getLastWindow(ctx).then((win: window.Window) => win.setPreferredOrientation(target))
.catch((e: BusinessError) => Logger.tag('BridgeGameContainer').w(`orientation failed: ${e.message}`));
}
/** 大厅入口 URL:params 非空用之,否则回退最小回显页(V1)。 */
private lobbySrc(): string | Resource {
return this.params.entryUrl !== '' ? this.params.entryUrl : $rawfile('test_echo.html');
}
build() {
NavDestination() {
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%')
// 铺满系统安全区(消除底部导航手势区白边),与原 Android 全屏 WebView 一致
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
.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();
// 大厅页面加载完成(已稳定上屏)→ 撤根层启动页覆盖,直接显示大厅 H5。
AppStorage.setOrCreate(KEY_SPLASH_VISIBLE, false);
})
.onProgressChange((event: OnProgressChangeEvent) => {
if (event.newProgress === 100) {
this.slotL?.onFirstProgress();
}
})
.onRenderExited((event: OnRenderExitedEvent) => this.slotL?.onRenderExited(event.renderExitReason))
// 子游戏 Web(临时,subgameUrl 非空时存在)
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)
.width('100%').height('100%')
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
.onControllerAttached(() => this.setupSubgame())
.onLoadIntercept((event: OnLoadInterceptEvent) =>
this.slotS !== undefined ? this.slotS.onLoadIntercept(event.data.getRequestUrl()) : false)
.onPageEnd(() => {
this.slotS?.onPageEnd();
this.subgamePainted = true; // 子游戏页面加载完、稳定上屏 → 撤启动图覆盖层
})
.onProgressChange((event: OnProgressChangeEvent) => {
if (event.newProgress === 100) {
this.slotS?.onFirstProgress();
}
})
.onRenderExited((event: OnRenderExitedEvent) => this.slotS?.onRenderExited(event.renderExitReason))
}
// (大厅首帧前的启动图覆盖已上移到 Index.ets 根层,避免导航空隙白闪 + 二次 splash)
// 子游戏进入覆盖层(与启动页 SplashPage 完全一致):覆盖「下载中」与「下载完→子游戏首帧上屏」
// 两段,启动图铺底 + 底部金色进度条 + 文案,直到子游戏 onPageEnd 才撤——消除两段间白屏。
if (this.subgameDownloading || (this.subgameUrl !== '' && !this.subgamePainted)) {
Stack() {
Image($r('app.media.launch_image'))
.width('100%').height('100%')
.objectFit(ImageFit.Cover)
Column({ space: 14 }) {
Progress({ value: this.subgameDownloading ? this.subgamePercent : 100, total: 100, type: ProgressType.Linear })
.width('56%')
.color('#D2A312')
.backgroundColor('#E6E6E6')
Row({ space: 8 }) {
LoadingProgress().width(20).height(20).color('#D2A312')
Text(this.subgameDownloading ? `正在下载游戏… ${this.subgamePercent}%` : '正在进入游戏…')
.fontSize(15).fontColor('#1B2A4A')
}
}
.width('100%').height('100%')
.justifyContent(FlexAlign.End)
.padding({ bottom: 24 })
}
.width('100%').height('100%')
.backgroundColor(Color.White)
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
}
// 分享面板(顶层叠加;用户选平台/取消 → 一次性回投 ShareProvider
if (this.sharePanelVisible) {
SharePanel({ onPick: (platform: string) => this.emitShareResult(platform) })
}
}
.width('100%').height('100%')
// 垫白底:Web 首帧前/页面切换间隙显白色(与启动页白底无缝),避免黑屏闪烁
.backgroundColor(Color.White)
}
.hideTitleBar(true)
.onBackPressed(() => {
// 分享面板打开时,返回键关闭面板并按取消回投(对齐 Android 点空白关闭)
if (this.sharePanelVisible) {
this.emitShareResult('cancel');
return true;
}
return false;
})
}
}