启动期错误处理 UX 改造:优雅降级 + 友好文案,不暴露技术细节

修复用户反馈的体验问题:网络权限未同意 / 长期不响应权限弹窗时,
启动会弹出"启动失败" + 原始 NSError("Error Domain=NSURLErrorDomain
Code=-1009 ...大量技术细节..."),用户被吓懵。

按行业主流做法(参考微信/支付宝/网易云音乐启动期错误处理)改两件事:

1. **优雅降级:远端 config 失败 ≠ 启动失败**
   runBootPipelineSteps 把"拉远端 config + 升级判定"整段包 do/catch:
   - 业务流程信号(operationalMessage / ipaUpgradeRequired)正常抛出弹窗
   - 网络 / 解析 / H5 zip 下载任何失败 → 用 LocalVersionReader 构造
     fallback ResolvedVersion,跳过升级判定,直接加载本地 H5 大厅
   - splash 文案区分在线 "加载大厅..." / 离线 "离线加载..."
   - raw error 仍 print 到 Xcode console 便于排查

   理论依据:ResourceUnzipper.ensureReady 之后本地 H5 zip 已就绪,
   远端 config 仅用于升级判定,失败时本地大厅完全可用。

2. **友好文案 + 多按钮 alert(showRetryableAlert)**
   - 新增 BootErrorKind 分类:剥洋葱看 NSURLError code 映射到
     networkOffline (-1009) / networkTimeout (-1001) / networkCannotReach
     (-1003/-1004/-1005/-1009/-1018/-1019/-1020) / localResourceMissing /
     unknown
   - 每类配友好标题 + 正文("网络未连接"/"网络较慢"/"暂时无法连接服务器"
     而非 raw "Error Domain=...")
   - 按钮组合按类别:网络类含"去设置"(跳 UIApplication.openSettingsURLString)
     + 重试 + 稍后再说;本地资源类只重试 + 稍后再说
   - "重试" 不强制("稍后再说" 让用户自由退出,避免单按钮卡死)

结果:用户在断网 / 未授权 Wi-Fi / 测试域名不可达等场景下,splash 多停
约 7s(3 次重试 + 退避)后自动降级进入大厅,**不弹任何 alert**。仅当
本地资源也失效(极罕见)时才弹友好提示,永不暴露原始 NSError。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
joywayer
2026-06-23 03:12:33 +08:00
co-authored by Claude Opus 4.7
parent 323fb9074b
commit 74f6925be3
@@ -175,7 +175,10 @@ public final class WebContainerViewController: UIViewController {
} catch BootError.ipaUpgradeRequired(let dl) { } catch BootError.ipaUpgradeRequired(let dl) {
showIPAUpgradeAlert(downloadURL: dl) showIPAUpgradeAlert(downloadURL: dl)
} catch { } catch {
showFatalAlert(error: error) // ResourceUnzipper.ensureReady /
// writeAppDataFiles
// runBootPipelineSteps fallback NSError
showRetryableAlert(error: error)
} }
} }
@@ -183,14 +186,25 @@ public final class WebContainerViewController: UIViewController {
// 1. unzip fileExists 1ms // 1. unzip fileExists 1ms
try await ResourceUnzipper.shared.ensureReady() try await ResourceUnzipper.shared.ensureReady()
// 2. // 2-5. +
// - / / LocalVersionReader fallback
// ResolvedVersion H5
// - operationalMessage / ipaUpgradeRequired
//
// config zip ensureReady
// config "" H5
// iOS 14+ / /
// " + NSError"
splash.update(text: "拉取配置中...", progress: nil) splash.update(text: "拉取配置中...", progress: nil)
let outcome = try await RemoteConfigClient.shared.fetch()
let resolved: ResolvedVersion let resolved: ResolvedVersion
let usedOfflineFallback: Bool
do {
let outcome = try await RemoteConfigClient.shared.fetch()
switch outcome { switch outcome {
case .shortText(let msg): case .shortText(let msg):
// #1 // #1
throw BootError.operationalMessage(msg) throw BootError.operationalMessage(msg)
case .parsed(let cfg): case .parsed(let cfg):
@@ -203,20 +217,21 @@ public final class WebContainerViewController: UIViewController {
gameId: bc.gameId gameId: bc.gameId
) )
// 3. #2showmessage // #2showmessage
if let msg = r.showmessage, !msg.isEmpty { if let msg = r.showmessage, !msg.isEmpty {
throw BootError.operationalMessage(msg) throw BootError.operationalMessage(msg)
} }
// 4. IPA appVersion > + Safari // IPA appVersion > + Safari
if r.appVersion > LocalVersionReader.localAppVersion, if r.appVersion > LocalVersionReader.localAppVersion,
let dl = r.appDownload { let dl = r.appDownload {
throw BootError.ipaUpgradeRequired(downloadURL: dl) throw BootError.ipaUpgradeRequired(downloadURL: dl)
} }
// 5. H5 zip gameVersion > // H5 zip
// LobbyZipUpgrader onProgress URLSession delegate // LobbyZipUpgrader.onProgress URLSession delegate
// hop MainActor splash UI // hop MainActor splash UI
do {
_ = try await LobbyZipUpgrader.shared.upgradeIfNeeded( _ = try await LobbyZipUpgrader.shared.upgradeIfNeeded(
resolved: r, resolved: r,
onProgress: { [weak self] p in onProgress: { [weak self] p in
@@ -228,7 +243,29 @@ public final class WebContainerViewController: UIViewController {
} }
} }
) )
} catch {
print("[Boot] H5 zip 升级失败,继续用本地资源:", error)
}
resolved = r 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
)
usedOfflineFallback = true
} }
// 6. loadFileURL 4 app_*.js msext NewRootVC.initJSdata // 6. loadFileURL 4 app_*.js msext NewRootVC.initJSdata
@@ -236,7 +273,8 @@ public final class WebContainerViewController: UIViewController {
try writeAppDataFiles(resolved: resolved) try writeAppDataFiles(resolved: resolved)
// 7. H5allowingReadAccessTo lobbyRoot // 7. H5allowingReadAccessTo lobbyRoot
splash.update(text: "加载大厅...", progress: nil) // splash 线/线
splash.update(text: usedOfflineFallback ? "离线加载..." : "加载大厅...", progress: nil)
bridgedWebView.webView.loadFileURL( bridgedWebView.webView.loadFileURL(
SandboxPaths.lobbyIndex, SandboxPaths.lobbyIndex,
allowingReadAccessTo: SandboxPaths.lobbyRoot allowingReadAccessTo: SandboxPaths.lobbyRoot
@@ -377,19 +415,103 @@ public final class WebContainerViewController: UIViewController {
present(alert, animated: true) present(alert, animated: true)
} }
private func showFatalAlert(error: Error) { ///
let alert = UIAlertController( ///
title: "启动失败", /// ** NSError ** `Error Domain=NSURLErrorDomain Code=-1009 ...`
message: "\(error)", /// NSURLError + +
preferredStyle: .alert /// - 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 alert.addAction(UIAlertAction(title: "重试", style: .default) { [weak self] _ in
Task { @MainActor in Task { @MainActor in
await self?.runBootPipeline() 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) present(alert, animated: true)
} }
/// RemoteConfigError.allRetriesFailed underlying NSURLError
/// NSURLErrorDomain code NSURLError
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 // MARK: - WKNavigationDelegate