Files
youle_app_ios_v2/ylgamehall/Source/WebView/SubGameViewController.swift
T
joywayer f2b456cf00 3.E:prepareaudio 完整实现 + 大厅/子游戏分流接入
替换 RemoteAudioHandler 中 prepareaudio 的 stub 为完整链路:权限检查 →
录音浮层 → 转码 → 七牛上传 → 反向 callback getaudiourl + 子游戏额外
recordSuccess。mediaTypeAudio 仍为 stub(Phase 3.F 处理)。

## 完整链路

1. H5 调 prepareaudio
2. `AVAudioSession.requestRecordPermission`
   - 拒绝:弹 Alert("需要访问您的麦克风..."),**不触发 cb**
     (对齐 daoqi NewRootVC.m:314-319)
3. 同意 → `RecordingPresenter.start(on: hostVC, fileName:)`
   - 启动失败:log + return,**不触发 cb**
   - 启动成功:立即触发 `cb("Response from prepareaudio")`,对齐 daoqi
     NewRootVC.m:322 `[recorderVC beginRecordByFileName:..]` 之后 cb 行为
4. 用户松手 / 60s 自动停 → `processCompletedRecording` 后台 Task
   - 用 AVAudioPlayer 读 wav `duration` → `round` 取整秒(对齐 daoqi
     NewRootVC.m:1891-1893)
   - 生成 amr 文件名:
     - 大厅 `yyyyMMddHHmmss%08X.amr`(无下划线,NewRootVC.m:1908)
     - 子游戏 `yyyyMMddHHmmss_%08X.amr`(有下划线,gameController.m:2269)
   - `AMRCodec.wavToAmr`
   - `QiniuUploader.upload`(key = 本地文件名,对齐 daoqi 上传命名)
5. 反向 callback(MainActor):
   - `getaudiourl({audiourl: String, time: String})`
     ⚠️ time 是字符串(%ld 整秒),不是 number — 对齐 daoqi
     NewRootVC.m:1923 / gameController.m:2305
   - **仅子游戏**额外 `recordSuccess({fileUrl, fileName, fileKey})`
     对齐 daoqi gameController.m:2310;大厅 NewRootVC 无此 callHandler

## register 签名扩展

新增参数:
- `hostVC: @escaping @MainActor @Sendable () -> UIViewController?`
  —— 每次录音重新求值,避免捕获过时引用
- `isSubGame: Bool` —— 决定是否触发 recordSuccess

WebContainerViewController(大厅)传 `isSubGame: false`;
SubGameViewController(子游戏)传 `isSubGame: true`。

## QiniuUploader 改动

key 生成方式:UUID → 本地文件名(lastPathComponent)。让调用方完全控制
key 命名,对齐 daoqi 录音文件命名规则。H5 通过 audiourl 转发给对端,对端
按文件名规律假设,必须严格对齐。

## 错误处理

启动失败 / 转码失败 / 上传失败:log + return,不触发反向 callback。
对齐 daoqi 同款 silent return 行为(mediaTypeAudio 下载失败、转码失败
也是同样 silent return 模式)。

## 验证

BuildProject 通过 0 错误。
2026-06-24 01:36:25 +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)
// 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()
})
}
}