Phase 6 commit A:AppCoordinator + SubGameViewController 框架 + SwitchOverGameData push

- AppCoordinator:2s 节流 + 栈深 < 2 守门 + .subGameDidReturn 通知名(Design §2.4.3)
- SubGameViewController:复用 BridgedWebView/Splash/16:9 letterbox/ExternalSubscriptions,
  AppDataWriter(.subGame) 写 4 个 app_*.js;H5 未安装时抛错(待 commit B 接入下载)
- SwitchOverGameHandler:解 Gamedirectory/gamedownloadurl/data → AppCoordinator.showSubGame
  (msext NewRootVC.m:541-566 等价,节流与栈深由 Coordinator 守门,cb 字面始终回)
- SceneDelegate:UINavigationController 承载大厅,nav 引用交给 AppCoordinator
- Plan 进度已勾选 §5 / §8(commit B/C 留待 SubGameDownloader 与 backgameData/getWebdata 链路)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
joywayer
2026-06-22 20:59:00 +08:00
co-authored by Claude Opus 4.7
parent 68cf7140d7
commit 7a5644053d
5 changed files with 471 additions and 15 deletions
+9 -8
View File
@@ -587,10 +587,11 @@ Contract Design Plan(本文档)
#### 任务清单 #### 任务清单
- [ ] **6.1** `Source/Coordinator/AppCoordinator.swift`path 节流 + 栈深约束Design §2.4.3 - [x] **6.1** `Source/Coordinator/AppCoordinator.swift`2s 节流(`lastSwitchAt`+ 栈深约束(`viewControllers.count < 2`+ `.subGameDidReturn` 通知名Design §2.4.3
- [ ] **6.2** `Source/Containers/SubGameViewController.swift`继承 WebContainer,注入 SubGameHandlers - [x] **6.2** `Source/WebView/SubGameViewController.swift`与大厅同款 BridgedWebView + Splash + 16:9 letterbox + ExternalSubscriptions`AppDataWriter(containerRole: .subGame)` 写 4 个 app_*.jsH5 未安装则抛 `SubGameBootError.subGameNotInstalled`(待 6.7 接入下载)
- [ ] **6.3** `Source/Bridge/Handlers/SubGameHandlers.swift`19 共享 + `backgameData` + 3 个视频房间 stub - [ ] **6.3** `Source/Bridge/Handlers/SubGameHandlers.swift`19 共享 + `backgameData` + 3 个视频房间 stubcommit C 接入 backgameData
- [ ] **6.4** `SwitchOverGameData` handler大厅注册):解参 → AppCoordinator.showSubGame → 节流 2s - [x] **6.4** `SwitchOverGameData` handler(大厅 + 子游戏共用):解参 `Gamedirectory` / `gamedownloadurl` / `data``AppCoordinator.shared.showSubGame(...)`;节流 / 栈深守门移到 Coordinatorhandler 始终回 cb `"SwitchOverGameData"`msext NewRootVC.m:541-566 等价)
- [x] **SceneDelegate**:以 `UINavigationController` 承载大厅,`AppCoordinator.shared.navigationController` 持引;导航栏隐藏 + 禁用边缘 pop 手势
- [ ] **6.5** `backgameData` handler(仅子游戏注册):停 audio → 发 `.subGameDidReturn` 通知 → coordinator.popSubGame - [ ] **6.5** `backgameData` handler(仅子游戏注册):停 audio → 发 `.subGameDidReturn` 通知 → coordinator.popSubGame
- [ ] **6.6** 大厅监听 `.subGameDidReturn``bridge.call("getWebdata", data)` - [ ] **6.6** 大厅监听 `.subGameDidReturn``bridge.call("getWebdata", data)`
- [ ] **6.7** 子游戏 zip 下载 / 解压(H5 端通过 SwitchOverGameData 传 `gamedownloadurl` - [ ] **6.7** 子游戏 zip 下载 / 解压(H5 端通过 SwitchOverGameData 传 `gamedownloadurl`
@@ -918,10 +919,10 @@ H5 调 `OpenurlTitleData` 打开弹层 WebView,弹层内 H5 用 `window.settin
- [ ] 5.6 失败回包 errorCode 12 - [ ] 5.6 失败回包 errorCode 12
### Phase 6 子游戏 ### Phase 6 子游戏
- [ ] 6.1 AppCoordinator 栈深节流 - [x] 6.1 AppCoordinator 栈深节流2s + viewControllers.count < 2
- [ ] 6.2 SubGameViewController - [x] 6.2 SubGameViewController 框架(commit A:H5 未安装则抛错,待 6.7 接入)
- [ ] 6.3 SubGameHandlers - [ ] 6.3 SubGameHandlersbackgameData 留待 commit C
- [ ] 6.4 SwitchOverGameData - [x] 6.4 SwitchOverGameData → AppCoordinator.showSubGame
- [ ] 6.5 backgameData - [ ] 6.5 backgameData
- [ ] 6.6 getWebdata 通知链 - [ ] 6.6 getWebdata 通知链
- [ ] 6.7 SubGameDownloader - [ ] 6.7 SubGameDownloader
+11 -1
View File
@@ -14,7 +14,17 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate {
options connectionOptions: UIScene.ConnectionOptions) { options connectionOptions: UIScene.ConnectionOptions) {
guard let windowScene = scene as? UIWindowScene else { return } guard let windowScene = scene as? UIWindowScene else { return }
let window = UIWindow(windowScene: windowScene) let window = UIWindow(windowScene: windowScene)
window.rootViewController = WebContainerViewController()
// UINavigationController 便 Phase 6 push / Phase 7 push
// 3Design §2.4.3 AppCoordinator push
let lobby = WebContainerViewController()
let nav = UINavigationController(rootViewController: lobby)
nav.setNavigationBarHidden(true, animated: false)
nav.interactivePopGestureRecognizer?.isEnabled = false
AppCoordinator.shared.navigationController = nav
window.rootViewController = nav
self.window = window self.window = window
window.makeKeyAndVisible() window.makeKeyAndVisible()
} }
@@ -2,12 +2,19 @@
// SwitchOverGameHandler.swift // SwitchOverGameHandler.swift
// ylgamehall // ylgamehall
// //
// H5 Native handler stubSwitchOverGameData // H5 Native handlerSwitchOverGameData
// docs/H5-Native-Contract.md §3.1 17 // docs/H5-Native-Contract.md §3.1 17
// //
// Phase 6.4 AppCoordinator + SubGameViewController + // msext NewRootVC.m:541-566
// SubGameDownloader zip stub cb // - Gamedirectory / gamedownloadurl / data
// H5 "no handler" // - 2s msext one_Time / canbackone AppCoordinator.showSubGame
// - AppCoordinator.shared.showSubGame(...) push VC
// - push callback "SwitchOverGameData"msext
//
//
// - "Gamedirectory" G
// - "gamedownloadurl"
// - "data" H5commit C .subGameDidReturn callback getWebdata
// //
import Foundation import Foundation
@@ -18,8 +25,23 @@ public enum SwitchOverGameHandler {
// 17SwitchOverGameData // 17SwitchOverGameData
// type / Gamedirectory / gamedownloadurl / data // type / Gamedirectory / gamedownloadurl / data
// cb: "SwitchOverGameData"沿 msext // cb: "SwitchOverGameData"沿 msext
// push SubGameViewController + zip + H5 bridge.register("SwitchOverGameData") { data, callback in
bridge.register("SwitchOverGameData") { _, callback in await MainActor.run {
let dir = data?["Gamedirectory"]?.asString ?? ""
let url = data?["gamedownloadurl"]?.asString ?? ""
let web = data?["data"]?.asString ?? ""
// msext gameStart 沿 Gamedirectory
// gamestart
let request = SubGameRequest(
gameDir: dir,
gameStart: dir,
downloadURL: url,
webData: web
)
AppCoordinator.shared.showSubGame(request)
}
// msext / cb H5
callback?(.string("SwitchOverGameData")) callback?(.string("SwitchOverGameData"))
} }
} }
@@ -0,0 +1,83 @@
//
// AppCoordinator.swift
// ylgamehall
//
// Lobby SubGame Overlay
//
// Design §2.4.3 3
// - + + threeView 3
// - H5 SwitchOverGameData pushmsext first_Time
//
// msext
// - SwitchOverGameData 2s
// - subGameDidReturn pop callback getWebdata
//
import UIKit
/// push H5 SwitchOverGameData
public struct SubGameRequest: Sendable {
public let gameDir: String // Gamedirectory Caches
public let gameStart: String // gameDirmsext
public let downloadURL: String // gamedownloadurl zip
public let webData: String // H5
public init(gameDir: String, gameStart: String, downloadURL: String, webData: String) {
self.gameDir = gameDir
self.gameStart = gameStart
self.downloadURL = downloadURL
self.webData = webData
}
}
/// pop msext .subGameDidReturn
public extension Notification.Name {
/// pop userInfo["data"] = backgameData
static let subGameDidReturn = Notification.Name("ylgamehall.subGameDidReturn")
}
@MainActor
public final class AppCoordinator {
public static let shared = AppCoordinator()
/// 2 SwitchOverGameData msext first_Time
private var lastSwitchAt: Date?
/// WebContainer push
public weak var navigationController: UINavigationController?
public init() {}
/// SwitchOverGameData 2
/// true push false
@discardableResult
public func showSubGame(_ request: SubGameRequest) -> Bool {
// 2s
if let last = lastSwitchAt, Date().timeIntervalSince(last) < 2 {
return false
}
// + = 2
guard let nav = navigationController,
nav.viewControllers.count < 2
else {
return false
}
lastSwitchAt = Date()
let subGame = SubGameViewController(request: request)
nav.pushViewController(subGame, animated: true)
return true
}
/// pop data H5 backgameData
/// WebContainer `getWebdata` callback
public func popSubGame(returningData data: String) {
navigationController?.popViewController(animated: true)
NotificationCenter.default.post(
name: .subGameDidReturn,
object: nil,
userInfo: ["data": data]
)
}
}
@@ -0,0 +1,340 @@
//
// SubGameViewController.swift
// ylgamehall
//
// H5 BridgedWebView + Splash + 16:9 letterbox + 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: - UI
private let bridgedWebView = BridgedWebView()
private let splash = SplashOverlay()
// MARK: - Handlers
private let shakeHandler = ShakeHandler()
// MARK: - Init
public init(request: SubGameRequest) {
self.request = request
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)
LocalAudioHandler.register(on: bridge)
RemoteAudioHandler.register(on: bridge)
AccreditLoginHandler.register(on: bridge)
FriendsShareHandler.register(on: bridge)
// OpenurlTitleData threeView push stub
OpenurlTitleDataHandler.register(on: bridge)
// backgameData / exitRoom / getVideoinfo / createRoom handler
// commit C / Phase
}
// 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: - 16:9 letterbox LetterboxLayout
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 {
splash.isHidden = false
splash.update(text: "加载子游戏...", progress: nil)
do {
try await runBootPipelineSteps()
} catch {
showFatalAlert(error: error)
}
}
private func runBootPipelineSteps() async throws {
// 1. zip commit B SubGameDownloader
// commit A FileManager commit B
let indexURL = SandboxPaths.subGameIndex(request.gameDir, request.gameStart)
guard FileManager.default.fileExists(atPath: indexURL.path) else {
throw SubGameBootError.subGameNotInstalled(
dir: request.gameDir,
downloadURL: request.downloadURL
)
}
// 2. 4 app_*.jscontainerRole = .subGameBatteryMonitor / NetworkMonitor
// currentXxx
let writer = AppDataWriter(
bundleConfig: .shared,
resolvedVersion: nil,
containerRole: .subGame(name: request.gameStart, dir: request.gameDir)
)
try writer.writeInitial()
try writer.writeBattery(BatteryMonitor.shared.currentLevel)
try writer.writeNetwork(NetworkMonitor.shared.currentCode)
// 3. H5allowingReadAccessTo subGameRoot
splash.update(text: "进入子游戏...", progress: nil)
bridgedWebView.webView.loadFileURL(
indexURL,
allowingReadAccessTo: SandboxPaths.subGameRoot(request.gameDir)
)
}
// 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()
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)
}
AppLifecycleObserver.shared.onBackground = {
bridge.call("appservice", data: .string("1"), callback: nil)
}
AppLifecycleObserver.shared.onForeground = {
bridge.call("appservice", data: .string("2"), callback: nil)
}
}
private func teardownExternalSubscriptions() {
BatteryMonitor.shared.onChange = nil
NetworkMonitor.shared.onChange = nil
AppLifecycleObserver.shared.onBackground = nil
AppLifecycleObserver.shared.onForeground = nil
}
// MARK: - Errors
private enum SubGameBootError: LocalizedError {
case subGameNotInstalled(dir: String, downloadURL: String)
var errorDescription: String? {
switch self {
case .subGameNotInstalled(let dir, _):
return "子游戏未安装(\(dir)),下载流程待 Phase 6.B 接入"
}
}
}
private func showFatalAlert(error: Error) {
let alert = UIAlertController(
title: "加载失败",
message: "\(error.localizedDescription)",
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()
})
}
}
// MARK: - SplashOverlay WebContainerViewController
// Phase 1.B Source/UI/SplashOverlay.swift
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
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)
}
}
}