跟错误态"重试"主按钮 systemBlue 视觉一致;轨道用 .label 系统语义色 的 15% alpha,浅深模式自适应(浅模式淡灰、深模式淡白),在浅色启动图 上比默认 UIProgressView 灰条更显眼。 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
674 lines
30 KiB
Swift
674 lines
30 KiB
Swift
//
|
||
// WebContainerViewController.swift
|
||
// ylgamehall
|
||
//
|
||
// H5 大厅承载容器:BridgedWebView 嵌入 + 16:9 letterbox + Splash 覆盖层
|
||
// + 启动流水线(ensureReady → fetch → resolve → upgrade → loadFileURL)。
|
||
// 详见 docs/H5-Native-Implementation-Design.md §6.3.5。
|
||
//
|
||
|
||
import UIKit
|
||
import WebKit
|
||
import Network
|
||
|
||
public final class WebContainerViewController: UIViewController {
|
||
|
||
// MARK: - UI
|
||
|
||
private let bridgedWebView = BridgedWebView()
|
||
private let splash = SplashOverlay()
|
||
|
||
// MARK: - Handlers (有状态的 handler 注册器持有,无状态的走 enum 静态 register)
|
||
|
||
private let shakeHandler = ShakeHandler()
|
||
|
||
// MARK: - .subGameDidReturn 观察者(子游戏 backgameData pop 后回大厅,反向 callback getWebdata)
|
||
|
||
private var subGameReturnObserver: NSObjectProtocol?
|
||
|
||
// MARK: - Boot pipeline 内部错误分类
|
||
//
|
||
// 用 enum 区分"终态阻塞"与"系统级错误":
|
||
// - shortText / ipaUpgrade 是运营杀手锏,弹窗后永停(无重试按钮)
|
||
// - 其它(网络 / 解析 / 解压 / 下载失败)→ showFatalAlert,提供重试
|
||
|
||
private enum BootError: Error {
|
||
/// 短文本响应 或 showmessage 非空:契约 §3.2 运营杀手锏 #1/#2
|
||
case operationalMessage(String)
|
||
/// IPA 需要升级,弹窗 + Safari 外链
|
||
case ipaUpgradeRequired(downloadURL: String)
|
||
}
|
||
|
||
// MARK: - Lifecycle
|
||
|
||
public override func viewDidLoad() {
|
||
super.viewDidLoad()
|
||
view.backgroundColor = .black
|
||
|
||
setupBridgedWebView()
|
||
setupSplash()
|
||
|
||
bridgedWebView.webView.navigationDelegate = self
|
||
registerBridgeHandlers()
|
||
|
||
Task { @MainActor in
|
||
await runBootPipeline()
|
||
}
|
||
}
|
||
|
||
/// 注册所有 H5 → Native handler 的单一聚合点。
|
||
/// Phase 1.15 仅 vibrator;Phase 2.A 扩展到 8 个 handler 模块。
|
||
private func registerBridgeHandlers() {
|
||
let bridge = bridgedWebView.bridge
|
||
|
||
// Phase 1.15
|
||
VibratorHandler.register(on: bridge) // §3.1 [10][11][12]扩展
|
||
|
||
// Phase 2.A 大厅简单 handler(无外部依赖)
|
||
ClipboardHandler.register(on: bridge) // §3.1 [13][14]
|
||
shakeHandler.register(on: bridge) // §3.1 [7][8][9] + §3.2 [5]
|
||
VoicePlayingHandler.register(on: bridge) // §3.1 [6]
|
||
DeviceInfoHandler.register(on: bridge) // §3.1 [21] + §3.2 [1]
|
||
BrowserHandler.register(on: bridge) // §3.1 [16]
|
||
OpenSaomaHandler.register(on: bridge) // §3.1 [22]空 stub
|
||
StartLocationHandler.register(on: bridge) // §3.1 [20]Phase 5 完整实现,Phase 2 stub
|
||
|
||
// Phase 3.A 本地音频 + 3.C/3.D stub
|
||
LocalAudioHandler.register(on: bridge) // §3.1 [3]srcIsloop 完整实现
|
||
RemoteAudioHandler.register(on: bridge) // §3.1 [4][5]Phase 3.B/3.C/3.D stub
|
||
|
||
// Phase 4.A 登录 + 分享 stub(Phase 4.B 微信 SDK / Phase 4.C QQ URL Scheme 后升级)
|
||
AccreditLoginHandler.register(on: bridge) // §3.1 [1]Phase 4.E 完整微信 SDK
|
||
FriendsShareHandler.register(on: bridge) // §3.1 [2]Phase 4.B 完整框架(QQ/抖音 已 4.C/4.D 落地)
|
||
|
||
// Phase 6.4 / 7 子游戏 + 弹层入口 stub(让 H5 业务期调时不报 "no handler")
|
||
SwitchOverGameHandler.register(on: bridge) // §3.1 [17]Phase 6 完整实现
|
||
OpenurlTitleDataHandler.register(on: bridge) // §3.1 [15]Phase 7 完整实现
|
||
}
|
||
|
||
// MARK: - 摇一摇支持
|
||
// motionEnded 必须 first responder 才触发;canBecomeFirstResponder = true +
|
||
// viewDidAppear becomeFirstResponder。详见 Design §8.7.1。
|
||
|
||
public override var canBecomeFirstResponder: Bool { true }
|
||
|
||
public override func viewWillAppear(_ animated: Bool) {
|
||
super.viewWillAppear(animated)
|
||
// 外部订阅生命周期:栈顶时挂钩,避免子游戏 push 上来后双发(§2.4.2)
|
||
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 布局
|
||
//
|
||
// H5 设计分辨率 1280×720(16:9)。设备比例与 16:9 不匹配时用黑边补齐:
|
||
// - 比 16:9 宽(如现代 iPhone 横屏 2.16:1)→ 左右黑边
|
||
// - 比 16:9 窄(如 iPad 4:3)→ 上下黑边
|
||
//
|
||
// 约束策略:
|
||
// required:aspect = 16:9,width ≤ view.width,height ≤ view.height
|
||
// low: width = view.width,height = view.height(尽量撑满)
|
||
// 居中: centerX / centerY
|
||
// Auto Layout 会自动选择"短边贴边、长边居中"的解。
|
||
|
||
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 {
|
||
// 重新进入 pipeline 时(重试路径):清除错误态 + 重置 splash
|
||
stopBootRetryWaiting()
|
||
splash.hideError()
|
||
splash.isHidden = false
|
||
splash.update(text: "拼命启动中...", progress: nil)
|
||
|
||
do {
|
||
try await runBootPipelineSteps()
|
||
} catch BootError.operationalMessage(let msg) {
|
||
// 运营杀手锏:modal alert(业务流程信号,不是技术错误)
|
||
showBlockingAlert(message: msg)
|
||
} catch BootError.ipaUpgradeRequired(let dl) {
|
||
// IPA 升级 modal alert(业务流程信号)
|
||
showIPAUpgradeAlert(downloadURL: dl)
|
||
} catch {
|
||
// 网络 / 本地资源 / 解析等可重试错误 → 在 splash 上展示错误态
|
||
// (不弹 modal、不暴露原始 NSError),并启动 NWPathMonitor 静默自动重试
|
||
presentBootError(error)
|
||
}
|
||
}
|
||
|
||
/// 在 splash 上展示错误态(不弹 modal alert)。同时启动 NWPathMonitor 静默监听网络
|
||
/// 恢复事件,自动触发一次重试。raw error 仍 print 到 Xcode console 便于开发期排查。
|
||
///
|
||
/// 用户点"重试"会直接重跑 runBootPipeline → RemoteConfigClient 重新发起 URLSession
|
||
/// 请求。**这正是 Apple 推荐的「让网络请求自然触发权限弹窗」模式**:
|
||
/// - 首次启动若系统网络权限弹窗未响应/被拒,重新发请求会再次触发系统对权限状态
|
||
/// 的评估,可能再次弹窗(视 iOS 行为,多数情况下未响应的弹窗会复弹)
|
||
/// - 用户在 iOS 隐私管理里改了权限后回 app,点重试就能成功
|
||
/// - NWPathMonitor 也在静默监听,无需用户点重试,切到正常网络后自动重试
|
||
private func presentBootError(_ error: any Error) {
|
||
let kind = Self.classifyBootError(error)
|
||
print("[Boot] presentBootError kind=\(kind) underlying:", error)
|
||
|
||
let title: String
|
||
let message: String
|
||
switch kind {
|
||
case .networkOffline:
|
||
title = "当前未联网"
|
||
message = "请检查 Wi-Fi 或蜂窝数据后重试。"
|
||
case .networkTimeout:
|
||
title = "网络较慢"
|
||
message = "连接超时,请稍后重试。"
|
||
case .networkCannotReach:
|
||
title = "暂时无法连接服务器"
|
||
message = "网络似乎不太通畅,请稍后重试。"
|
||
case .localResourceMissing:
|
||
title = "资源加载失败"
|
||
message = "请尝试重启 app;若仍未恢复,请重新安装。"
|
||
case .unknown:
|
||
title = "启动遇到问题"
|
||
message = "请稍后重试。"
|
||
}
|
||
|
||
splash.onRetry = { [weak self] in
|
||
Task { @MainActor in await self?.runBootPipeline() }
|
||
}
|
||
splash.showError(title: title, message: message)
|
||
|
||
// 静默后台监听网络恢复,从无网 → 有网时自动重试一次
|
||
// (行业主流体验:用户切到 Wi-Fi 后无需手动操作即自动进入大厅)
|
||
startBootRetryWaiting()
|
||
}
|
||
|
||
/// 启动期 NWPathMonitor。仅在 splash 错误态期间存活;进入大厅或用户主动重试时销毁。
|
||
/// 仅在"从无网变有网"时触发自动重试,避免初始 satisfied 状态导致死循环。
|
||
private var bootRetryPathMonitor: NWPathMonitor?
|
||
|
||
private func startBootRetryWaiting() {
|
||
stopBootRetryWaiting()
|
||
let m = NWPathMonitor()
|
||
let queue = DispatchQueue(label: "ylgamehall.boot-retry", qos: .utility)
|
||
nonisolated(unsafe) var previousSatisfied: Bool? = nil
|
||
m.pathUpdateHandler = { [weak self] path in
|
||
let isSatisfied = (path.status == .satisfied)
|
||
let prev = previousSatisfied
|
||
previousSatisfied = isSatisfied
|
||
// 仅从 false 转 true 触发自动重试;初始 nil(首次回调)不触发
|
||
guard prev == false, isSatisfied else { return }
|
||
Task { @MainActor in
|
||
guard let self else { return }
|
||
print("[Boot] 网络恢复,自动重试启动流水线")
|
||
await self.runBootPipeline()
|
||
}
|
||
}
|
||
m.start(queue: queue)
|
||
bootRetryPathMonitor = m
|
||
}
|
||
|
||
private func stopBootRetryWaiting() {
|
||
bootRetryPathMonitor?.cancel()
|
||
bootRetryPathMonitor = nil
|
||
}
|
||
|
||
private func runBootPipelineSteps() async throws {
|
||
// 1. 首装解压(仅首次安装走真正的 unzip;之后 fileExists 短路 ≤ 1ms)
|
||
try await ResourceUnzipper.shared.ensureReady()
|
||
|
||
// 2. 拉远端渠道配置(**强约束**:必须拿到正确远端 config 才进大厅)
|
||
//
|
||
// 关键决策:**不能**在远端 config 失败时走 fallback 直接进大厅。
|
||
// 原因:远端 config 包含「本地 appVersion 是否落后于远端」的升级判定,
|
||
// 跳过 = 用户用旧版本不被强制升级 = 业务风险。
|
||
//
|
||
// 失败处理 UX(行业主流:停在 splash 显示错误态 + 自动重试):
|
||
// - 抛出网络错误 → 外层 runBootPipeline catch → splash.showError
|
||
// - 同时启动 NWPathMonitor,网络从无变有时自动重试一次
|
||
// - 用户也可手动点 splash 上的「重试」/「前往系统设置」按钮
|
||
// - 永不弹 modal alert / 永不暴露原始 NSError
|
||
splash.update(text: "拉取配置中...", progress: nil)
|
||
|
||
let resolved: ResolvedVersion
|
||
let outcome = try await RemoteConfigClient.shared.fetch()
|
||
switch outcome {
|
||
case .shortText(let msg):
|
||
// 运营杀手锏 #1:服务端响应被替换为短文本,前端弹窗永停
|
||
throw BootError.operationalMessage(msg)
|
||
|
||
case .parsed(let cfg):
|
||
let bc = BundleConfig.shared
|
||
let r = VersionResolver.resolve(
|
||
config: cfg,
|
||
agentId: bc.agent,
|
||
channelId: bc.channel,
|
||
marketId: bc.market,
|
||
gameId: bc.gameId
|
||
)
|
||
|
||
// 运营杀手锏 #2:showmessage 非空 → 弹窗永停
|
||
if let msg = r.showmessage, !msg.isEmpty {
|
||
throw BootError.operationalMessage(msg)
|
||
}
|
||
|
||
// IPA 升级(远端 appVersion > 本地):弹窗 + Safari 外链永停
|
||
if r.appVersion > LocalVersionReader.localAppVersion,
|
||
let dl = r.appDownload {
|
||
throw BootError.ipaUpgradeRequired(downloadURL: dl)
|
||
}
|
||
|
||
// H5 zip 升级失败仍致命(用户体验 ≠ 静默用旧 zip)—— 抛出由外层 splash.showError
|
||
// LobbyZipUpgrader.onProgress 由 URLSession delegate 后台队列调度,
|
||
// 必须显式 hop 到 MainActor 才能动 splash UI。
|
||
_ = try await LobbyZipUpgrader.shared.upgradeIfNeeded(
|
||
resolved: r,
|
||
onProgress: { [weak self] p in
|
||
Task { @MainActor in
|
||
self?.splash.update(
|
||
text: String(format: "下载更新中 %d%%", Int(p * 100)),
|
||
progress: p
|
||
)
|
||
}
|
||
}
|
||
)
|
||
|
||
resolved = r
|
||
}
|
||
|
||
// 6. 在 loadFileURL 之前写 4 个 app_*.js(与原 msext NewRootVC.initJSdata 等价时序)
|
||
// 详见 docs/H5-Native-Implementation-Design.md §7.5
|
||
try writeAppDataFiles(resolved: resolved)
|
||
|
||
// 7. 加载本地 H5(allowingReadAccessTo 必须给 lobbyRoot 才能跨目录引用资源)
|
||
splash.update(text: "加载大厅...", progress: nil)
|
||
bridgedWebView.webView.loadFileURL(
|
||
SandboxPaths.lobbyIndex,
|
||
allowingReadAccessTo: SandboxPaths.lobbyRoot
|
||
)
|
||
}
|
||
|
||
/// 写 4 个 app_*.js 文件首次值(loadFileURL 之前调一次,monitor 已启动)。
|
||
/// 业务期变化的桥事件挂钩由 `setupExternalSubscriptions` / `teardownExternalSubscriptions`
|
||
/// 配对管理(详见 Design §2.4.2 ExternalSubscriptions 生命周期),避免大厅 push
|
||
/// 子游戏后两个 VC 都在监听导致 H5 收到双倍桥事件。
|
||
private func writeAppDataFiles(resolved: ResolvedVersion) throws {
|
||
let writer = AppDataWriter(
|
||
bundleConfig: .shared,
|
||
resolvedVersion: resolved,
|
||
containerRole: .lobby
|
||
)
|
||
|
||
// 启动 2 个 currentXxx 类 monitor(幂等)以便读首次值;
|
||
// AppLifecycleObserver 不需要 currentXxx,挪到 setup 启动
|
||
NetworkMonitor.shared.start()
|
||
BatteryMonitor.shared.start()
|
||
|
||
// 首次写入:4 个文件全部落盘
|
||
try writer.writeInitial()
|
||
try writer.writeBattery(BatteryMonitor.shared.currentLevel)
|
||
try writer.writeNetwork(NetworkMonitor.shared.currentCode)
|
||
}
|
||
|
||
// MARK: - 外部订阅生命周期(Phase 2.C / Design §2.4.2)
|
||
//
|
||
// 把 battery / network / appservice 三个外部事件源的订阅挂钩与 VC 生命周期
|
||
// 配对:viewWillAppear → setup,viewWillDisappear → teardown。
|
||
// 配套约束:未来 Phase 6 子游戏 push 上来时,大厅的 viewWillDisappear 会自动
|
||
// 触发 teardown,避免桥事件双发(参 §2.4.3 栈深 ≤ 2)。
|
||
//
|
||
// 业务期变化时**不重写文件**,改为直接 evaluateJavaScript 重新赋值 window.app_*
|
||
// 全局变量(H5 已加载完,<script src> 不会再 fetch;重新赋值后 H5 业务期同步读
|
||
// app_xxx 永远拿到最新值)。同时触发 §3.2 反向 callback 推送给注册了 handler 的
|
||
// H5 业务。详见 docs/H5-Native-Implementation-Design.md §7.5.2 / §7.5.4。
|
||
|
||
private func setupExternalSubscriptions() {
|
||
// 幂等启动 3 个 monitor(writeAppDataFiles 可能已经启动了 NetworkMonitor /
|
||
// BatteryMonitor;AppLifecycleObserver 在 setup 内首次启动)
|
||
NetworkMonitor.shared.start()
|
||
BatteryMonitor.shared.start()
|
||
AppLifecycleObserver.shared.start()
|
||
|
||
let bridge = bridgedWebView.bridge
|
||
let webView = bridgedWebView.webView
|
||
|
||
// §3.2 [2] getBattery — UIDevice 电量变化
|
||
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)
|
||
}
|
||
|
||
// §3.2 [3] getnetwork — NWPath 变化(1 无网 / 2 WiFi / 3 蜂窝)
|
||
NetworkMonitor.shared.onChange = { code in
|
||
Task { @MainActor in
|
||
_ = try? await webView.evaluateJavaScript("window.app_getnetwork=\(code);")
|
||
}
|
||
bridge.call("getnetwork", data: .string("\(code)"), callback: nil)
|
||
}
|
||
|
||
// §3.2 [4] appservice — App 前后台切换(命名错位沿用历史)
|
||
AppLifecycleObserver.shared.onBackground = {
|
||
bridge.call("appservice", data: .string("1"), callback: nil)
|
||
}
|
||
AppLifecycleObserver.shared.onForeground = {
|
||
bridge.call("appservice", data: .string("2"), callback: nil)
|
||
}
|
||
|
||
// §3.2 [12] getWebdata — 子游戏 pop 回大厅,反向把 backgameData 入参透传给 H5
|
||
// (msext NewRootVC 监听 "backgameDatatwo" 通知 → callHandler:"getWebdata" 等价)
|
||
// 大厅栈顶时挂钩,pop 进子游戏时由 viewWillDisappear teardown 解绑,避免双发。
|
||
subGameReturnObserver = NotificationCenter.default.addObserver(
|
||
forName: .subGameDidReturn,
|
||
object: nil,
|
||
queue: .main
|
||
) { note in
|
||
// queue=.main 保证 closure 在主线程;显式 hop 让 Swift 6 actor 推断通过
|
||
let data = (note.userInfo?["data"] as? String) ?? ""
|
||
MainActor.assumeIsolated {
|
||
bridge.call("getWebdata", data: .string(data), callback: nil)
|
||
}
|
||
}
|
||
}
|
||
|
||
private func teardownExternalSubscriptions() {
|
||
// 仅解绑 onChange / on{Background,Foreground} 回调,monitor 继续在
|
||
// 后台运行(保持 currentXxx 状态新鲜),下次 setup 时直接 resume。
|
||
// 单例 monitor 全局只有一个 closure 引用,子游戏 setup 时会覆盖;
|
||
// 大厅 viewWillDisappear 时 teardown 释放对当前 bridge/webView 的引用
|
||
// 避免双发。
|
||
BatteryMonitor.shared.onChange = nil
|
||
NetworkMonitor.shared.onChange = nil
|
||
AppLifecycleObserver.shared.onBackground = nil
|
||
AppLifecycleObserver.shared.onForeground = nil
|
||
|
||
if let token = subGameReturnObserver {
|
||
NotificationCenter.default.removeObserver(token)
|
||
subGameReturnObserver = nil
|
||
}
|
||
}
|
||
|
||
// MARK: - Alerts (terminal / retry states)
|
||
|
||
private var appDisplayName: String {
|
||
(Bundle.main.infoDictionary?["CFBundleDisplayName"] as? String)
|
||
?? (Bundle.main.infoDictionary?["CFBundleName"] as? String)
|
||
?? "提示"
|
||
}
|
||
|
||
private func showBlockingAlert(message: String) {
|
||
let alert = UIAlertController(
|
||
title: "\(appDisplayName) 提醒",
|
||
message: message,
|
||
preferredStyle: .alert
|
||
)
|
||
alert.addAction(UIAlertAction(title: "确定", style: .default))
|
||
present(alert, animated: true)
|
||
}
|
||
|
||
private func showIPAUpgradeAlert(downloadURL: String) {
|
||
let alert = UIAlertController(
|
||
title: "需要升级",
|
||
message: "检测到新版本,请前往下载安装。",
|
||
preferredStyle: .alert
|
||
)
|
||
alert.addAction(UIAlertAction(title: "确定", style: .default) { _ in
|
||
if let url = URL(string: downloadURL) {
|
||
UIApplication.shared.open(url)
|
||
}
|
||
})
|
||
present(alert, animated: true)
|
||
}
|
||
|
||
/// 启动期错误的分类。剥 RemoteConfigError.allRetriesFailed 找到 underlying NSURLError,
|
||
/// 按 NSURLErrorDomain code 映射到友好类别;非 NSURLError 视作本地资源问题。
|
||
/// 给 `presentBootError` 决定 splash 错误态的标题/正文/按钮组合用。
|
||
private enum BootErrorKind {
|
||
case networkOffline
|
||
case networkTimeout
|
||
case networkCannotReach
|
||
case localResourceMissing
|
||
case unknown
|
||
}
|
||
|
||
private static func classifyBootError(_ error: Error) -> BootErrorKind {
|
||
// 剥洋葱:RemoteConfigError.allRetriesFailed(underlying:) → 内层
|
||
var actual: any Error = error
|
||
if case let RemoteConfigError.allRetriesFailed(inner) = error {
|
||
actual = inner
|
||
}
|
||
let ns = actual as NSError
|
||
guard ns.domain == NSURLErrorDomain else {
|
||
// 非网络错误(FileManager / Codable 等)视作本地资源问题
|
||
return .localResourceMissing
|
||
}
|
||
switch ns.code {
|
||
case NSURLErrorNotConnectedToInternet:
|
||
return .networkOffline
|
||
case NSURLErrorTimedOut:
|
||
return .networkTimeout
|
||
case NSURLErrorCannotFindHost,
|
||
NSURLErrorCannotConnectToHost,
|
||
NSURLErrorNetworkConnectionLost,
|
||
NSURLErrorDNSLookupFailed,
|
||
NSURLErrorInternationalRoamingOff,
|
||
NSURLErrorCallIsActive,
|
||
NSURLErrorDataNotAllowed:
|
||
return .networkCannotReach
|
||
default:
|
||
return .networkCannotReach
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - WKNavigationDelegate
|
||
|
||
extension WebContainerViewController: WKNavigationDelegate {
|
||
public func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
||
// H5 大厅已经渲染完毕,此时把 splash 从 0.3s 内淡出再 removeFromSuperview,
|
||
// 避免黑屏闪烁也避免后续 superview 再持有这层不必要的视图。
|
||
UIView.animate(withDuration: 0.3, animations: { [weak self] in
|
||
self?.splash.alpha = 0
|
||
}, completion: { [weak self] _ in
|
||
self?.splash.removeFromSuperview()
|
||
})
|
||
}
|
||
}
|
||
|
||
// MARK: - SplashOverlay
|
||
//
|
||
// 与 LaunchScreen 同款启动图 + UIProgressView + 状态文字标签的复合 view。
|
||
// 单文件私有:仅 WebContainerViewController 使用;若 Phase 6 子游戏容器复用,再抽出。
|
||
|
||
private final class SplashOverlay: UIView {
|
||
|
||
private let imageView = UIImageView()
|
||
private let progressView = UIProgressView(progressViewStyle: .default)
|
||
private let label = UILabel()
|
||
|
||
// 错误态 UI(默认隐藏;showError 时显示,覆盖在 label/progress 区域)
|
||
private let errorContainer = UIStackView()
|
||
private let errorTitleLabel = UILabel()
|
||
private let errorMessageLabel = UILabel()
|
||
private let retryButton = UIButton(type: .system)
|
||
|
||
/// 用户点重试按钮时调(启动期 splash 期间)
|
||
var onRetry: (() -> Void)?
|
||
|
||
init() {
|
||
super.init(frame: .zero)
|
||
backgroundColor = .black
|
||
|
||
// 与 LaunchScreen.storyboard 同款 contentMode:素材是 1136×640 横屏画面,
|
||
// 在更宽的现代横屏设备上用 scaleAspectFit 左右补黑边(黑底统一),
|
||
// 避免 scaleAspectFill 把 logo 上下裁切;同时确保 LaunchScreen → WebContainer
|
||
// 视觉过渡时不出现裁切方式跳变。
|
||
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
|
||
// 与错误态主按钮("重试")配色一致,进度条用 systemBlue;
|
||
// 轨道用 .label 的 15% alpha,浅深模式自适应(浅模式淡灰、深模式淡白)
|
||
progressView.progressTintColor = .systemBlue
|
||
progressView.trackTintColor = UIColor.label.withAlphaComponent(0.15)
|
||
addSubview(progressView)
|
||
|
||
setupErrorContainer()
|
||
|
||
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),
|
||
|
||
errorContainer.centerXAnchor.constraint(equalTo: centerXAnchor),
|
||
errorContainer.centerYAnchor.constraint(equalTo: centerYAnchor, constant: 60),
|
||
errorContainer.leadingAnchor.constraint(greaterThanOrEqualTo: leadingAnchor, constant: 24),
|
||
errorContainer.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor, constant: -24),
|
||
errorContainer.widthAnchor.constraint(lessThanOrEqualTo: widthAnchor, multiplier: 0.7)
|
||
])
|
||
}
|
||
|
||
@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)
|
||
}
|
||
}
|
||
|
||
/// 显示错误态。隐藏 imageView / label / progress,背景改为浅色系统背景,
|
||
/// 展示 title + message + 单个"重试"按钮(点击触发 onRetry)。
|
||
/// 启动图是浅色 + 复杂图案(不适合在上面叠文字),错误态下改纯净浅背景对比清晰。
|
||
func showError(title: String, message: String) {
|
||
imageView.isHidden = true
|
||
label.isHidden = true
|
||
progressView.isHidden = true
|
||
backgroundColor = .systemBackground
|
||
errorTitleLabel.text = title
|
||
errorMessageLabel.text = message
|
||
errorContainer.isHidden = false
|
||
}
|
||
|
||
/// 关闭错误态,恢复 splash 默认状态(启动图 + loading 文案;runBootPipeline 重新进入时调)。
|
||
func hideError() {
|
||
errorContainer.isHidden = true
|
||
imageView.isHidden = false
|
||
label.isHidden = false
|
||
backgroundColor = .black
|
||
}
|
||
|
||
private func setupErrorContainer() {
|
||
// 错误态在浅色 systemBackground 上展示,文字用 .label / .secondaryLabel
|
||
// 系统语义色(浅模式深、深模式浅),保证两种 iOS appearance 下都看得清。
|
||
errorTitleLabel.textColor = .label
|
||
errorTitleLabel.font = .systemFont(ofSize: 18, weight: .semibold)
|
||
errorTitleLabel.textAlignment = .center
|
||
errorTitleLabel.numberOfLines = 1
|
||
|
||
errorMessageLabel.textColor = .secondaryLabel
|
||
errorMessageLabel.font = .systemFont(ofSize: 14)
|
||
errorMessageLabel.textAlignment = .center
|
||
errorMessageLabel.numberOfLines = 0
|
||
|
||
var cfg = UIButton.Configuration.filled()
|
||
cfg.title = "重试"
|
||
cfg.baseBackgroundColor = .systemBlue
|
||
cfg.baseForegroundColor = .white
|
||
cfg.cornerStyle = .medium
|
||
cfg.contentInsets = NSDirectionalEdgeInsets(top: 8, leading: 24, bottom: 8, trailing: 24)
|
||
retryButton.configuration = cfg
|
||
|
||
retryButton.addAction(UIAction { [weak self] _ in self?.onRetry?() }, for: .touchUpInside)
|
||
|
||
errorContainer.axis = .vertical
|
||
errorContainer.alignment = .center
|
||
errorContainer.spacing = 12
|
||
errorContainer.isHidden = true
|
||
errorContainer.translatesAutoresizingMaskIntoConstraints = false
|
||
errorContainer.addArrangedSubview(errorTitleLabel)
|
||
errorContainer.addArrangedSubview(errorMessageLabel)
|
||
errorContainer.addArrangedSubview(retryButton)
|
||
addSubview(errorContainer)
|
||
|
||
NSLayoutConstraint.activate([
|
||
retryButton.heightAnchor.constraint(equalToConstant: 40),
|
||
retryButton.widthAnchor.constraint(greaterThanOrEqualToConstant: 120)
|
||
])
|
||
}
|
||
}
|