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:
co-authored by
Claude Opus 4.7
parent
68cf7140d7
commit
7a5644053d
@@ -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_*.js;H5 未安装则抛 `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 个视频房间 stub(commit C 接入 backgameData)
|
||||||
- [ ] **6.4** `SwitchOverGameData` handler(仅大厅注册):解参 → AppCoordinator.showSubGame → 节流 2s
|
- [x] **6.4** `SwitchOverGameData` handler(大厅 + 子游戏共用):解参 `Gamedirectory` / `gamedownloadurl` / `data` → `AppCoordinator.shared.showSubGame(...)`;节流 / 栈深守门移到 Coordinator,handler 始终回 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 SubGameHandlers(backgameData 留待 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
|
||||||
|
|||||||
@@ -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 弹层。
|
||||||
|
// 栈深约束 ≤ 3(Design §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 stub:SwitchOverGameData — 切换到子游戏
|
// H5 → Native handler:SwitchOverGameData — 切换到子游戏。
|
||||||
// 契约: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" 透传给子游戏 H5(commit C 通过 .subGameDidReturn 反向 callback getWebdata)
|
||||||
//
|
//
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
@@ -18,8 +25,23 @@ public enum SwitchOverGameHandler {
|
|||||||
// 【17】SwitchOverGameData
|
// 【17】SwitchOverGameData
|
||||||
// 入参 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 死循环 push(msext 同款 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 // 子游戏入口(通常等于 gameDir,msext 行为)
|
||||||
|
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_*.js(containerRole = .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_*.js(containerRole = .subGame)。BatteryMonitor / 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. 加载子游戏 H5(allowingReadAccessTo 给 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user