Files
youle_app_ios_v2/ylgamehall/Source/WebView/WebContainerViewController.swift
T
joywayerandClaude Opus 4.7 d38ff0f8c4 Phase 2.A:大厅 8 个简单 handler 落地 + BridgeData 加 nonisolated
闭合 Phase 1.17 联调最后一公里(解决 Xcode 日志 4 个 "no handler registered"
+ 提前落地 4 个无外部依赖的简单 handler)。

新增 7 个 handler 模块(Source/Bridge/Handlers/):
  - ClipboardHandler.swift           §3.1 [13][14]剪贴板 UIPasteboard.general
  - ShakeHandler.swift               §3.1 [7][8][9]摇一摇 + §3.2 [5]shakeEnd 反向
                                       canShake / canVoice 状态 + motionEnded 转发钩子
  - VoicePlayingHandler.swift        §3.1 [6]actor VoiceCenter.shared 开关位
  - DeviceInfoHandler.swift          §3.1 [21]+ §3.2 [1]getphoneInfo 大写 I 入、
                                       getphoneinfo 小写 i 出反向 callback
                                       DeviceInfoSnapshot 6 字段(IDFA 用 idfv 兜底,
                                       CLAUDE.md 不接 ATT/IDFA)
  - BrowserHandler.swift             §3.1 [16]UIApplication.open async(Task @MainActor
                                       异步打开,闭包先 cb 不等 open 完成)
  - OpenSaomaHandler.swift           §3.1 [22]空 stub(契约要求注册)
  - StartLocationHandler.swift       §3.1 [20]Phase 5 完整实现,Phase 2 stub

扩展 VibratorHandler.swift:
  - §3.1 [10]vibrator(原有)+ [11]repeatvibrator(msext RootVC.m:1820 沿用:
    实际单次 vibrate 不真循环)+ [12]canclevibrator

BridgeProtocol.swift BridgeData 加 nonisolated:
  - 项目默认 SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor 让 BridgeData 跨 actor
    访问被拦;asString / asDouble / asInt / asBool / asArray / asObject /
    subscript 全部标 nonisolated(纯值类型本就该是 isolation-free)
  - 影响所有 @Sendable async handler 闭包内的 data?.asXxx 访问

WebContainerViewController 接入:
  - 加 private let shakeHandler = ShakeHandler() 持有
  - registerBridgeHandlers() 注册 8 个 handler 模块
  - 加 canBecomeFirstResponder = true + viewDidAppear becomeFirstResponder
  - 加 motionEnded(_:with:) 转发到 shakeHandler.handleMotionEnded
    (motion 检测必需 first responder,否则不触发)

BuildProject 通过。重装 app 后 H5 启动应不再报 "no handler registered" 错误。

Plan §5 Phase 2.A 全部勾选(含 startlocation bonus stub + BridgeData
nonisolated 重构)。

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

381 lines
15 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 变化的重写监听。
/// 与原 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)
}
}
}