Files
youle_app_ios_v2/ylgamehall/Source/WebView/SubGameViewController.swift
T
joywayerandClaude Opus 4.7 1247c14351 splash 启动图改 scaleAspectFill 全屏覆盖(与 WebView 同款撤销黑边)
接上一 commit f4307cc(WebView 全屏铺满):splash 启动图也应保持一致策略,
否则启动瞬间(iOS 系统 LaunchScreen)和过渡期(SplashOverlay)仍会显示
左右大黑边,与 WebView 全屏的视觉不连续。

3 处 contentMode 从 scaleAspectFit 改为 scaleAspectFill:
- LaunchScreen.storyboard(iOS 启动瞬间)
- WebContainerViewController 内 SplashOverlay.imageView
- SubGameViewController 内 SplashOverlay.imageView

scaleAspectFill 行为:素材 1136×640(16:9)在现代 iPhone 横屏(19.5:9)
上高度撑满,宽度按比例放大→左右轻微裁切。启动图左右是空白底色,logo
+ 文字主体居中可见,裁切无影响。

等价 daoqi msext LaunchImage Asset Catalog 时代「启动图覆盖整屏」体感。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-23 07:26:15 +08:00

363 lines
14 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: - 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: - 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 {
// 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_*.jscontainerRole = .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. 加载子游戏 H5allowingReadAccessTo 给 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)
}
// overlayPhase 7pop 回子游戏时反向 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
// scaleAspectFill:与 LaunchScreen / WebContainer splash 同款全屏覆盖策略
// (现代 iPhone 横屏 19.5:9 比 16:9 素材更宽,scaleAspectFit 会左右补大黑边)
imageView.contentMode = .scaleAspectFill
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)
}
}
}