Files
youle_app_ios_v2/ylgamehall/Source/WebView/WebContainerViewController.swift
T
joywayerandClaude Opus 4.7 5f3a435180 Phase 6 commit C:backgameData pop + getWebdata 反向 callback
- 新增 Source/Bridge/Handlers/BackGameDataHandler.swift(仅 SubGameViewController 注册):
  - 调 AudioPlayer.stopAllBackground 停背景音
  - AppCoordinator.popSubGame(returningData:) → popViewController + 发 .subGameDidReturn
  - data 兼容 string / object(非字符串走 JSONSerialization 序列化透传)
  - 字面 cb "backgameData"(msext gameController.m:691-709 等价,
    WXApi 清理跳过 — 微信 SDK 待 Phase 4.E)
- AudioPlayer.stopAllBackground:无条件停背景音(msext 不看 type 直接 nil 行为)
- SubGameViewController.registerBridgeHandlers:挂上 BackGameDataHandler,
  注释更新 exitRoom/getVideoinfo/createRoom 推迟到 Phase 8
- WebContainerViewController:在 setupExternalSubscriptions 挂 .subGameDidReturn
  观察者 → bridge.call("getWebdata", .string(data)),teardown 时 removeObserver
  (生命周期与 battery/network/appservice 同一对,子游戏栈顶时大厅已 teardown
  避免双发)
- Plan §5.6.3 / 6.5 / 6.6 / §8 进度已勾选

至此 SwitchOverGameData → push 子游戏 → backgameData → pop + getWebdata
完整链路接通;子游戏视频房间(exitRoom/getVideoinfo/createRoom)留待 Phase 8。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-22 21:05:23 +08:00

