现象:大厅资源下载完成、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>
404 lines
18 KiB
Plaintext
404 lines
18 KiB
Plaintext
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.js(H5 同步读),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;
|
||
})
|
||
}
|
||
}
|