Files
youle_app_ios_v2/ylgamehall/Source/WebView/WebContainerViewController.swift
T
joywayerandClaude Opus 4.7 5731588d33 启动期错误处理 v2:撤回降级,splash 错误态 + NWPathMonitor 自动重试
撤回上一 commit 74f6925 的"远端 config 失败就降级进大厅"路径——错过升级
判定 = 用户用旧 IPA 不被强制升级 = 业务风险。新策略:

**远端 config 失败 → 停留 splash 显示错误态**(不弹 modal alert / 不暴露原始 NSError)

行业主流方案:
1. SplashOverlay 扩展错误态 UI(隐藏 label / progress,显示 title +
   message + "重试" 主按钮 + "前往系统设置" 次按钮),通过 onRetry /
   onOpenSettings closure 与 controller 解耦
2. WebContainerViewController.presentBootError 按 BootErrorKind 分类给出
   友好文案(networkOffline / networkTimeout / networkCannotReach /
   localResourceMissing / unknown)
3. 启动期独立 NWPathMonitor,仅在"从无网变有网"时静默自动重试一次
   (初始 nil 状态不触发,避免死循环;用户切到 Wi-Fi 后无需手动操作)
4. runBootPipeline 重新进入时 stopBootRetryWaiting + splash.hideError
   保证多次重试干净(防止 monitor 泄漏)

进大厅的必要条件(恢复严格):
- ResourceUnzipper.ensureReady 成功(本地 H5 zip 就位)
- RemoteConfigClient.fetch 拿到正确 config(升级判定可执行)
- 本地 appVersion >= 远端(否则走 IPA 升级 modal alert)
- showmessage 为空(否则走运营 modal alert)
- LobbyZipUpgrader 完成(含跳过升级 / 真升级两路径)

业务流程信号(operationalMessage / ipaUpgradeRequired)继续用 modal alert
(这两类不是错误,是产品决策的弹窗永停);技术错误(网络/解压/写文件等)
统一进 splash 错误态 + 自动重试。

raw error 仍 print 到 Xcode console(含 NSURLErrorDomain code 等开发期
排查信息),用户视角永不暴露技术细节。

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

