Files
youle_app_ios_v2/ylgamehall/Source/WebView/SubGameViewController.swift
T
joywayerandClaude Opus 4.7 8125dd4d11 1.11 修订:VersionResolver 改单链 4 层 fallback,删除双子树合并算法(契约影响)
【契约影响】
- RemoteConfig 模型:Agent 节点移除 channellist 字段(线上 JSON 实际只挂 gamelist;此前误读 chulishengji 引入的 channellist 是空 schema)
- VersionResolver 解析算法:从"双子树 + 字段级合并"改为"agentlist → gamelist → channellist → marketlist 单链 4 层 fallback",行为按 daoqi NewRootVC.m:689-810 主路径
- 对外 API ResolvedVersion / resolve(config:agentId:channelId:marketId:gameId:) 签名零变化,WebContainerViewController / SubGameViewController 调用点未动

【设计要点】
- 4 节点类型 conform 同一 private protocol RemoteConfigNode
- 5 字段共用 pickString / pickInt 两个倒序 fallback 工具,不允许为某字段单独写 if 链
- buildChain 集中处理"任一层 id 不匹配立刻截断"语义

【文档同步】
- Design §6.3.2 整段重写
- Plan ADR-008 顶部追加"第三轮修订(2026-06-27)"小节 + 第二轮误读复盘
- Plan ADR-008-D 替换为单链 4 层算法说明 + 保留修订史
- Plan §1.11 / ADR-008-I / 最后更新日期 / Verification-Checklist L73 同步

参考契约章节:docs/H5-Native-Implementation-Design.md §6.3.2、docs/Development-Plan.md ADR-008-D
BuildProject 通过。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-27 20:04:19 +08:00

