启动期错误处理 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:
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) {
|
||||
showIPAUpgradeAlert(downloadURL: dl)
|
||||
} 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)
|
||||
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)
|
||||
let outcome = try await RemoteConfigClient.shared.fetch()
|
||||
|
||||
let resolved: ResolvedVersion
|
||||
let usedOfflineFallback: Bool
|
||||
|
||||
do {
|
||||
let outcome = try await RemoteConfigClient.shared.fetch()
|
||||
switch outcome {
|
||||
case .shortText(let msg):
|
||||
// 运营杀手锏 #1:运营把整段服务端响应替换为短文本,前端弹窗永停
|
||||
// 运营杀手锏 #1:服务端响应被替换为短文本,前端弹窗永停
|
||||
throw BootError.operationalMessage(msg)
|
||||
|
||||
case .parsed(let cfg):
|
||||
@@ -203,20 +217,21 @@ public final class WebContainerViewController: UIViewController {
|
||||
gameId: bc.gameId
|
||||
)
|
||||
|
||||
// 3. 运营杀手锏 #2:showmessage 非空 → 弹窗永停
|
||||
// 运营杀手锏 #2:showmessage 非空 → 弹窗永停
|
||||
if let msg = r.showmessage, !msg.isEmpty {
|
||||
throw BootError.operationalMessage(msg)
|
||||
}
|
||||
|
||||
// 4. IPA 升级(远端 appVersion > 本地):弹窗 + Safari 外链永停
|
||||
// IPA 升级(远端 appVersion > 本地):弹窗 + Safari 外链永停
|
||||
if r.appVersion > LocalVersionReader.localAppVersion,
|
||||
let dl = r.appDownload {
|
||||
throw BootError.ipaUpgradeRequired(downloadURL: dl)
|
||||
}
|
||||
|
||||
// 5. H5 zip 升级(远端 gameVersion > 本地):进度条实时更新
|
||||
// LobbyZipUpgrader 的 onProgress 由 URLSession delegate 在后台队列调度,
|
||||
// 必须显式 hop 到 MainActor 才能动 splash UI。
|
||||
// H5 zip 升级失败也走降级(继续用本地版本,不阻塞启动;
|
||||
// LobbyZipUpgrader.onProgress 由 URLSession delegate 在后台队列调度,
|
||||
// 必须显式 hop 到 MainActor 才能动 splash UI)
|
||||
do {
|
||||
_ = try await LobbyZipUpgrader.shared.upgradeIfNeeded(
|
||||
resolved: r,
|
||||
onProgress: { [weak self] p in
|
||||
@@ -228,7 +243,29 @@ public final class WebContainerViewController: UIViewController {
|
||||
}
|
||||
}
|
||||
)
|
||||
} 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
|
||||
)
|
||||
usedOfflineFallback = true
|
||||
}
|
||||
|
||||
// 6. 在 loadFileURL 之前写 4 个 app_*.js(与原 msext NewRootVC.initJSdata 等价时序)
|
||||
@@ -236,7 +273,8 @@ public final class WebContainerViewController: UIViewController {
|
||||
try writeAppDataFiles(resolved: resolved)
|
||||
|
||||
// 7. 加载本地 H5(allowingReadAccessTo 必须给 lobbyRoot 才能跨目录引用资源)
|
||||
splash.update(text: "加载大厅...", progress: nil)
|
||||
// splash 文案区分在线/离线,让用户对状态有感知(不暴露技术细节)
|
||||
splash.update(text: usedOfflineFallback ? "离线加载..." : "加载大厅...", progress: nil)
|
||||
bridgedWebView.webView.loadFileURL(
|
||||
SandboxPaths.lobbyIndex,
|
||||
allowingReadAccessTo: SandboxPaths.lobbyRoot
|
||||
@@ -377,19 +415,103 @@ public final class WebContainerViewController: UIViewController {
|
||||
present(alert, animated: true)
|
||||
}
|
||||
|
||||
private func showFatalAlert(error: Error) {
|
||||
let alert = UIAlertController(
|
||||
title: "启动失败",
|
||||
message: "\(error)",
|
||||
preferredStyle: .alert
|
||||
)
|
||||
/// 启动期可重试错误的友好弹窗。
|
||||
///
|
||||
/// **不暴露原始 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 视作本地资源问题。
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user