696 lines
30 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.
//
// 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 仅 vibratorPhase 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 20Phase 5 完整实现,Phase 2 stub
// Phase 3.A 本地音频 + 3.C/3.D stub
LocalAudioHandler.register(on: bridge) // §3.1 3srcIsloop 完整实现
RemoteAudioHandler.register(on: bridge) // §3.1 4][5Phase 3.B/3.C/3.D stub
// Phase 4.A 登录 + 分享 stubPhase 4.B 微信 SDK / Phase 4.C QQ URL Scheme 后升级)
AccreditLoginHandler.register(on: bridge) // §3.1 1Phase 4.E 完整微信 SDK
FriendsShareHandler.register(on: bridge) // §3.1 2Phase 4.B 完整框架(QQ/抖音 已 4.C/4.D 落地)
// Phase 6.4 / 7 子游戏 + 弹层入口 stub(让 H5 业务期调时不报 "no handler"
SwitchOverGameHandler.register(on: bridge) // §3.1 17Phase 6 完整实现
OpenurlTitleDataHandler.register(on: bridge) // §3.1 15Phase 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×72016:9)。设备比例与 16:9 不匹配时用黑边补齐:
// - 比 16:9 宽(如现代 iPhone 横屏 2.16:1)→ 左右黑边
// - 比 16:9 窄(如 iPad 4:3)→ 上下黑边
//
// 约束策略:
// requiredaspect = 16:9width ≤ view.widthheight ≤ view.height
// low width = view.widthheight = 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 便于开发期排查。
private func presentBootError(_ error: any Error) {
let kind = Self.classifyBootError(error)
print("[Boot] presentBootError kind=\(kind) underlying:", error)
let title: String
let message: String
let showSettings: Bool
switch kind {
case .networkOffline:
title = "当前未联网"
message = "请检查 Wi-Fi 或蜂窝数据后重试。"
showSettings = true
case .networkTimeout:
title = "网络较慢"
message = "连接超时,请稍后重试。"
showSettings = false
case .networkCannotReach:
title = "暂时无法连接服务器"
message = "网络似乎不太通畅,请稍后重试,或前往系统设置检查网络权限。"
showSettings = true
case .localResourceMissing:
title = "资源加载失败"
message = "请尝试重启 app;若仍未恢复,请重新安装。"
showSettings = false
case .unknown:
title = "启动遇到问题"
message = "请稍后重试。"
showSettings = false
}
splash.onRetry = { [weak self] in
Task { @MainActor in await self?.runBootPipeline() }
}
splash.onOpenSettings = {
if let url = URL(string: UIApplication.openSettingsURLString) {
UIApplication.shared.open(url)
}
}
splash.showError(title: title, message: message, showSettings: showSettings)
// 静默后台监听网络恢复,从无网 → 有网时自动重试一次
// (行业主流体验:用户切到 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
)
// 运营杀手锏 #2showmessage 非空 → 弹窗永停
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. 加载本地 H5allowingReadAccessTo 必须给 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 → setupviewWillDisappear → 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 个 monitorwriteAppDataFiles 可能已经启动了 NetworkMonitor /
// BatteryMonitorAppLifecycleObserver 在 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)
private let settingsButton = UIButton(type: .system)
/// 用户点重试按钮时调(启动期 splash 期间)
var onRetry: (() -> Void)?
/// 用户点去设置按钮时调
var onOpenSettings: (() -> 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
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)
}
}
/// 显示错误态。隐藏 label / progress,展示 title + message + 重试 + 去设置(可选)。
func showError(title: String, message: String, showSettings: Bool) {
label.isHidden = true
progressView.isHidden = true
errorTitleLabel.text = title
errorMessageLabel.text = message
settingsButton.isHidden = !showSettings
errorContainer.isHidden = false
}
/// 关闭错误态,恢复 loading 文案展示(runBootPipeline 重新进入时调)。
func hideError() {
errorContainer.isHidden = true
label.isHidden = false
}
private func setupErrorContainer() {
errorTitleLabel.textColor = .white
errorTitleLabel.font = .systemFont(ofSize: 18, weight: .semibold)
errorTitleLabel.textAlignment = .center
errorTitleLabel.numberOfLines = 1
errorMessageLabel.textColor = UIColor.white.withAlphaComponent(0.85)
errorMessageLabel.font = .systemFont(ofSize: 14)
errorMessageLabel.textAlignment = .center
errorMessageLabel.numberOfLines = 0
configureFilledButton(retryButton, title: "重试")
configureOutlineButton(settingsButton, title: "前往系统设置")
retryButton.addAction(UIAction { [weak self] _ in self?.onRetry?() }, for: .touchUpInside)
settingsButton.addAction(UIAction { [weak self] _ in self?.onOpenSettings?() }, for: .touchUpInside)
let buttonStack = UIStackView(arrangedSubviews: [retryButton, settingsButton])
buttonStack.axis = .horizontal
buttonStack.spacing = 12
buttonStack.alignment = .center
buttonStack.distribution = .fillEqually
errorContainer.axis = .vertical
errorContainer.alignment = .center
errorContainer.spacing = 12
errorContainer.isHidden = true
errorContainer.translatesAutoresizingMaskIntoConstraints = false
errorContainer.addArrangedSubview(errorTitleLabel)
errorContainer.addArrangedSubview(errorMessageLabel)
errorContainer.addArrangedSubview(buttonStack)
addSubview(errorContainer)
NSLayoutConstraint.activate([
retryButton.heightAnchor.constraint(equalToConstant: 40),
retryButton.widthAnchor.constraint(greaterThanOrEqualToConstant: 96),
settingsButton.heightAnchor.constraint(equalToConstant: 40),
settingsButton.widthAnchor.constraint(greaterThanOrEqualToConstant: 130)
])
}
private func configureFilledButton(_ button: UIButton, title: String) {
var cfg = UIButton.Configuration.filled()
cfg.title = title
cfg.baseBackgroundColor = .systemBlue
cfg.baseForegroundColor = .white
cfg.cornerStyle = .medium
cfg.contentInsets = NSDirectionalEdgeInsets(top: 8, leading: 20, bottom: 8, trailing: 20)
button.configuration = cfg
}
private func configureOutlineButton(_ button: UIButton, title: String) {
var cfg = UIButton.Configuration.bordered()
cfg.title = title
cfg.baseForegroundColor = .white
cfg.cornerStyle = .medium
cfg.background.strokeColor = .white
cfg.background.strokeWidth = 1
cfg.background.backgroundColor = .clear
cfg.contentInsets = NSDirectionalEdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16)
button.configuration = cfg
}
}