413 lines
17 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.
//
// SubGameViewController.swift
// ylgamehall
//
// 子游戏 H5 承载容器:BridgedWebView 全屏铺满 + Splash + boot pipeline。
// 与 WebContainerViewController(大厅)共享同款 BridgedWebView + SplashOverlay 样式,
// 但 boot 流程更短(不拉远端配置、不做 IPA 升级、不做 lobby zip 升级),仅:
// 1) 确保子游戏 H5 已就绪(commit B: SubGameDownloader.ensureReady
// 2) 写 app_*.jscontainerRole = .subGame
// 3) loadFileURL 子游戏 index.html
//
// 详见 docs/H5-Native-Implementation-Design.md §2.4.3 / §6.4。
//
import UIKit
import WebKit
public final class SubGameViewController: UIViewController {
// MARK: - 入参(从 SwitchOverGameData 解析)
private let request: SubGameRequest
// MARK: - 实际生效的子游戏目录名
//
// init 时从 SubGameDirectoryStore 同步读:首次 = request.gameDir,已升级 = 末尾追加
// 若干 "1" 的持久化值(msext gameController.m:1414 等价的 advance 链)。
// boot pipeline 内 SubGameDownloader.ensureReady 若触发升级(远端 game_version >
// 本地 version.xml),返回的 actualDirectory 会更新本属性。所有资源路径(loadFileURL /
// AppDataWriter / srcIsloop assets/wav)必须走它,而不是 request.gameDir。
private var effectiveGameDir: String
// MARK: - UI
private let bridgedWebView = BridgedWebView()
private let splash = SplashOverlay()
// MARK: - Handlers
private let shakeHandler = ShakeHandler()
// MARK: - .subGameDidReturn 观察者(overlay pop 回子游戏时反向 callback getWebdata
private var subGameReturnObserver: NSObjectProtocol?
// MARK: - Init
public init(request: SubGameRequest) {
self.request = request
// 同步读持久化目录名,registerBridgeHandlers 的 closure 捕获 self 即可读到当前值
self.effectiveGameDir = SubGameDirectoryStore.current(forKey: request.gameDir)
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("SubGameViewController requires SubGameRequest")
}
// MARK: - Lifecycle
public override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .black
setupBridgedWebView()
setupSplash()
bridgedWebView.webView.navigationDelegate = self
registerBridgeHandlers()
Task { @MainActor in
await runBootPipeline()
}
}
/// 注册子游戏 handler。大部分与大厅共用同一注册器(无状态 enum register),
/// 但 SwitchOverGameData 不再注册(子游戏不能 push 更深一层,符合 §2.4.3 栈深 ≤ 2)。
/// backgameData 在 commit C 接入。
private func registerBridgeHandlers() {
let bridge = bridgedWebView.bridge
VibratorHandler.register(on: bridge)
ClipboardHandler.register(on: bridge)
shakeHandler.register(on: bridge)
VoicePlayingHandler.register(on: bridge)
DeviceInfoHandler.register(on: bridge)
BrowserHandler.register(on: bridge)
OpenSaomaHandler.register(on: bridge)
StartLocationHandler.register(on: bridge)
// assetsRoot 闭包 = 子游戏 H5 根目录(msext gameController.m:364 等价)。
// 用 closure 是因为 effectiveGameDir 在 boot pipeline 升级路径下会变化,
// 必须运行时读最新值,否则升级后 srcIsloop 仍指旧目录。
LocalAudioHandler.register(
on: bridge,
assetsRoot: { [weak self] in
guard let self else {
return SandboxPaths.caches
}
return SandboxPaths.subGameIndex(self.effectiveGameDir, self.request.gameStart)
.deletingLastPathComponent()
}
)
// 子游戏 isSubGame=true:录音上传成功后额外触发 recordSuccess 反向 callback
// (对齐 daoqi gameController.m:2310;大厅 NewRootVC 无此 callHandler
RemoteAudioHandler.register(
on: bridge,
hostVC: { [weak self] in self },
isSubGame: true
)
AccreditLoginHandler.register(on: bridge)
FriendsShareHandler.register(on: bridge, mode: .subGame) // 子游戏路径:SharePanel 三选一 + 朋友圈兜底(对齐 msext gameController.m 活路径)
// OpenurlTitleData 在子游戏中也可能调(threeView 弹层 push 自子游戏页),保留 stub
OpenurlTitleDataHandler.register(on: bridge)
// 子游戏专属:backgameData(退出回大厅 + 反向 callback getWebdata
BackGameDataHandler.register(on: bridge)
// 视频房间 3 件套 stub(业务暂未启用,仅维持桥契约不让 H5 报 "no handler"
// 未来接 Agora 时把 VideoRoomHandlers 内 3 个 stub 展开实现,注册点不变。
VideoRoomHandlers.register(on: bridge)
}
// MARK: - 摇一摇支持
public override var canBecomeFirstResponder: Bool { true }
public override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
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: - WebView 全屏布局(与大厅同款,等价 daoqi msext gameController.m
// 全屏 WKWebView 路径)
private func setupBridgedWebView() {
bridgedWebView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(bridgedWebView)
NSLayoutConstraint.activate([
bridgedWebView.topAnchor.constraint(equalTo: view.topAnchor),
bridgedWebView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
bridgedWebView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
bridgedWebView.trailingAnchor.constraint(equalTo: view.trailingAnchor)
])
}
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 {
splash.isHidden = false
splash.update(text: "加载子游戏...", progress: nil)
do {
try await runBootPipelineSteps()
} catch {
showFatalAlert(error: error)
}
}
private func runBootPipelineSteps() async throws {
// 0. H5 传过来的 SwitchOverGameData.gamedownloadurl **字段名是 url 但语义是 gameid**
// msext gameController.m:544 注释明确写"游戏ID")。真实 zip 下载地址要按
// gameid 去启动期缓存的 RemoteConfig 里查 `game_zip` 字段;走 VersionResolver
// 单链 4 层 fallbackagent → game → channel → market)的同一套实现。
//
// 需要做两次 resolve
// - lobbyResolvedgameId = bc.gameId)→ 给 AppDataWriter 算 app_appversion 审核标志
// 与 msext NewRootVC 算 result 后 initinfo 传给 gameController 等价
// - subGameResolvedgameId = H5 传的 gameid)→ 拿 game_version 和 game_zip URL
// 与 msext gameController.uplevel: 等价
let (lobbyResolved, subGameResolved) = try await resolveBoot()
let subGameZipURL = subGameResolved.gameZip ?? ""
// 1. 准备子游戏 zipmsext gameController.m:1239-1255 等价):
// - 目录不存在(首装):下载到 currentDir
// - 目录存在 + 远端 > 本地:advance + 下载到新目录
// - 远端 ≤ 本地:复用 currentDir
splash.update(text: "准备子游戏...", progress: nil)
let outcome = try await SubGameDownloader.shared.ensureReady(
directoryKey: request.gameDir,
gameStart: request.gameStart,
remoteVersion: subGameResolved.gameVersion,
remoteZipURL: subGameZipURL,
onProgress: { [weak self] p in
Task { @MainActor in
self?.splash.update(
text: String(format: "下载子游戏 %d%%", Int(p * 100)),
progress: p
)
}
}
)
// ensureReady 内可能 advance 了目录名,同步到本地状态。
// 此后所有资源路径(loadFileURL / AppDataWriter / srcIsloop closure)都走它。
effectiveGameDir = outcome.actualDirectory
// 2. 写 4 个 app_*.jscontainerRole = .subGame)。BatteryMonitor / NetworkMonitor
// 单例已由大厅启动,此处仅复用其 currentXxx。
// resolvedVersion 必须传大厅视角的版本判定结果,让 app_appversion 审核标志
// 与大厅一致(msext result_state 传递路径等价)。
// containerRole.dir 传 effectiveGameDir,让 app_gamedir = msext gamefilepath
// (升级后的"xxx1"形式),与 msext app_data.js:2160 等价。
let writer = AppDataWriter(
bundleConfig: .shared,
resolvedVersion: lobbyResolved,
containerRole: .subGame(name: request.gameStart, dir: effectiveGameDir)
)
try writer.writeInitial()
try writer.writeBattery(BatteryMonitor.shared.currentLevel)
try writer.writeNetwork(NetworkMonitor.shared.currentCode)
// 3. 加载子游戏 H5allowingReadAccessTo 给 subGameRoot 才能跨目录引用资源)
splash.update(text: "进入子游戏...", progress: nil)
let indexURL = SandboxPaths.subGameIndex(effectiveGameDir, request.gameStart)
bridgedWebView.webView.loadFileURL(
indexURL,
allowingReadAccessTo: SandboxPaths.subGameRoot(effectiveGameDir)
)
}
/// 子游戏启动期两次 resolve
/// - lobby 视角(gameId = bc.gameId)给 app_appversion 审核标志(msext result_state 等价)
/// - sub-game 视角(gameId = H5 传的 gameid)给 game_version + game_zip URLmsext gameController.uplevel: 等价)
private func resolveBoot() async throws -> (lobbyResolved: ResolvedVersion, subGameResolved: ResolvedVersion) {
guard let cfg = await RemoteConfigClient.shared.current() else {
throw SubGameBootError.remoteConfigUnavailable
}
let bc = BundleConfig.shared
let lobbyResolved = VersionResolver.resolve(
config: cfg,
agentId: bc.agent,
channelId: bc.channel,
marketId: bc.market,
gameId: bc.gameId
)
let subGameResolved = VersionResolver.resolve(
config: cfg,
agentId: bc.agent,
channelId: bc.channel,
marketId: bc.market,
gameId: request.downloadURL // ← H5 字段名 url,实为 gameid
)
// 远端必须给出 game_zip URL,否则 SubGameDownloader 首装路径无法落地。
// 已有本地缓存且远端不需要升级时(≤本地版本),URL 空也允许 → 让 ensureReady 走复用分支。
let zip = subGameResolved.gameZip ?? ""
let localVer = LocalVersionReader.subGameVersion(
dir: SubGameDirectoryStore.current(forKey: request.gameDir),
start: request.gameStart
)
if zip.isEmpty && subGameResolved.gameVersion > localVer {
throw SubGameBootError.zipURLNotFound(gameId: request.downloadURL)
}
return (lobbyResolved, subGameResolved)
}
enum SubGameBootError: Error, CustomStringConvertible {
case remoteConfigUnavailable
case zipURLNotFound(gameId: String)
var description: String {
switch self {
case .remoteConfigUnavailable:
return "远端配置未就绪,请返回大厅重试"
case .zipURLNotFound(let id):
return "未找到子游戏 \(id) 的下载地址"
}
}
}
// MARK: - 外部订阅生命周期(参 §2.4.2)
//
// 子游戏栈顶时挂钩 battery / network / appservice。大厅 push 子游戏后大厅
// viewWillDisappear 已 teardown,避免双发。子游戏 pop 时 teardown,大厅
// viewWillAppear 重新 setup。
//
// 与大厅一致:业务期变化不重写文件,evaluateJavaScript 重新赋值 window.app_*。
private func setupExternalSubscriptions() {
NetworkMonitor.shared.start()
BatteryMonitor.shared.start()
AppLifecycleObserver.shared.start()
// 仅子游戏挂电话状态监听(大厅不订阅,与 msext gameController 同款边界)
PhoneStateMonitor.shared.start()
let bridge = bridgedWebView.bridge
let webView = bridgedWebView.webView
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)
}
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 — 值错位沿用历史 msext WKWebView 路径:
// 真·进入后台 → "2",真·回到前台 → "1"。参 WebContainerViewController 同位置注释。
AppLifecycleObserver.shared.onBackground = {
bridge.call("appservice", data: .string("2"), callback: nil)
}
AppLifecycleObserver.shared.onForeground = {
bridge.call("appservice", data: .string("1"), callback: nil)
}
// 电话状态 — 契约 §3.1 14]:来电 "2" / 挂断 "0"
PhoneStateMonitor.shared.onChange = { state in
bridge.call("phonestate", data: .string(state), callback: nil)
}
// overlayPhase 7pop 回子游戏时反向 callback getWebdata。**lifecycle-tied**
// 子游戏栈顶时挂钩,overlay push / 子游戏自身 pop 时由 viewWillDisappear 解绑。
// 这一行为与大厅 observer 的"永久挂钩"配合,复现 msext 双观察者行为:
// - 子游戏自身 backgameData pop 回大厅:sub-game observer 在 viewWillDisappear
// 已解绑 → 仅大厅 observer 触发 → 大厅 H5 收到 getWebdatamsext 单发等价)
// - 子游戏内 overlay backgameData popsub-game.viewWillAppear 时 observer 重挂
// → 大厅 + sub-game 双触发 → 大厅 H5 + sub-game H5 双发收到(msext 双观察者等价)
subGameReturnObserver = NotificationCenter.default.addObserver(
forName: .subGameDidReturn,
object: nil,
queue: .main
) { note in
let data = (note.userInfo?["data"] as? String) ?? ""
MainActor.assumeIsolated {
bridge.call("getWebdata", data: .string(data), callback: nil)
}
}
}
private func teardownExternalSubscriptions() {
BatteryMonitor.shared.onChange = nil
NetworkMonitor.shared.onChange = nil
AppLifecycleObserver.shared.onBackground = nil
AppLifecycleObserver.shared.onForeground = nil
PhoneStateMonitor.shared.onChange = nil
PhoneStateMonitor.shared.stop()
if let token = subGameReturnObserver {
NotificationCenter.default.removeObserver(token)
subGameReturnObserver = nil
}
}
// MARK: - Error alert
private func showFatalAlert(error: Error) {
let alert = UIAlertController(
title: "加载失败",
message: "\(error)",
preferredStyle: .alert
)
alert.addAction(UIAlertAction(title: "返回大厅", style: .default) { [weak self] _ in
self?.navigationController?.popViewController(animated: true)
})
present(alert, animated: true)
}
}
// MARK: - WKNavigationDelegate
extension SubGameViewController: WKNavigationDelegate {
public func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
UIView.animate(withDuration: 0.3, animations: { [weak self] in
self?.splash.alpha = 0
}, completion: { [weak self] _ in
self?.splash.removeFromSuperview()
})
}
}