Files
youle_app_ios_v2/ylgamehall/Source/WebView/WebContainerViewController.swift
T
joywayerandClaude Opus 4.7 23b3a1fdad Phase 2.B:3 项反向 callback 落地(getBattery / getnetwork / appservice)
完成大厅业务期实时状态推送给 H5 的事件驱动链路。每次变化时同时(1)
重写对应 app_*.js 文件供下次 reload,(2)bridge.call 反向推送给当前
页 H5 — 双轨与原 msext 行为等价。

新增 Source/Resource/BatteryMonitor.swift:
  - @MainActor public final class BatteryMonitor,shared 单例 + 幂等 start()
  - 监听 UIDevice.batteryLevelDidChangeNotification + 启动期读首次值
  - currentLevel 同步快照(max(level, 0) 兜底模拟器 -1)+ onChange 回调
  - notification block 内 MainActor.assumeIsolated 安全跨到 main actor

新增 Source/Resource/AppLifecycleObserver.swift:
  - @MainActor public final class AppLifecycleObserver,shared 单例 + 幂等 start()
  - 监听 didEnterBackgroundNotification + willEnterForegroundNotification
  - onBackground / onForeground 双钩子;assumeIsolated 同上

WebContainerViewController.writeAppDataFiles 接入(替换 inline addObserver):
  - 3 个 monitor 启动统一聚合(NetworkMonitor + BatteryMonitor + AppLifecycle)
  - BatteryMonitor.onChange:writeBattery + bridge.call("getBattery", "%.2f")
  - NetworkMonitor.onChange:writeNetwork + bridge.call("getnetwork", "1"/"2"/"3")
  - AppLifecycle.onBackground/Foreground:bridge.call("appservice", "1"/"2")
  - 首次值改读 BatteryMonitor.currentLevel 而非 UIDevice 原始值

至此 Phase 2.B 全部完成(5 项反向 callback:getphoneinfo / getBattery /
getnetwork / appservice / shakeEnd 全部接好)。

剩 Phase 2.C ExternalSubscriptions 生命周期管理(防双发,栈深 ≥ 2 时
下层不发桥事件)。

BuildProject 通过

Plan §5 Phase 2.B 全部勾选

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

398 lines
16 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: - 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
}
// MARK: - 摇一摇支持
// motionEnded 必须 first responder 才触发;canBecomeFirstResponder = true +
// viewDidAppear becomeFirstResponder。详见 Design §8.7.1。
public override var canBecomeFirstResponder: Bool { true }
public override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
becomeFirstResponder()
}
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 文件,并挂钩 battery / network / appservice 三组钩子:
/// 每次变化时同时(1)重写对应 .js 文件(下次 reload 读到新值)
/// 和(2`bridge.call` 反向推送给当前页 H5(§3.2 [2][3][4])。
/// 与原 msext NewRootVC.initJSdata + changebattery + changenetstate + appservice 等价。
private func writeAppDataFiles(resolved: ResolvedVersion) throws {
let writer = AppDataWriter(
bundleConfig: .shared,
resolvedVersion: resolved,
containerRole: .lobby
)
// 启动 3 个 monitor(幂等:内部 started 标记)
NetworkMonitor.shared.start()
BatteryMonitor.shared.start()
AppLifecycleObserver.shared.start()
// 首次写入:4 个文件全部落盘
try writer.writeInitial()
try writer.writeBattery(BatteryMonitor.shared.currentLevel)
try writer.writeNetwork(NetworkMonitor.shared.currentCode)
let bridge = bridgedWebView.bridge
// §3.2 2 getBattery — UIDevice 电量变化
// data: 字符串 "%.2f" 形式的小数电量
BatteryMonitor.shared.onChange = { level in
try? writer.writeBattery(level)
bridge.call("getBattery",
data: .string(String(format: "%.2f", level)),
callback: nil)
}
// §3.2 3 getnetwork — NWPath 变化
// data: 字符串 "1"=无网 / "2"=WiFi / "3"=蜂窝
NetworkMonitor.shared.onChange = { code in
try? writer.writeNetwork(code)
bridge.call("getnetwork",
data: .string("\(code)"),
callback: nil)
}
// §3.2 4 appservice — App 前后台切换(命名错位沿用历史)
// data: "1"=进入后台 / "2"=回到前台
AppLifecycleObserver.shared.onBackground = {
bridge.call("appservice", data: .string("1"), callback: nil)
}
AppLifecycleObserver.shared.onForeground = {
bridge.call("appservice", data: .string("2"), callback: 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)
}
}
}