// // 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: - Handlers (有状态的 handler 注册器持有,无状态的走 enum 静态 register) private let shakeHandler = ShakeHandler() // MARK: - .subGameDidReturn 观察者(子游戏 backgameData pop 后回大厅,反向 callback getWebdata) private var subGameReturnObserver: NSObjectProtocol? // 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 registerBridgeHandlers() Task { @MainActor in await runBootPipeline() } } /// 注册所有 H5 → Native handler 的单一聚合点。 /// Phase 1.15 仅 vibrator;Phase 2.A 扩展到 8 个 handler 模块。 private func registerBridgeHandlers() { let bridge = bridgedWebView.bridge // Phase 1.15 VibratorHandler.register(on: bridge) // §3.1 [10][11][12]扩展 // Phase 2.A 大厅简单 handler(无外部依赖) ClipboardHandler.register(on: bridge) // §3.1 [13][14] shakeHandler.register(on: bridge) // §3.1 [7][8][9] + §3.2 [5] VoicePlayingHandler.register(on: bridge) // §3.1 [6] DeviceInfoHandler.register(on: bridge) // §3.1 [21] + §3.2 [1] BrowserHandler.register(on: bridge) // §3.1 [16] OpenSaomaHandler.register(on: bridge) // §3.1 [22]空 stub StartLocationHandler.register(on: bridge) // §3.1 [20]Phase 5 完整实现,Phase 2 stub // Phase 3.A 本地音频 + 3.C/3.D stub LocalAudioHandler.register(on: bridge) // §3.1 [3]srcIsloop 完整实现 RemoteAudioHandler.register(on: bridge) // §3.1 [4][5]Phase 3.B/3.C/3.D stub // Phase 4.A 登录 + 分享 stub(Phase 4.B 微信 SDK / Phase 4.C QQ URL Scheme 后升级) AccreditLoginHandler.register(on: bridge) // §3.1 [1]Phase 4.E 完整微信 SDK FriendsShareHandler.register(on: bridge) // §3.1 [2]Phase 4.B 完整框架(QQ/抖音 已 4.C/4.D 落地) // Phase 6.4 / 7 子游戏 + 弹层入口 stub(让 H5 业务期调时不报 "no handler") SwitchOverGameHandler.register(on: bridge) // §3.1 [17]Phase 6 完整实现 OpenurlTitleDataHandler.register(on: bridge) // §3.1 [15]Phase 7 完整实现 } // MARK: - 摇一摇支持 // motionEnded 必须 first responder 才触发;canBecomeFirstResponder = true + // viewDidAppear becomeFirstResponder。详见 Design §8.7.1。 public override var canBecomeFirstResponder: Bool { true } public override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) // 外部订阅生命周期:栈顶时挂钩,避免子游戏 push 上来后双发(§2.4.2) setupExternalSubscriptions() } public override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) becomeFirstResponder() } public override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) teardownExternalSubscriptions() } public override func motionEnded(_ motion: UIEvent.EventSubtype, with event: UIEvent?) { shakeHandler.handleMotionEnded(motion) } 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 { // 走到这里说明:优雅降级也没救(多半是 ResourceUnzipper.ensureReady 失败 / // writeAppDataFiles 文件系统失败 等本地资源问题,因为网络相关错误已经在 // runBootPipelineSteps 内被 fallback 吞掉了)。仍按友好文案弹窗,不暴露原始 NSError。 showRetryableAlert(error: error) } } private func runBootPipelineSteps() async throws { // 1. 首装解压(仅首次安装走真正的 unzip;之后 fileExists 短路 ≤ 1ms) try await ResourceUnzipper.shared.ensureReady() // 2-5. 拉远端配置 + 升级判定,整段优雅降级: // - 网络 / 解析 / 下载失败 → 不抛错,用本地 LocalVersionReader 构造 fallback // ResolvedVersion,跳过升级判定直接走本地 H5 // - 业务流程信号(operationalMessage / ipaUpgradeRequired)正常抛出由外层弹窗 // // 行业主流做法:远端 config 失败 ≠ 启动失败。本地 zip 在 ensureReady 之后已就绪, // 没有远端 config 只是失去"能否升级判定",大厅 H5 本身完全可加载。这避免了用户 // 在 iOS 14+ 网络权限弹窗未响应 / 测试环境域名不可达 / 临时断网等场景下被 // "启动失败 + 原始 NSError" 卡死的差体验。 splash.update(text: "拉取配置中...", progress: nil) let resolved: ResolvedVersion let usedOfflineFallback: Bool do { 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 r = VersionResolver.resolve( config: cfg, agentId: bc.agent, channelId: bc.channel, marketId: bc.market, gameId: bc.gameId ) // 运营杀手锏 #2:showmessage 非空 → 弹窗永停 if let msg = r.showmessage, !msg.isEmpty { throw BootError.operationalMessage(msg) } // IPA 升级(远端 appVersion > 本地):弹窗 + Safari 外链永停 if r.appVersion > LocalVersionReader.localAppVersion, let dl = r.appDownload { throw BootError.ipaUpgradeRequired(downloadURL: dl) } // H5 zip 升级失败也走降级(继续用本地版本,不阻塞启动; // LobbyZipUpgrader.onProgress 由 URLSession delegate 在后台队列调度, // 必须显式 hop 到 MainActor 才能动 splash UI) do { _ = try await LobbyZipUpgrader.shared.upgradeIfNeeded( resolved: r, onProgress: { [weak self] p in Task { @MainActor in self?.splash.update( text: String(format: "下载更新中 %d%%", Int(p * 100)), progress: p ) } } ) } catch { print("[Boot] H5 zip 升级失败,继续用本地资源:", error) } resolved = r usedOfflineFallback = false } } catch let e as BootError { // 业务流程信号继续抛(外层弹 operational / IPA 升级弹窗) throw e } catch { // 远端 config 失败(多数是网络),优雅降级用本地版本继续启动。 // raw error 打到 Xcode console 便于排查(含 NSURLErrorDomain code 等); // 用户感知仅是 splash 多停一会儿 → 直接进入大厅,无致命弹窗。 print("[Boot] 远端配置拉取失败,降级为离线模式继续启动:", error) resolved = ResolvedVersion( appVersion: LocalVersionReader.localAppVersion, appDownload: nil, gameVersion: LocalVersionReader.localGameVersion, gameZip: nil, showmessage: nil ) usedOfflineFallback = true } // 6. 在 loadFileURL 之前写 4 个 app_*.js(与原 msext NewRootVC.initJSdata 等价时序) // 详见 docs/H5-Native-Implementation-Design.md §7.5 try writeAppDataFiles(resolved: resolved) // 7. 加载本地 H5(allowingReadAccessTo 必须给 lobbyRoot 才能跨目录引用资源) // splash 文案区分在线/离线,让用户对状态有感知(不暴露技术细节) splash.update(text: usedOfflineFallback ? "离线加载..." : "加载大厅...", progress: nil) bridgedWebView.webView.loadFileURL( SandboxPaths.lobbyIndex, allowingReadAccessTo: SandboxPaths.lobbyRoot ) } /// 写 4 个 app_*.js 文件首次值(loadFileURL 之前调一次,monitor 已启动)。 /// 业务期变化的桥事件挂钩由 `setupExternalSubscriptions` / `teardownExternalSubscriptions` /// 配对管理(详见 Design §2.4.2 ExternalSubscriptions 生命周期),避免大厅 push /// 子游戏后两个 VC 都在监听导致 H5 收到双倍桥事件。 private func writeAppDataFiles(resolved: ResolvedVersion) throws { let writer = AppDataWriter( bundleConfig: .shared, resolvedVersion: resolved, containerRole: .lobby ) // 启动 2 个 currentXxx 类 monitor(幂等)以便读首次值; // AppLifecycleObserver 不需要 currentXxx,挪到 setup 启动 NetworkMonitor.shared.start() BatteryMonitor.shared.start() // 首次写入:4 个文件全部落盘 try writer.writeInitial() try writer.writeBattery(BatteryMonitor.shared.currentLevel) try writer.writeNetwork(NetworkMonitor.shared.currentCode) } // MARK: - 外部订阅生命周期(Phase 2.C / Design §2.4.2) // // 把 battery / network / appservice 三个外部事件源的订阅挂钩与 VC 生命周期 // 配对:viewWillAppear → setup,viewWillDisappear → teardown。 // 配套约束:未来 Phase 6 子游戏 push 上来时,大厅的 viewWillDisappear 会自动 // 触发 teardown,避免桥事件双发(参 §2.4.3 栈深 ≤ 2)。 // // 业务期变化时**不重写文件**,改为直接 evaluateJavaScript 重新赋值 window.app_* // 全局变量(H5 已加载完,