476 lines
20 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//
// 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 仅 vibratorPhase 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 20Phase 5 完整实现,Phase 2 stub
// Phase 3.A 本地音频 + 3.C/3.D stub
LocalAudioHandler.register(on: bridge) // §3.1 3srcIsloop 完整实现
RemoteAudioHandler.register(on: bridge) // §3.1 4][5Phase 3.B/3.C/3.D stub
// Phase 4.A 登录 + 分享 stubPhase 4.B 微信 SDK / Phase 4.C QQ URL Scheme 后升级)
AccreditLoginHandler.register(on: bridge) // §3.1 1Phase 4.E 完整微信 SDK
FriendsShareHandler.register(on: bridge) // §3.1 2Phase 4.B 完整框架(QQ/抖音 已 4.C/4.D 落地)
// Phase 6.4 / 7 子游戏 + 弹层入口 stub(让 H5 业务期调时不报 "no handler"
SwitchOverGameHandler.register(on: bridge) // §3.1 17Phase 6 完整实现
OpenurlTitleDataHandler.register(on: bridge) // §3.1 15Phase 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×72016:9)。设备比例与 16:9 不匹配时用黑边补齐:
// - 比 16:9 宽(如现代 iPhone 横屏 2.16:1)→ 左右黑边
// - 比 16:9 窄(如 iPad 4:3)→ 上下黑边
//
// 约束策略:
// requiredaspect = 16:9width ≤ view.widthheight ≤ view.height
// low width = view.widthheight = 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()
let resolved: ResolvedVersion
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
)
// 3. 运营杀手锏 #2showmessage 非空 → 弹窗永停
if let msg = r.showmessage, !msg.isEmpty {
throw BootError.operationalMessage(msg)
}
// 4. IPA 升级(远端 appVersion > 本地):弹窗 + Safari 外链永停
if r.appVersion > LocalVersionReader.localAppVersion,
let dl = r.appDownload {
throw BootError.ipaUpgradeRequired(downloadURL: dl)
}
// 5. H5 zip 升级(远端 gameVersion > 本地):进度条实时更新
// LobbyZipUpgrader 的 onProgress 由 URLSession delegate 在后台队列调度,
// 必须显式 hop 到 MainActor 才能动 splash UI。
_ = 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
)
}
}
)
resolved = r
}
// 6. 在 loadFileURL 之前写 4 个 app_*.js(与原 msext NewRootVC.initJSdata 等价时序)
// 详见 docs/H5-Native-Implementation-Design.md §7.5
try writeAppDataFiles(resolved: resolved)
// 7. 加载本地 H5allowingReadAccessTo 必须给 lobbyRoot 才能跨目录引用资源)
splash.update(text: "加载大厅...", 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 → setupviewWillDisappear → teardown。
// 配套约束:未来 Phase 6 子游戏 push 上来时,大厅的 viewWillDisappear 会自动
// 触发 teardown,避免桥事件双发(参 §2.4.3 栈深 ≤ 2)。
//
// 业务期变化时**不重写文件**,改为直接 evaluateJavaScript 重新赋值 window.app_*
// 全局变量(H5 已加载完,<script src> 不会再 fetch;重新赋值后 H5 业务期同步读
// app_xxx 永远拿到最新值)。同时触发 §3.2 反向 callback 推送给注册了 handler 的
// H5 业务。详见 docs/H5-Native-Implementation-Design.md §7.5.2 / §7.5.4。
private func setupExternalSubscriptions() {
// 幂等启动 3 个 monitorwriteAppDataFiles 可能已经启动了 NetworkMonitor /
// BatteryMonitorAppLifecycleObserver 在 setup 内首次启动)
NetworkMonitor.shared.start()
BatteryMonitor.shared.start()
AppLifecycleObserver.shared.start()
let bridge = bridgedWebView.bridge
let webView = bridgedWebView.webView
// §3.2 2 getBattery — UIDevice 电量变化
BatteryMonitor.shared.onChange = { level in
let value = String(format: "%.2f", level)
Task { @MainActor in
_ = try? await webView.evaluateJavaScript("window.app_getbattery=\(value);")
}
bridge.call("getBattery", data: .string(value), callback: nil)
}
// §3.2 3 getnetwork — NWPath 变化(1 无网 / 2 WiFi / 3 蜂窝)
NetworkMonitor.shared.onChange = { code in
Task { @MainActor in
_ = try? await webView.evaluateJavaScript("window.app_getnetwork=\(code);")
}
bridge.call("getnetwork", data: .string("\(code)"), callback: nil)
}
// §3.2 4 appservice — App 前后台切换(命名错位沿用历史)
AppLifecycleObserver.shared.onBackground = {
bridge.call("appservice", data: .string("1"), callback: nil)
}
AppLifecycleObserver.shared.onForeground = {
bridge.call("appservice", data: .string("2"), callback: nil)
}
// §3.2 12 getWebdata — 子游戏 pop 回大厅,反向把 backgameData 入参透传给 H5
// msext NewRootVC 监听 "backgameDatatwo" 通知 → callHandler:"getWebdata" 等价)
// 大厅栈顶时挂钩,pop 进子游戏时由 viewWillDisappear teardown 解绑,避免双发。
subGameReturnObserver = NotificationCenter.default.addObserver(
forName: .subGameDidReturn,
object: nil,
queue: .main
) { note in
// queue=.main 保证 closure 在主线程;显式 hop 让 Swift 6 actor 推断通过
let data = (note.userInfo?["data"] as? String) ?? ""
MainActor.assumeIsolated {
bridge.call("getWebdata", data: .string(data), callback: nil)
}
}
}
private func teardownExternalSubscriptions() {
// 仅解绑 onChange / on{Background,Foreground} 回调,monitor 继续在
// 后台运行(保持 currentXxx 状态新鲜),下次 setup 时直接 resume。
// 单例 monitor 全局只有一个 closure 引用,子游戏 setup 时会覆盖;
// 大厅 viewWillDisappear 时 teardown 释放对当前 bridge/webView 的引用
// 避免双发。
BatteryMonitor.shared.onChange = nil
NetworkMonitor.shared.onChange = nil
AppLifecycleObserver.shared.onBackground = nil
AppLifecycleObserver.shared.onForeground = nil
if let token = subGameReturnObserver {
NotificationCenter.default.removeObserver(token)
subGameReturnObserver = nil
}
}
// 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!) {
// H5 大厅已经渲染完毕,此时把 splash 从 0.3s 内淡出再 removeFromSuperview
// 避免黑屏闪烁也避免后续 superview 再持有这层不必要的视图。
UIView.animate(withDuration: 0.3, animations: { [weak self] in
self?.splash.alpha = 0
}, completion: { [weak self] _ in
self?.splash.removeFromSuperview()
})
}
}
// 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
// 与 LaunchScreen.storyboard 同款 contentMode:素材是 1136×640 横屏画面,
// 在更宽的现代横屏设备上用 scaleAspectFit 左右补黑边(黑底统一),
// 避免 scaleAspectFill 把 logo 上下裁切;同时确保 LaunchScreen → WebContainer
// 视觉过渡时不出现裁切方式跳变。
imageView.contentMode = .scaleAspectFit
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)
}
}
}