From db206068c0907f062042250a0c3b06f2f1c3f9c4 Mon Sep 17 00:00:00 2001 From: lanterngamescn Date: Sat, 27 Jun 2026 08:57:30 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=A4=A7=E5=8E=85=E5=90=AF?= =?UTF-8?q?=E5=8A=A8=E9=A1=B5=E2=86=92H5"=E7=99=BD=E9=97=AA+=E4=BA=8C?= =?UTF-8?q?=E6=AC=A1=E6=98=BE=E7=A4=BA=20splash"=EF=BC=9A=E5=90=AF?= =?UTF-8?q?=E5=8A=A8=E5=9B=BE=E8=A6=86=E7=9B=96=E5=B1=82=E4=B8=8A=E7=A7=BB?= =?UTF-8?q?=E5=88=B0=E6=A0=B9=E5=B1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 现象:大厅资源下载完成、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) --- .../main/ets/pages/BridgeGameContainer.ets | 56 +++++++------ entry/src/main/ets/pages/Index.ets | 66 +++++++++++++-- entry/src/main/ets/pages/SplashPage.ets | 80 ++++--------------- 3 files changed, 108 insertions(+), 94 deletions(-) diff --git a/entry/src/main/ets/pages/BridgeGameContainer.ets b/entry/src/main/ets/pages/BridgeGameContainer.ets index 5432e8d..2590cc6 100644 --- a/entry/src/main/ets/pages/BridgeGameContainer.ets +++ b/entry/src/main/ets/pages/BridgeGameContainer.ets @@ -10,6 +10,7 @@ import { AppEnv, EventBus, EventPayload, NavEvents, OpenGenericWebPayload, Switc 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 槽模型**: @@ -35,12 +36,12 @@ export struct BridgeGameContainer { private cancels: Array<() => void> = []; /** 非空 → 子游戏 Web 显示(路径 A 切换)。 */ @State private subgameUrl: string = ''; - /** 大厅首帧是否已绘制:未绘制时用启动图盖住大厅 Web,消除 splash→大厅的白屏闪烁。 */ - @State private hallPainted: boolean = false; /** 子游戏资源下载中(按需下载期间显示启动页同款进度条)。 */ @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; @@ -187,6 +188,7 @@ export struct BridgeGameContainer { 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 } @@ -317,9 +319,8 @@ export struct BridgeGameContainer { this.slotL !== undefined ? this.slotL.onLoadIntercept(event.data.getRequestUrl()) : false) .onPageEnd(() => { this.slotL?.onPageEnd(); - // 页面加载完成(已稳定上屏)再撤启动图覆盖层——不在 onFirstContentfulPaint 撤, - // 避免首帧绘制到合成上屏间一帧黑屏。 - this.hallPainted = true; + // 大厅页面加载完成(已稳定上屏)→ 撤根层启动页覆盖,直接显示大厅 H5。 + AppStorage.setOrCreate(KEY_SPLASH_VISIBLE, false); }) .onProgressChange((event: OnProgressChangeEvent) => { if (event.newProgress === 100) { @@ -339,7 +340,10 @@ export struct BridgeGameContainer { .onControllerAttached(() => this.setupSubgame()) .onLoadIntercept((event: OnLoadInterceptEvent) => this.slotS !== undefined ? this.slotS.onLoadIntercept(event.data.getRequestUrl()) : false) - .onPageEnd(() => this.slotS?.onPageEnd()) + .onPageEnd(() => { + this.slotS?.onPageEnd(); + this.subgamePainted = true; // 子游戏页面加载完、稳定上屏 → 撤启动图覆盖层 + }) .onProgressChange((event: OnProgressChangeEvent) => { if (event.newProgress === 100) { this.slotS?.onFirstProgress(); @@ -348,29 +352,33 @@ export struct BridgeGameContainer { .onRenderExited((event: OnRenderExitedEvent) => this.slotS?.onRenderExited(event.renderExitReason)) } - // 大厅首帧前用启动图盖住(消除 splash→大厅白屏);仅大厅阶段、首帧后撤除 - if (!this.hallPainted && this.subgameUrl === '') { - Image($r('app.media.launch_image')) - .width('100%').height('100%') - .objectFit(ImageFit.Cover) - .expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM]) - } + // (大厅首帧前的启动图覆盖已上移到 Index.ets 根层,避免导航空隙白闪 + 二次 splash) - // 子游戏按需下载进度(启动页同款:金色进度条 + 文案,覆盖全屏白底) - if (this.subgameDownloading) { - Column({ space: 14 }) { - Progress({ value: this.subgamePercent, total: 100, type: ProgressType.Linear }) - .width('56%') - .color('#D2A312') - .backgroundColor('#E6E6E6') - Row({ space: 8 }) { - LoadingProgress().width(20).height(20).color('#D2A312') - Text(`正在下载游戏… ${this.subgamePercent}%`).fontSize(15).fontColor('#1B2A4A') + // 子游戏进入覆盖层(与启动页 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%') - .justifyContent(FlexAlign.Center) .backgroundColor(Color.White) + .expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM]) } // 分享面板(顶层叠加;用户选平台/取消 → 一次性回投 ShareProvider) diff --git a/entry/src/main/ets/pages/Index.ets b/entry/src/main/ets/pages/Index.ets index 057a4cd..79b41d5 100644 --- a/entry/src/main/ets/pages/Index.ets +++ b/entry/src/main/ets/pages/Index.ets @@ -3,16 +3,37 @@ import { SplashPage } from './SplashPage'; import { BridgeGameContainer } from './BridgeGameContainer'; import { GenericWebContainer } from './GenericWebContainer'; +/** 根层启动页状态(AppStorage 键)。SplashPage 更新进度/文案/阻断;大厅首帧后置 splashVisible=false。 */ +export const KEY_SPLASH_VISIBLE: string = 'splashVisible'; +export const KEY_SPLASH_PERCENT: string = 'splashPercent'; +export const KEY_SPLASH_LABEL: string = 'splashLabel'; +export const KEY_SPLASH_BLOCKED: string = 'splashBlocked'; + /** * 应用导航宿主(@Entry)。持有全局 NavPathStack,初始进入 Splash。 - * 各容器作为 NavDestination 子页,经 navDestination builder 按路由名分发。 + * + * **根层启动页覆盖**:启动页视觉(启动图 + 进度条)在此根层渲染,从 app 启动**持续显示**到大厅 + * 首帧上屏才撤。SplashPage→BridgeGameContainer 的页面切换发生在该覆盖层**之下**——彻底消除 + * 导航空隙白闪与"二次显示 splash",大厅就绪即直接显示 H5。SplashPage 退化为纯启动逻辑载体。 */ @Entry @Component struct Index { private pathStack: NavPathStack = new NavPathStack(); + @StorageProp(KEY_SPLASH_VISIBLE) private splashVisible: boolean = true; + @StorageProp(KEY_SPLASH_PERCENT) private splashPercent: number = 0; + @StorageProp(KEY_SPLASH_LABEL) private splashLabel: string = '正在启动…'; + @StorageProp(KEY_SPLASH_BLOCKED) private splashBlocked: string = ''; + + // 品牌色(取自 logo):navy 文字 + 金色进度 + private static readonly NAVY: string = '#1B2A4A'; + private static readonly GOLD: string = '#D2A312'; aboutToAppear(): void { + AppStorage.setOrCreate(KEY_SPLASH_VISIBLE, true); + AppStorage.setOrCreate(KEY_SPLASH_PERCENT, 0); + AppStorage.setOrCreate(KEY_SPLASH_LABEL, '正在启动…'); + AppStorage.setOrCreate(KEY_SPLASH_BLOCKED, ''); // 启动即进入引导页(对应 weclomeactivity1 为 Launcher) this.pathStack.pushPathByName(RouteName.SPLASH, '', false); } @@ -31,10 +52,45 @@ struct Index { } build() { - Navigation(this.pathStack) { + Stack() { + Navigation(this.pathStack) { + } + .navDestination(this.pageMap) + .hideNavBar(true) + .mode(NavigationMode.Stack) + + // 根层启动页:从 app 启动持续到大厅首帧上屏,跨页面切换无缝(无白闪、无二次 splash) + if (this.splashVisible) { + Stack() { + Image($r('app.media.launch_image')) + .width('100%').height('100%') + .objectFit(ImageFit.Cover) + Column({ space: 14 }) { + if (this.splashBlocked === '') { + Progress({ value: this.splashPercent, total: 100, type: ProgressType.Linear }) + .width('56%') + .color(Index.GOLD) + .backgroundColor('#E6E6E6') + Row({ space: 8 }) { + LoadingProgress().width(20).height(20).color(Index.GOLD) + Text(`${this.splashLabel} ${this.splashPercent}%`).fontSize(15).fontColor(Index.NAVY) + } + } else { + Text(this.splashBlocked) + .fontSize(16).fontColor(Index.NAVY).textAlign(TextAlign.Center) + .backgroundColor('rgba(255,255,255,0.9)').borderRadius(8).padding(14) + .margin({ left: 24, right: 24 }) + } + } + .width('100%').height('100%') + .justifyContent(FlexAlign.End) + .padding({ bottom: 24 }) + } + .width('100%').height('100%') + .backgroundColor(Color.White) + .expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM]) + } } - .navDestination(this.pageMap) - .hideNavBar(true) - .mode(NavigationMode.Stack) + .width('100%').height('100%') } } diff --git a/entry/src/main/ets/pages/SplashPage.ets b/entry/src/main/ets/pages/SplashPage.ets index 6b0c1bc..5f3c0c1 100644 --- a/entry/src/main/ets/pages/SplashPage.ets +++ b/entry/src/main/ets/pages/SplashPage.ets @@ -1,27 +1,18 @@ import { common } from '@kit.AbilityKit'; import { StartupOrchestrator, StartupResult, StartupStage } from 'domain_resource'; import { RouteName, BridgeGameParams } from '../routes/AppRoutes'; +import { KEY_SPLASH_VISIBLE, KEY_SPLASH_PERCENT, KEY_SPLASH_LABEL, KEY_SPLASH_BLOCKED } from './Index'; /** - * 启动引导页(对应 weclomeactivity1,框架 §4/§8.1)。 - * 进入即跑 StartupOrchestrator(本地配置→远程配置→资源准备→app_data 注入), - * 完成后 replace 进大厅容器;被 showmessage 阻断时弹公告。 + * 启动引导页(对应 weclomeactivity1,框架 §4/§8.1)——**纯启动逻辑载体,无可见 UI**。 + * 进入即跑 StartupOrchestrator(本地配置→远程配置→资源准备→app_data 注入),进度/文案/阻断写入 + * AppStorage,由 Index 根层启动页覆盖层统一呈现;完成后 replace 进大厅容器(覆盖层在大厅首帧后撤)。 * - * 启动时序:系统启动窗用透明 startWindowIcon($media:start_window_blank) + 白底 → - * **纯白屏**;随后本页淡入全屏启动图 launch_image(白底 + 金色 logo)。系统窗无 logo, - * 故不存在"两套资源比例不一致"问题。覆盖层文字用品牌 navy(白底可见),进度条金色, - * 展示当前阶段中文文案。 + * 视觉放在 Index 根层而非本页:使 splash→大厅的页面切换发生在覆盖层之下,彻底消除导航空隙白闪。 */ @Component export struct SplashPage { pathStack: NavPathStack = new NavPathStack(); - @State private stage: StartupStage = StartupStage.INIT; - @State private percent: number = 0; - @State private blocked: string = ''; - - // 品牌色(取自 logo):navy 文字 + 金色进度 - private static readonly NAVY: string = '#1B2A4A'; - private static readonly GOLD: string = '#D2A312'; aboutToAppear(): void { this.runStartup(); @@ -30,26 +21,27 @@ export struct SplashPage { private async runStartup(): Promise { const ctx: common.UIAbilityContext = this.getUIContext().getHostContext() as common.UIAbilityContext; const orchestrator = new StartupOrchestrator(ctx, (stage: StartupStage, percent: number) => { - this.stage = stage; - this.percent = percent; + AppStorage.setOrCreate(KEY_SPLASH_PERCENT, percent); + AppStorage.setOrCreate(KEY_SPLASH_LABEL, SplashPage.stageLabel(stage, percent)); }); try { const result: StartupResult = await orchestrator.run(); if (result.enter) { + // 切到大厅:保持 splashVisible=true,根层覆盖层持续盖住,由大厅首帧 onPageEnd 撤除。 const params: BridgeGameParams = { entryUrl: result.entryUrl, launchType: '0' }; this.pathStack.replacePathByName(RouteName.BRIDGE_GAME, params, false); } else { - this.blocked = result.message; + AppStorage.setOrCreate(KEY_SPLASH_BLOCKED, result.message); } } catch (e) { const err = e as Error; - this.blocked = `启动失败:${err.message}`; + AppStorage.setOrCreate(KEY_SPLASH_BLOCKED, `启动失败:${err.message}`); } } /** 阶段 → 用户可读中文文案(PREPARE_RESOURCE 按进度区分"准备资源/下载更新")。 */ - private stageLabel(): string { - switch (this.stage) { + private static stageLabel(stage: StartupStage, percent: number): string { + switch (stage) { case StartupStage.INIT: return '正在启动…'; case StartupStage.LOAD_LOCAL_CONFIG: @@ -62,7 +54,7 @@ export struct SplashPage { return '检测升级版本…'; case StartupStage.PREPARE_RESOURCE: // 35% 为内置资源准备;55%~85% 为远程 zip 下载 - return this.percent >= 55 ? '正在下载更新…' : '准备游戏资源…'; + return percent >= 55 ? '正在下载更新…' : '准备游戏资源…'; case StartupStage.INJECT_APP_DATA: return '即将进入大厅…'; case StartupStage.ENTER_HALL: @@ -73,52 +65,10 @@ export struct SplashPage { } build() { + // 视觉由 Index 根层启动页覆盖层呈现;本页仅承载启动逻辑(透明、无内容) NavDestination() { - Stack() { - // 全屏启动图(白底 + 金色 logo) - Image($r('app.media.launch_image')) - .width('100%') - .height('100%') - .objectFit(ImageFit.Cover) - // 启动进度/公告叠加在底部(深色文字,白底可见) - Column({ space: 14 }) { - if (this.blocked === '') { - Progress({ value: this.percent, total: 100, type: ProgressType.Linear }) - .width('56%') - .color(SplashPage.GOLD) - .backgroundColor('#E6E6E6') - // 阶段提示文字放在进度条下方 - Row({ space: 8 }) { - LoadingProgress() - .width(20) - .height(20) - .color(SplashPage.GOLD) - Text(`${this.stageLabel()} ${this.percent}%`) - .fontSize(15) - .fontColor(SplashPage.NAVY) - } - } else { - Text(this.blocked) - .fontSize(16) - .fontColor(SplashPage.NAVY) - .textAlign(TextAlign.Center) - .backgroundColor('rgba(255,255,255,0.9)') - .borderRadius(8) - .padding(14) - .margin({ left: 24, right: 24 }) - } - } - .width('100%') - .height('100%') - .justifyContent(FlexAlign.End) - // 横屏(auto_rotation_landscape)下屏幕"高"为短边,过大的底边距会把进度块顶进居中 logo。 - // 以底部为基准只留一小段间距,使进度/文字落在 logo 下方的空白带、贴近底部(系统手势条由安全区自动避让)。 - .padding({ bottom: 24 }) - } - .width('100%') - .height('100%') - .backgroundColor(Color.White) } .hideTitleBar(true) + .backgroundColor(Color.Transparent) } }