Files
youle_app_ios_v2/ylgamehall/Source/WebView/SubGameViewController.swift
T
joywayer 5d4bdc2e29 4.B 分享按页面 mode 拆分:大厅直发微信 3 分支 / 子游戏 SharePanel
【契约影响】docs/H5-Native-Contract.md §3.1[2]

原 msext 把 friendsSharetypeUrlToptitleDescript 同名 handler 在两个 VC
各注册一份且**行为完全不同**:

- NewRootVC.m(大厅):直发微信,3 type 分支
    type==1 sendLinkURL(sharelogo 缩略图)
    type==2 sendImageData(截图 40% 缩放为 thumb)
    其他    NSData fromURL → sendImageData(公众号二维码场景)
- gameController.m(子游戏,活路径,早 return):
    sharefriend==1 SharePanel 三选一
    其他           WechatShareManager 朋友圈兜底(2 分支:截图/链接)

先前新外壳两个 VC 都注册同一份 handler 走"子游戏"逻辑,导致大厅页:
1. 强行弹 SharePanel 多一步操作
2. 丢失"远端图"分支(公众号二维码被错当链接发)

按页面 mode 拆开:

- FriendsShareHandler 加 Mode { lobby, subGame } 参数
- ShareCenter 拆 dispatchLobby(直发微信)/ dispatchSubGame(SharePanel)
- WechatShare 加 shareLobby() 实现完整 3 type 分支:
    type==1 → shareLink + mediaTagName "游戏下载链接" + appIcon thumb
              (原 msext 用 sharelogo.png,按用户决定 fallback 到 app icon)
    type==2 → shareImage + mediaTagName "游戏截图分享" + 截图 0.4x thumb
              + messageExt = H5 description
    其他    → URLSession 下载 NSData 直发 + 远端图 0.4x thumb
              + mediaTagName/messageExt 固定字面"公众号二维码分享"
- WebContainerViewController 注册 .lobby;SubGameViewController .subGame

附带修 sharefriend 默认值:之前 ?? "1"(弹面板)与原 msext nil→[intValue]
==0→朋友圈微信不一致。改成 ?? "",由 ShareCenter default 分支兜底走
朋友圈微信,与原项目完全等价。
2026-06-24 08:19:21 +08:00

411 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` 字段;msext 走 uplevel:
// chulishengji 算法,新外壳直接复用 VersionResolver 同一套实现。
//
// 需要做两次 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)
}
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。**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()
})
}
}