Files
youle_app_ios_v2/ylgamehall/Source/WebView/WebContainerViewController.swift
T
joywayerandClaude Opus 4.7 829cc8f825 代码层落地 AppDataWriter + NetworkMonitor + WebContainer 接入
按 Design §7.5 蓝图落地,让 H5 启动时能从沙盒读到实际渠道/启动/设备
值,闭合 Phase 1.17 联调"app_gameconfig 不正确"现象。

新增 Source/WebView/AppDataWriter.swift:
  - public struct AppDataWriter(@MainActor)
  - ContainerRole enum:.lobby / .subGame(name:, dir:)
  - writeInitial():写 app_data.js(12 项)+ app_gamesname.js(暂只写当前
    gameStart 一项,Phase 6 子游戏完整时扩为扫描已装列表)
  - writeBattery(level:):写 app_battery.js(var app_getbattery=N;)
  - writeNetwork(code:):写 app_network.js(var app_getnetwork=N;)
  - 字面严格对齐 msext:
    * 字符串单引号包裹 'value'
    * 数值无引号(version=1 / Launchtype=0 / getwifisignalLevel=1)
    * app_gamesname 用 "var  app_gamesname=new Array(...)"(var 后两空格)
    * 大小写硬约束:Launchtype L 大、getwifisignalLevel wifi 小 + signal/Level 区分
  - escape 转义反斜杠 / 单引号 / 换行(避免渠道字段含单引号导致 H5 JS 解析错)
  - Logger debug 输出文件路径 + 每个 key=value 一行,便于 §7.5.8 联调对账

新增 Source/Resource/NetworkMonitor.swift:
  - @MainActor public final class NetworkMonitor
  - NWPathMonitor 最简 wrap:currentCode 同步快照(默认 2 WiFi,启动后被
    首次 path 更新)+ onChange MainActor 回调
  - shared 单例 + 幂等 start()

WebContainer.runBootPipelineSteps 接入:
  - switch outcome 前提取 let resolved: ResolvedVersion,避免变量作用域
    限制 case 内
  - 新增 writeAppDataFiles(resolved:):启动 NetworkMonitor + 开 battery
    监控 + 写 4 个文件首次值 + 挂 addObserver(battery) + onChange(network)
  - 调用位置:step 5 LobbyZipUpgrader 之后、step 6 loadFileURL 之前
    (与 msext NewRootVC.initJSdata 等价时序)

BuildProject 通过

Plan §6.4 里程碑加一行。

Phase 1.17 联调验证:启动后 H5 console 应读到实际 app_channel /
app_gameconfig 等值,不再是 H5 zip 包内默认占位。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-22 12:44:45 +08:00

350 lines
14 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: - 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+ 在此追加更多 handler(保持单一聚合点便于发现 / 调试)。
private func registerBridgeHandlers() {
VibratorHandler.register(on: bridgedWebView.bridge)
}
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 文件,并挂钩 battery / network 变化的重写监听。
/// 与原 msext NewRootVC.initJSdata + changebattery + changenetstate 等价。
private func writeAppDataFiles(resolved: ResolvedVersion) throws {
let writer = AppDataWriter(
bundleConfig: .shared,
resolvedVersion: resolved,
containerRole: .lobby
)
// 启动 NetworkMonitor(幂等:内部 started 标记)
NetworkMonitor.shared.start()
UIDevice.current.isBatteryMonitoringEnabled = true
// 首次写入:4 个文件全部落盘
try writer.writeInitial()
try writer.writeBattery(UIDevice.current.batteryLevel)
try writer.writeNetwork(NetworkMonitor.shared.currentCode)
// 持续监听:battery / network 变化时重写对应文件。
// 注意:H5 此次加载已读到首次写入的值,下次 reload 才会读到新值;
// 业务期间的实时变化由 §3.2 [2][3]反向 callback 推送(Phase 2 实现)。
NotificationCenter.default.addObserver(
forName: UIDevice.batteryLevelDidChangeNotification,
object: nil,
queue: .main
) { _ in
try? writer.writeBattery(UIDevice.current.batteryLevel)
}
NetworkMonitor.shared.onChange = { code in
try? writer.writeNetwork(code)
}
}
// 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)
}
}
}