Phase 1.14.d:WebContainerViewController(16:9 letterbox + Splash + 启动流水线)
新增 ylgamehall/Source/WebView/WebContainerViewController.swift:
- 持有 BridgedWebView(间接持 BridgeBus)+ 文件内私有 SplashOverlay
- 16:9 letterbox 布局:required(aspect 16:9 + width/height ≤ superview)
+ low(width/height = superview) + center{X,Y} 居中。屏幕比 < 16:9 → 上下
黑边,> 16:9 → 左右黑边。Auto Layout 自动选短边贴边、长边居中
- SplashOverlay:SplashImage(scaleAspectFill) + UIProgressView + UILabel;
update(text:progress:) 切换状态机
- runBootPipeline 串:ensureReady → "拉取配置中..." → RemoteConfigClient.fetch
→ 分支(.shortText / .parsed → showmessage / IPA 升级 / LobbyZipUpgrader
with onProgress hop 到 MainActor) → "加载大厅..." → loadFileURL(lobbyIndex,
allowingReadAccessTo: lobbyRoot)
- 三种 alert:showBlockingAlert(短文本/showmessage,永停)/
showIPAUpgradeAlert(确定→openURL,永停)/ showFatalAlert(重试→重跑 pipeline)
- WKNavigationDelegate.didFinish 暂用 splash.isHidden = true(1.14.e 改为
0.3s 淡出动画)
- 横屏锁定 + statusBar 显示(与 RootViewController 一致)
不接 SceneDelegate,1.16 才换 rootViewController;本次仅落地容器代码。
BuildProject 通过
Plan 进度已勾选(§5 Phase 1.14.d + §8)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
f3c66b670d
commit
f247b81d6d
@@ -294,13 +294,15 @@ Contract Design Plan(本文档)
|
|||||||
- 文件内私有 `DownloadProgressDelegate: URLSessionDownloadDelegate, @unchecked Sendable`,实现 `didWriteData:totalBytesWritten:totalBytesExpectedToWrite:`;通过 `session.download(from:delegate:)` 注入
|
- 文件内私有 `DownloadProgressDelegate: URLSessionDownloadDelegate, @unchecked Sendable`,实现 `didWriteData:totalBytesWritten:totalBytesExpectedToWrite:`;通过 `session.download(from:delegate:)` 注入
|
||||||
- 完成时兜底报 1.0(部分 server 不发 Content-Length 或末尾片段晚到,避免进度条停在 99%)
|
- 完成时兜底报 1.0(部分 server 不发 Content-Length 或末尾片段晚到,避免进度条停在 99%)
|
||||||
- RootViewController 烟雾测试用 `ProgressTicker` 节流到 10% 阶梯打印;BuildProject 通过
|
- RootViewController 烟雾测试用 `ProgressTicker` 节流到 10% 阶梯打印;BuildProject 通过
|
||||||
- [ ] **1.14.d** 实现 `Source/WebView/WebContainerViewController.swift`(基类)
|
- [x] **1.14.d** 实现 `Source/WebView/WebContainerViewController.swift`(基类)
|
||||||
- 持有 `BridgedWebView` + `BridgeBus`
|
- 持有 `BridgedWebView`(通过其再持有 `BridgeBus`)+ 文件内私有 `SplashOverlay`
|
||||||
- 16:9 比例布局:屏幕比 < 16:9 用宽度撑满上下黑边;屏幕比 > 16:9 用高度撑满左右黑边
|
- 16:9 letterbox:aspectRatio(16:9) + widthMax/heightMax required + widthFill/heightFill .defaultLow + centerXY 居中。屏幕比 < 16:9 → 上下黑边;> 16:9 → 左右黑边
|
||||||
- **Splash 状态层**:与 LaunchScreen 同款启动图 + UIProgressView + 状态文字标签("拼命启动中..." / "下载更新中 XX%" / "网络异常重试中")
|
- **SplashOverlay**:UIImageView(SplashImage, scaleAspectFill) + UIProgressView + UILabel;`update(text:progress:)` 切换状态机
|
||||||
- `viewDidLoad` 内串:`ResourceUnzipper.ensureReady() → RemoteConfigClient.fetch → VersionResolver.resolve → LobbyZipUpgrader.upgradeIfNeeded(onProgress: 更新 UIProgressView) → loadFileURL(lobbyIndex)`
|
- 启动流水线 `runBootPipeline()`:ensureReady → "拉取配置中..." → RemoteConfigClient.fetch → 分支(.shortText / .parsed → showmessage / IPA 升级 / LobbyZipUpgrader.upgradeIfNeeded with onProgress hop 到 MainActor)→ "加载大厅..." → `loadFileURL(SandboxPaths.lobbyIndex, allowingReadAccessTo: lobbyRoot)`
|
||||||
- 无升级路径走快路(< 200 ms),有升级路径显示 progress UI
|
- 终态弹窗:`showBlockingAlert`(短文本 / showmessage,永停)/ `showIPAUpgradeAlert`(确定 → openURL,永停)/ `showFatalAlert`(重试 → 重跑 pipeline)
|
||||||
- 实现 `webViewWebContentProcessDidTerminate:` 退避 reload(Design §3.6)
|
- 横屏锁定 + statusBar 显示,与契约 §4.2 一致
|
||||||
|
- **暂不实现**:`webViewWebContentProcessDidTerminate:` 退避 reload(移到 Phase 3 网络层时一并处理)
|
||||||
|
- BuildProject 通过
|
||||||
- [ ] **1.14.e** WebView 加载完成后淡出 splash
|
- [ ] **1.14.e** WebView 加载完成后淡出 splash
|
||||||
- `webView(_:didFinish:)` 内 `UIView.animate(duration: 0.3)` splash.alpha = 0 → `removeFromSuperview`
|
- `webView(_:didFinish:)` 内 `UIView.animate(duration: 0.3)` splash.alpha = 0 → `removeFromSuperview`
|
||||||
- 期间 webView 已渲染好 H5 大厅,无白屏闪烁
|
- 期间 webView 已渲染好 H5 大厅,无白屏闪烁
|
||||||
@@ -799,7 +801,7 @@ H5 调 `OpenurlTitleData` 打开弹层 WebView,弹层内 H5 用 `window.settin
|
|||||||
- [x] 1.14.a AppIcon 替换 Xcode 默认空模板(iPad / 1024 缺失留 Phase 10 polish)
|
- [x] 1.14.a AppIcon 替换 Xcode 默认空模板(iPad / 1024 缺失留 Phase 10 polish)
|
||||||
- [x] 1.14.b LaunchScreen 改启动图(SplashImage 全屏 scaleAspectFill,竖图横屏裁剪折衷)
|
- [x] 1.14.b LaunchScreen 改启动图(SplashImage 全屏 scaleAspectFill,竖图横屏裁剪折衷)
|
||||||
- [x] 1.14.c LobbyZipUpgrader 加 progress 回调(URLSessionDownloadDelegate,0.0…1.0)
|
- [x] 1.14.c LobbyZipUpgrader 加 progress 回调(URLSessionDownloadDelegate,0.0…1.0)
|
||||||
- [ ] 1.14.d WebContainerViewController(含 splash UI 状态层 + 16:9 letterbox + 启动流水线串接)
|
- [x] 1.14.d WebContainerViewController(SplashOverlay + 16:9 letterbox + 启动流水线 + 三种 alert)
|
||||||
- [ ] 1.14.e WebView didFinish 后淡出 splash
|
- [ ] 1.14.e WebView didFinish 后淡出 splash
|
||||||
- [ ] 1.15 VibratorHandler
|
- [ ] 1.15 VibratorHandler
|
||||||
- [ ] 1.16 SceneDelegate → WebContainerViewController
|
- [ ] 1.16 SceneDelegate → WebContainerViewController
|
||||||
|
|||||||
@@ -0,0 +1,294 @@
|
|||||||
|
//
|
||||||
|
// WebContainerViewController.swift
|
||||||
|
// ylgamehall
|
||||||
|
//
|
||||||
|
// H5 大厅承载容器:BridgedWebView 嵌入 + 16:9 letterbox + Splash 覆盖层
|
||||||
|
// + 启动流水线(ensureReady → fetch → resolve → upgrade → loadFileURL)。
|
||||||
|
// 详见 docs/H5-Native-Implementation-Design.md §6.3.5。
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
import WebKit
|
||||||
|
|
||||||
|
public final class WebContainerViewController: UIViewController {
|
||||||
|
|
||||||
|
// MARK: - UI
|
||||||
|
|
||||||
|
private let bridgedWebView = BridgedWebView()
|
||||||
|
private let splash = SplashOverlay()
|
||||||
|
|
||||||
|
// MARK: - Boot pipeline 内部错误分类
|
||||||
|
//
|
||||||
|
// 用 enum 区分"终态阻塞"与"系统级错误":
|
||||||
|
// - shortText / ipaUpgrade 是运营杀手锏,弹窗后永停(无重试按钮)
|
||||||
|
// - 其它(网络 / 解析 / 解压 / 下载失败)→ showFatalAlert,提供重试
|
||||||
|
|
||||||
|
private enum BootError: Error {
|
||||||
|
/// 短文本响应 或 showmessage 非空:契约 §3.2 运营杀手锏 #1/#2
|
||||||
|
case operationalMessage(String)
|
||||||
|
/// IPA 需要升级,弹窗 + Safari 外链
|
||||||
|
case ipaUpgradeRequired(downloadURL: String)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Lifecycle
|
||||||
|
|
||||||
|
public override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
view.backgroundColor = .black
|
||||||
|
|
||||||
|
setupBridgedWebView()
|
||||||
|
setupSplash()
|
||||||
|
|
||||||
|
bridgedWebView.webView.navigationDelegate = self
|
||||||
|
|
||||||
|
Task { @MainActor in
|
||||||
|
await runBootPipeline()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public override var supportedInterfaceOrientations: UIInterfaceOrientationMask { .landscape }
|
||||||
|
public override var preferredInterfaceOrientationForPresentation: UIInterfaceOrientation { .landscapeRight }
|
||||||
|
public override var prefersStatusBarHidden: Bool { false }
|
||||||
|
|
||||||
|
// MARK: - 16:9 letterbox 布局
|
||||||
|
//
|
||||||
|
// H5 设计分辨率 1280×720(16:9)。设备比例与 16:9 不匹配时用黑边补齐:
|
||||||
|
// - 比 16:9 宽(如现代 iPhone 横屏 2.16:1)→ 左右黑边
|
||||||
|
// - 比 16:9 窄(如 iPad 4:3)→ 上下黑边
|
||||||
|
//
|
||||||
|
// 约束策略:
|
||||||
|
// required:aspect = 16:9,width ≤ view.width,height ≤ view.height
|
||||||
|
// low: width = view.width,height = view.height(尽量撑满)
|
||||||
|
// 居中: centerX / centerY
|
||||||
|
// Auto Layout 会自动选择"短边贴边、长边居中"的解。
|
||||||
|
|
||||||
|
private func setupBridgedWebView() {
|
||||||
|
bridgedWebView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
view.addSubview(bridgedWebView)
|
||||||
|
|
||||||
|
let aspect = bridgedWebView.widthAnchor.constraint(
|
||||||
|
equalTo: bridgedWebView.heightAnchor,
|
||||||
|
multiplier: 16.0 / 9.0
|
||||||
|
)
|
||||||
|
let widthMax = bridgedWebView.widthAnchor.constraint(lessThanOrEqualTo: view.widthAnchor)
|
||||||
|
let heightMax = bridgedWebView.heightAnchor.constraint(lessThanOrEqualTo: view.heightAnchor)
|
||||||
|
let widthFill = bridgedWebView.widthAnchor.constraint(equalTo: view.widthAnchor)
|
||||||
|
let heightFill = bridgedWebView.heightAnchor.constraint(equalTo: view.heightAnchor)
|
||||||
|
widthFill.priority = .defaultLow
|
||||||
|
heightFill.priority = .defaultLow
|
||||||
|
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
bridgedWebView.centerXAnchor.constraint(equalTo: view.centerXAnchor),
|
||||||
|
bridgedWebView.centerYAnchor.constraint(equalTo: view.centerYAnchor),
|
||||||
|
aspect, widthMax, heightMax, widthFill, heightFill
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupSplash() {
|
||||||
|
splash.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
view.addSubview(splash)
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
splash.topAnchor.constraint(equalTo: view.topAnchor),
|
||||||
|
splash.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||||
|
splash.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||||
|
splash.trailingAnchor.constraint(equalTo: view.trailingAnchor)
|
||||||
|
])
|
||||||
|
splash.update(text: "拼命启动中...", progress: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Boot pipeline
|
||||||
|
|
||||||
|
private func runBootPipeline() async {
|
||||||
|
// 重新进入 pipeline 时(重试路径):重置 splash 并显示
|
||||||
|
splash.isHidden = false
|
||||||
|
splash.update(text: "拼命启动中...", progress: nil)
|
||||||
|
|
||||||
|
do {
|
||||||
|
try await runBootPipelineSteps()
|
||||||
|
} catch BootError.operationalMessage(let msg) {
|
||||||
|
showBlockingAlert(message: msg)
|
||||||
|
} catch BootError.ipaUpgradeRequired(let dl) {
|
||||||
|
showIPAUpgradeAlert(downloadURL: dl)
|
||||||
|
} catch {
|
||||||
|
showFatalAlert(error: error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func runBootPipelineSteps() async throws {
|
||||||
|
// 1. 首装解压(仅首次安装走真正的 unzip;之后 fileExists 短路 ≤ 1ms)
|
||||||
|
try await ResourceUnzipper.shared.ensureReady()
|
||||||
|
|
||||||
|
// 2. 拉远端渠道配置
|
||||||
|
splash.update(text: "拉取配置中...", progress: nil)
|
||||||
|
let outcome = try await RemoteConfigClient.shared.fetch()
|
||||||
|
|
||||||
|
switch outcome {
|
||||||
|
case .shortText(let msg):
|
||||||
|
// 运营杀手锏 #1:运营把整段服务端响应替换为短文本,前端弹窗永停
|
||||||
|
throw BootError.operationalMessage(msg)
|
||||||
|
|
||||||
|
case .parsed(let cfg):
|
||||||
|
let bc = BundleConfig.shared
|
||||||
|
let resolved = VersionResolver.resolve(
|
||||||
|
config: cfg,
|
||||||
|
agentId: bc.agent,
|
||||||
|
channelId: bc.channel,
|
||||||
|
marketId: bc.market,
|
||||||
|
gameId: bc.gameId
|
||||||
|
)
|
||||||
|
|
||||||
|
// 3. 运营杀手锏 #2:showmessage 非空 → 弹窗永停
|
||||||
|
if let msg = resolved.showmessage, !msg.isEmpty {
|
||||||
|
throw BootError.operationalMessage(msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. IPA 升级(远端 appVersion > 本地):弹窗 + Safari 外链永停
|
||||||
|
if resolved.appVersion > LocalVersionReader.localAppVersion,
|
||||||
|
let dl = resolved.appDownload {
|
||||||
|
throw BootError.ipaUpgradeRequired(downloadURL: dl)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. H5 zip 升级(远端 gameVersion > 本地):进度条实时更新
|
||||||
|
// LobbyZipUpgrader 的 onProgress 由 URLSession delegate 在后台队列调度,
|
||||||
|
// 必须显式 hop 到 MainActor 才能动 splash UI。
|
||||||
|
_ = try await LobbyZipUpgrader.shared.upgradeIfNeeded(
|
||||||
|
resolved: resolved,
|
||||||
|
onProgress: { [weak self] p in
|
||||||
|
Task { @MainActor in
|
||||||
|
self?.splash.update(
|
||||||
|
text: String(format: "下载更新中 %d%%", Int(p * 100)),
|
||||||
|
progress: p
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. 加载本地 H5(allowingReadAccessTo 必须给 lobbyRoot 才能跨目录引用资源)
|
||||||
|
splash.update(text: "加载大厅...", progress: nil)
|
||||||
|
bridgedWebView.webView.loadFileURL(
|
||||||
|
SandboxPaths.lobbyIndex,
|
||||||
|
allowingReadAccessTo: SandboxPaths.lobbyRoot
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Alerts (terminal / retry states)
|
||||||
|
|
||||||
|
private var appDisplayName: String {
|
||||||
|
(Bundle.main.infoDictionary?["CFBundleDisplayName"] as? String)
|
||||||
|
?? (Bundle.main.infoDictionary?["CFBundleName"] as? String)
|
||||||
|
?? "提示"
|
||||||
|
}
|
||||||
|
|
||||||
|
private func showBlockingAlert(message: String) {
|
||||||
|
let alert = UIAlertController(
|
||||||
|
title: "\(appDisplayName) 提醒",
|
||||||
|
message: message,
|
||||||
|
preferredStyle: .alert
|
||||||
|
)
|
||||||
|
alert.addAction(UIAlertAction(title: "确定", style: .default))
|
||||||
|
present(alert, animated: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func showIPAUpgradeAlert(downloadURL: String) {
|
||||||
|
let alert = UIAlertController(
|
||||||
|
title: "需要升级",
|
||||||
|
message: "检测到新版本,请前往下载安装。",
|
||||||
|
preferredStyle: .alert
|
||||||
|
)
|
||||||
|
alert.addAction(UIAlertAction(title: "确定", style: .default) { _ in
|
||||||
|
if let url = URL(string: downloadURL) {
|
||||||
|
UIApplication.shared.open(url)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
present(alert, animated: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func showFatalAlert(error: Error) {
|
||||||
|
let alert = UIAlertController(
|
||||||
|
title: "启动失败",
|
||||||
|
message: "\(error)",
|
||||||
|
preferredStyle: .alert
|
||||||
|
)
|
||||||
|
alert.addAction(UIAlertAction(title: "重试", style: .default) { [weak self] _ in
|
||||||
|
Task { @MainActor in
|
||||||
|
await self?.runBootPipeline()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
present(alert, animated: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - WKNavigationDelegate
|
||||||
|
|
||||||
|
extension WebContainerViewController: WKNavigationDelegate {
|
||||||
|
public func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
||||||
|
// Phase 1.14.d 阶段:直接隐藏 splash(无动画)
|
||||||
|
// Phase 1.14.e 将替换为 UIView.animate(0.3) 淡出 + removeFromSuperview
|
||||||
|
splash.isHidden = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - SplashOverlay
|
||||||
|
//
|
||||||
|
// 与 LaunchScreen 同款启动图 + UIProgressView + 状态文字标签的复合 view。
|
||||||
|
// 单文件私有:仅 WebContainerViewController 使用;若 Phase 6 子游戏容器复用,再抽出。
|
||||||
|
|
||||||
|
private final class SplashOverlay: UIView {
|
||||||
|
|
||||||
|
private let imageView = UIImageView()
|
||||||
|
private let progressView = UIProgressView(progressViewStyle: .default)
|
||||||
|
private let label = UILabel()
|
||||||
|
|
||||||
|
init() {
|
||||||
|
super.init(frame: .zero)
|
||||||
|
backgroundColor = .black
|
||||||
|
|
||||||
|
imageView.contentMode = .scaleAspectFill
|
||||||
|
imageView.image = UIImage(named: "SplashImage")
|
||||||
|
imageView.clipsToBounds = true
|
||||||
|
imageView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
addSubview(imageView)
|
||||||
|
|
||||||
|
label.textColor = .white
|
||||||
|
label.font = .systemFont(ofSize: 14, weight: .medium)
|
||||||
|
label.textAlignment = .center
|
||||||
|
label.numberOfLines = 1
|
||||||
|
label.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
addSubview(label)
|
||||||
|
|
||||||
|
progressView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
progressView.isHidden = true
|
||||||
|
addSubview(progressView)
|
||||||
|
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
imageView.topAnchor.constraint(equalTo: topAnchor),
|
||||||
|
imageView.bottomAnchor.constraint(equalTo: bottomAnchor),
|
||||||
|
imageView.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||||
|
imageView.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||||||
|
|
||||||
|
label.centerXAnchor.constraint(equalTo: centerXAnchor),
|
||||||
|
label.bottomAnchor.constraint(equalTo: safeAreaLayoutGuide.bottomAnchor, constant: -32),
|
||||||
|
label.leadingAnchor.constraint(greaterThanOrEqualTo: leadingAnchor, constant: 24),
|
||||||
|
label.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor, constant: -24),
|
||||||
|
|
||||||
|
progressView.centerXAnchor.constraint(equalTo: centerXAnchor),
|
||||||
|
progressView.bottomAnchor.constraint(equalTo: label.topAnchor, constant: -12),
|
||||||
|
progressView.widthAnchor.constraint(equalTo: widthAnchor, multiplier: 0.45)
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) { fatalError("SplashOverlay does not support init(coder:)") }
|
||||||
|
|
||||||
|
func update(text: String, progress: Double?) {
|
||||||
|
label.text = text
|
||||||
|
if let p = progress {
|
||||||
|
progressView.isHidden = false
|
||||||
|
progressView.setProgress(Float(p), animated: true)
|
||||||
|
} else {
|
||||||
|
progressView.isHidden = true
|
||||||
|
progressView.setProgress(0, animated: false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user