启动期错误处理 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>
This commit is contained in:
joywayer
2026-06-23 06:47:04 +08:00
co-authored by Claude Opus 4.7
parent 74f6925be3
commit 5731588d33
@@ -9,6 +9,7 @@
import UIKit
import WebKit
import Network
public final class WebContainerViewController: UIViewController {
@@ -164,108 +165,165 @@ public final class WebContainerViewController: UIViewController {
// MARK: - Boot pipeline
private func runBootPipeline() async {
// pipeline splash
// 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 {
// ResourceUnzipper.ensureReady /
// writeAppDataFiles
// runBootPipelineSteps fallback NSError
showRetryableAlert(error: error)
// / / 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-5. +
// - / / LocalVersionReader fallback
// ResolvedVersion H5
// - operationalMessage / ipaUpgradeRequired
// 2. **** config
//
// config zip ensureReady
// config "" H5
// iOS 14+ / /
// " + NSError"
// **** config fallback
// config appVersion
// = =
//
// UX splash +
// - runBootPipeline catch splash.showError
// - NWPathMonitor
// - splash /
// - modal alert / NSError
splash.update(text: "拉取配置中...", progress: nil)
let resolved: ResolvedVersion
let usedOfflineFallback: Bool
let outcome = try await RemoteConfigClient.shared.fetch()
switch outcome {
case .shortText(let msg):
// #1
throw BootError.operationalMessage(msg)
do {
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
// LobbyZipUpgrader.onProgress URLSession delegate
// hop MainActor splash UI
do {
_ = 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
)
}
}
)
} catch {
print("[Boot] H5 zip 升级失败,继续用本地资源:", error)
}
resolved = r
usedOfflineFallback = false
}
} catch let e as BootError {
// operational / IPA
throw e
} catch {
// config
// raw error Xcode console 便 NSURLErrorDomain code
// splash
print("[Boot] 远端配置拉取失败,降级为离线模式继续启动:", error)
resolved = ResolvedVersion(
appVersion: LocalVersionReader.localAppVersion,
appDownload: nil,
gameVersion: LocalVersionReader.localGameVersion,
gameZip: nil,
showmessage: nil
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
)
usedOfflineFallback = true
// #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
@@ -273,8 +331,7 @@ public final class WebContainerViewController: UIViewController {
try writeAppDataFiles(resolved: resolved)
// 7. H5allowingReadAccessTo lobbyRoot
// splash 线/线
splash.update(text: usedOfflineFallback ? "离线加载..." : "加载大厅...", progress: nil)
splash.update(text: "加载大厅...", progress: nil)
bridgedWebView.webView.loadFileURL(
SandboxPaths.lobbyIndex,
allowingReadAccessTo: SandboxPaths.lobbyRoot
@@ -415,67 +472,9 @@ public final class WebContainerViewController: UIViewController {
present(alert, animated: true)
}
///
///
/// ** NSError ** `Error Domain=NSURLErrorDomain Code=-1009 ...`
/// NSURLError + +
/// - offline / timeout / cannotReach + +
/// - unzip / +
/// -
///
/// raw error Xcode console 便
///
/// ****runBootPipelineSteps
/// fallback catch
private func showRetryableAlert(error: Error) {
let kind = Self.classifyBootError(error)
print("[Boot] showRetryableAlert kind=\(kind) underlying:", error)
let title: String
let message: String
let showSettingsAction: Bool
switch kind {
case .networkOffline:
title = "网络未连接"
message = "请检查 Wi-Fi 或蜂窝数据后重试。"
showSettingsAction = true
case .networkTimeout:
title = "网络较慢"
message = "连接超时,请稍后重试。"
showSettingsAction = false
case .networkCannotReach:
title = "暂时无法连接服务器"
message = "网络似乎不太通畅,请稍后重试,或前往设置检查网络。"
showSettingsAction = true
case .localResourceMissing:
title = "资源加载失败"
message = "请尝试重启 app;若仍未恢复,请重新安装。"
showSettingsAction = false
case .unknown:
title = "启动遇到问题"
message = "请稍后重试。"
showSettingsAction = false
}
let alert = UIAlertController(title: title, message: message, preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "重试", style: .default) { [weak self] _ in
Task { @MainActor in
await self?.runBootPipeline()
}
})
if showSettingsAction {
alert.addAction(UIAlertAction(title: "去设置", style: .default) { _ in
if let url = URL(string: UIApplication.openSettingsURLString) {
UIApplication.shared.open(url)
}
})
}
alert.addAction(UIAlertAction(title: "稍后再说", style: .cancel))
present(alert, animated: true)
}
/// RemoteConfigError.allRetriesFailed underlying NSURLError
/// NSURLErrorDomain code NSURLError
/// `presentBootError` splash //
private enum BootErrorKind {
case networkOffline
case networkTimeout
@@ -539,6 +538,18 @@ private final class SplashOverlay: UIView {
private let progressView = UIProgressView(progressViewStyle: .default)
private let label = UILabel()
// UIshowError 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
@@ -564,6 +575,8 @@ private final class SplashOverlay: UIView {
progressView.isHidden = true
addSubview(progressView)
setupErrorContainer()
NSLayoutConstraint.activate([
imageView.topAnchor.constraint(equalTo: topAnchor),
imageView.bottomAnchor.constraint(equalTo: bottomAnchor),
@@ -577,7 +590,13 @@ private final class SplashOverlay: UIView {
progressView.centerXAnchor.constraint(equalTo: centerXAnchor),
progressView.bottomAnchor.constraint(equalTo: label.topAnchor, constant: -12),
progressView.widthAnchor.constraint(equalTo: widthAnchor, multiplier: 0.45)
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)
])
}
@@ -594,4 +613,83 @@ private final class SplashOverlay: UIView {
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
}
}