契约 §3.1 [14]phonestate 落地。msext 用已废弃的 CTCallCenter(iOS 10+
deprecated);新外壳改用 CallKit CXCallObserver,对 H5 输出字符串一致:
- "2" = 来电响铃中(hasConnected=false / isOutgoing=false / hasEnded=false)
或已连接(hasConnected=true)
- "0" = 挂断(hasEnded=true)
- 其它(拨出未连接、保持)不发
新增:
- Source/Resource/PhoneStateMonitor.swift(@MainActor singleton,与 BatteryMonitor /
NetworkMonitor 同款 pattern;onChange closure + start/stop 生命周期)
接入:
- SubGameViewController.setupExternalSubscriptions:start + onChange
- SubGameViewController.teardownExternalSubscriptions:onChange=nil + stop
- 大厅 WebContainerViewController 不订阅(msext gameController 同款边界,
契约表注「已注释」表示大厅不发;H5 子游戏才依赖此事件做哑麦/暂停)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
370 lines
14 KiB
Swift
370 lines
14 KiB
Swift
//
|
||
// 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: - .subGameDidReturn 观察者(overlay pop 回子游戏时反向 callback getWebdata)
|
||
|
||
private var subGameReturnObserver: NSObjectProtocol?
|
||
|
||
// 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(退出回大厅 + 反向 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: - 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 已下载解压(缓存命中直接复用,未命中下载 + 解压 + 原子 rename)
|
||
splash.update(text: "准备子游戏...", progress: nil)
|
||
_ = try await SubGameDownloader.shared.ensureReady(
|
||
request: request,
|
||
onProgress: { [weak self] p in
|
||
Task { @MainActor in
|
||
self?.splash.update(
|
||
text: String(format: "下载子游戏 %d%%", Int(p * 100)),
|
||
progress: p
|
||
)
|
||
}
|
||
}
|
||
)
|
||
|
||
// 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)
|
||
let indexURL = SandboxPaths.subGameIndex(request.gameDir, request.gameStart)
|
||
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()
|
||
// 仅子游戏挂电话状态监听(大厅不订阅,与 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)
|
||
}
|
||
|
||
AppLifecycleObserver.shared.onBackground = {
|
||
bridge.call("appservice", data: .string("1"), callback: nil)
|
||
}
|
||
AppLifecycleObserver.shared.onForeground = {
|
||
bridge.call("appservice", data: .string("2"), callback: nil)
|
||
}
|
||
|
||
// 电话状态 — 契约 §3.1 [14]:来电 "2" / 挂断 "0"
|
||
PhoneStateMonitor.shared.onChange = { state in
|
||
bridge.call("phonestate", data: .string(state), callback: nil)
|
||
}
|
||
|
||
// overlay(Phase 7)pop 回子游戏时反向 callback getWebdata。挂在子游戏栈顶
|
||
// 期间生效;overlay push 上来时 viewWillDisappear → teardown 自动解绑,避免双发。
|
||
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()
|
||
})
|
||
}
|
||
}
|
||
|
||
// 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)
|
||
}
|
||
}
|
||
}
|