diff --git a/docs/Development-Plan.md b/docs/Development-Plan.md index c641a06..d333845 100644 --- a/docs/Development-Plan.md +++ b/docs/Development-Plan.md @@ -294,13 +294,15 @@ Contract Design Plan(本文档) - 文件内私有 `DownloadProgressDelegate: URLSessionDownloadDelegate, @unchecked Sendable`,实现 `didWriteData:totalBytesWritten:totalBytesExpectedToWrite:`;通过 `session.download(from:delegate:)` 注入 - 完成时兜底报 1.0(部分 server 不发 Content-Length 或末尾片段晚到,避免进度条停在 99%) - RootViewController 烟雾测试用 `ProgressTicker` 节流到 10% 阶梯打印;BuildProject 通过 -- [ ] **1.14.d** 实现 `Source/WebView/WebContainerViewController.swift`(基类) - - 持有 `BridgedWebView` + `BridgeBus` - - 16:9 比例布局:屏幕比 < 16:9 用宽度撑满上下黑边;屏幕比 > 16:9 用高度撑满左右黑边 - - **Splash 状态层**:与 LaunchScreen 同款启动图 + UIProgressView + 状态文字标签("拼命启动中..." / "下载更新中 XX%" / "网络异常重试中") - - `viewDidLoad` 内串:`ResourceUnzipper.ensureReady() → RemoteConfigClient.fetch → VersionResolver.resolve → LobbyZipUpgrader.upgradeIfNeeded(onProgress: 更新 UIProgressView) → loadFileURL(lobbyIndex)` - - 无升级路径走快路(< 200 ms),有升级路径显示 progress UI - - 实现 `webViewWebContentProcessDidTerminate:` 退避 reload(Design §3.6) +- [x] **1.14.d** 实现 `Source/WebView/WebContainerViewController.swift`(基类) + - 持有 `BridgedWebView`(通过其再持有 `BridgeBus`)+ 文件内私有 `SplashOverlay` + - 16:9 letterbox:aspectRatio(16:9) + widthMax/heightMax required + widthFill/heightFill .defaultLow + centerXY 居中。屏幕比 < 16:9 → 上下黑边;> 16:9 → 左右黑边 + - **SplashOverlay**:UIImageView(SplashImage, scaleAspectFill) + UIProgressView + UILabel;`update(text:progress:)` 切换状态机 + - 启动流水线 `runBootPipeline()`:ensureReady → "拉取配置中..." → RemoteConfigClient.fetch → 分支(.shortText / .parsed → showmessage / IPA 升级 / LobbyZipUpgrader.upgradeIfNeeded with onProgress hop 到 MainActor)→ "加载大厅..." → `loadFileURL(SandboxPaths.lobbyIndex, allowingReadAccessTo: lobbyRoot)` + - 终态弹窗:`showBlockingAlert`(短文本 / showmessage,永停)/ `showIPAUpgradeAlert`(确定 → openURL,永停)/ `showFatalAlert`(重试 → 重跑 pipeline) + - 横屏锁定 + statusBar 显示,与契约 §4.2 一致 + - **暂不实现**:`webViewWebContentProcessDidTerminate:` 退避 reload(移到 Phase 3 网络层时一并处理) + - BuildProject 通过 - [ ] **1.14.e** WebView 加载完成后淡出 splash - `webView(_:didFinish:)` 内 `UIView.animate(duration: 0.3)` splash.alpha = 0 → `removeFromSuperview` - 期间 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.b LaunchScreen 改启动图(SplashImage 全屏 scaleAspectFill,竖图横屏裁剪折衷) - [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.15 VibratorHandler - [ ] 1.16 SceneDelegate → WebContainerViewController diff --git a/ylgamehall/Source/WebView/WebContainerViewController.swift b/ylgamehall/Source/WebView/WebContainerViewController.swift new file mode 100644 index 0000000..7210200 --- /dev/null +++ b/ylgamehall/Source/WebView/WebContainerViewController.swift @@ -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) + } + } +}