启动期错误态精确分类:NWPath 区分真断网/权限拒,按钮语义动态切换

用户反馈:之前的文案统一映射 NSURLError code,没区分"真没网"和"权限
被拒"——两类 iOS 14+ 上都报 -1009,但 NWPath.unsatisfiedReason 里有
wifiDenied / cellularDenied 标记可精确区分。点击重试时也没有针对权限
被拒场景做特殊处理(重发请求被同样的权限决定拒绝,原地循环)。

改动:

1. **NWPath snapshot 精确分类**
   - 新增 BootErrorKind: noNetworkAccess / wifiDenied / cellularDenied /
     networkTimeout / networkCannotReach / localResourceMissing / unknown
   - 新增 currentPathSnapshot() async:临时 NWPathMonitor 等首次 update
     拿当前 path
   - classifyBootError 接 path 参数:先看 NWPath.unsatisfiedReason
     (iOS 14.2+)区分权限拒/真断网;fallback 才按 NSURLError code
   - runBootPipeline 的 catch 内 await snapshot,传入精确 kind

2. **按钮语义按 kind 切换**(同一个按钮承担双语义,UX 一致)
   - 权限拒(wifiDenied / cellularDenied)→ 按钮显示"前往设置",
     点击跳 UIApplication.openSettingsURLString
   - 其他(noNetworkAccess / timeout / unreachable / localResource)→
     按钮显示"重试",点击重跑 runBootPipeline
   - SplashOverlay.showError 加 actionTitle 参数,通过 UIButton.Configuration
     动态改 title

3. **didBecomeActive 自动重试**(行业最佳做法)
   - 权限拒分支跳设置时挂 UIApplication.didBecomeActiveNotification 监听
   - 用户在设置改完权限切回 app → 自动 runBootPipeline → 进入大厅
     无需用户再点任何按钮
   - 一次性 observer:触发后自动取消,避免重复
   - runBootPipeline 入口同时 stop NWPathMonitor + stop settingsReturnObserver
     保证多次重试干净

文案精确化:
- noNetworkAccess:"当前未联网 / 请检查 Wi-Fi 或蜂窝数据后重试"
- wifiDenied:"未授权使用无线局域网 / 请前往 iOS 设置 → 本应用 → 打开
  「无线数据」,授权后将自动重试"
- cellularDenied:"未授权使用蜂窝数据 / 请前往 iOS 设置 → 本应用 → 打开
  「无线数据」,或连接 Wi-Fi 后重试"

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
joywayer
2026-06-23 07:19:50 +08:00
co-authored by Claude Opus 4.7
parent 0e446df97a
commit 4ca4459ad5
@@ -165,8 +165,9 @@ public final class WebContainerViewController: UIViewController {
// MARK: - Boot pipeline
private func runBootPipeline() async {
// 重新进入 pipeline 时(重试路径):清除错误态 + 重置 splash
// 重新进入 pipeline 时(重试路径):清除所有错误态监听 + 重置 splash
stopBootRetryWaiting()
stopWaitingForUserReturnFromSettings()
splash.hideError()
splash.isHidden = false
splash.update(text: "拼命启动中...", progress: nil)
@@ -180,55 +181,106 @@ public final class WebContainerViewController: UIViewController {
// IPA 升级 modal alert(业务流程信号)
showIPAUpgradeAlert(downloadURL: dl)
} catch {
// 网络 / 本地资源 / 解析等可重试错误 → 在 splash 上展示错误态
// (不弹 modal、不暴露原始 NSError),并启动 NWPathMonitor 静默自动重试
presentBootError(error)
// 网络 / 权限拒 / 本地资源等可重试错误 → 在 splash 上展示错误态。
// 异步 snapshot NWPath 区分"真断网"和"权限被拒",文案和按钮语义按 kind 精确分流。
let path = await Self.currentPathSnapshot()
let kind = Self.classifyBootError(error, path: path)
presentBootError(error, kind: kind)
}
}
/// 在 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)
/// 在 splash 上展示错误态(不弹 modal alert)。raw error 仍 print 到 Xcode console
/// 便于开发期排查。文案 + 按钮语义按 `kind` 精确切换:
/// - 真断网 / 超时 / 服务器不可达 → "重试"(重跑 runBootPipeline)
/// + NWPathMonitor 静默监听网络恢复自动重试一次
/// - 权限被拒(wifiDenied / cellularDenied)→ "前往设置"(跳 iOS 设置打开权限)
/// + 监听 didBecomeActive,用户从设置回 app 时自动重试
/// - 本地资源失败 → "重试"(同上但通常重试无用,需重启 app)
private func presentBootError(_ error: any Error, kind: BootErrorKind) {
print("[Boot] presentBootError kind=\(kind) underlying:", error)
let title: String
let message: String
let actionTitle: String
switch kind {
case .networkOffline:
case .noNetworkAccess:
title = "当前未联网"
message = "请检查 Wi-Fi 或蜂窝数据后重试。"
actionTitle = "重试"
case .wifiDenied:
title = "未授权使用无线局域网"
message = "请前往 iOS 设置 → 本应用 → 打开「无线数据」,授权后将自动重试。"
actionTitle = "前往设置"
case .cellularDenied:
title = "未授权使用蜂窝数据"
message = "请前往 iOS 设置 → 本应用 → 打开「无线数据」,或连接 Wi-Fi 后重试。"
actionTitle = "前往设置"
case .networkTimeout:
title = "网络较慢"
message = "连接超时,请稍后重试。"
actionTitle = "重试"
case .networkCannotReach:
title = "暂时无法连接服务器"
message = "网络似乎不太通畅,请稍后重试。"
actionTitle = "重试"
case .localResourceMissing:
title = "资源加载失败"
message = "请尝试重启 app;若仍未恢复,请重新安装。"
actionTitle = "重试"
case .unknown:
title = "启动遇到问题"
message = "请稍后重试。"
actionTitle = "重试"
}
if kind.isPermissionDenied {
// 权限拒:按钮 → 跳系统设置;同时挂 didBecomeActive 监听,用户改完权限
// 切回 app 时自动重试(无需用户再点按钮)
splash.onRetry = { [weak self] in
guard let url = URL(string: UIApplication.openSettingsURLString) else { return }
UIApplication.shared.open(url)
self?.startWaitingForUserReturnFromSettings()
}
} else {
// 网络 / 本地资源类:按钮 → 直接重跑 pipeline
splash.onRetry = { [weak self] in
Task { @MainActor in await self?.runBootPipeline() }
}
splash.showError(title: title, message: message)
// 静默后台监听网络恢复,从无网 → 有网时自动重试一次
// (行业主流体验:用户切到 Wi-Fi 后无需手动操作即自动进入大厅)
// 同时静默后台监听网络恢复(仅网络类有意义;本地资源类失败时 path 通常 satisfied
// 不会触发自动重试,无副作用)
startBootRetryWaiting()
}
splash.showError(title: title, message: message, actionTitle: actionTitle)
}
/// 用户跳 iOS 设置后,监听 app didBecomeActive 自动重试 runBootPipeline。
/// 仅一次性:触发后自动取消监听。错误态期间用户多次进出设置也只触发一次重试。
private var settingsReturnObserver: NSObjectProtocol?
private func startWaitingForUserReturnFromSettings() {
stopWaitingForUserReturnFromSettings()
settingsReturnObserver = NotificationCenter.default.addObserver(
forName: UIApplication.didBecomeActiveNotification,
object: nil,
queue: .main
) { [weak self] _ in
guard let self else { return }
MainActor.assumeIsolated {
self.stopWaitingForUserReturnFromSettings()
print("[Boot] 用户从系统设置返回 app,自动重试启动流水线")
Task { @MainActor in await self.runBootPipeline() }
}
}
}
private func stopWaitingForUserReturnFromSettings() {
if let obs = settingsReturnObserver {
NotificationCenter.default.removeObserver(obs)
settingsReturnObserver = nil
}
}
/// 启动期 NWPathMonitor。仅在 splash 错误态期间存活;进入大厅或用户主动重试时销毁。
/// 仅在"从无网变有网"时触发自动重试,避免初始 satisfied 状态导致死循环。
private var bootRetryPathMonitor: NWPathMonitor?
@@ -468,18 +520,29 @@ public final class WebContainerViewController: UIViewController {
present(alert, animated: true)
}
/// 启动期错误的分类。剥 RemoteConfigError.allRetriesFailed 找到 underlying NSURLError,
/// 按 NSURLErrorDomain code 映射到友好类别;非 NSURLError 视作本地资源问题。
/// 给 `presentBootError` 决定 splash 错误态的标题/正文/按钮组合用。
/// 启动期错误的精确分类。先看 NWPath.unsatisfiedReason 区分"真断网"和"权限被拒"
/// (iOS 14+ 两类都报 NSURLError -1009,但 path 里有 wifiDenied / cellularDenied
/// 标记),fallback 看 NSURLError code。
/// 给 `presentBootError` 决定 splash 错误态的标题/正文/按钮语义用。
private enum BootErrorKind {
case networkOffline
case noNetworkAccess // 真没网(飞行模式、无 Wi-Fi 无蜂窝)
case wifiDenied // 用户在 iOS 设置里禁了本 app 的 Wi-Fi 访问
case cellularDenied // 用户在 iOS 设置里禁了本 app 的蜂窝数据访问
case networkTimeout
case networkCannotReach
case localResourceMissing
case unknown
/// 该错误是否由"用户拒绝授权"导致(按钮应导向系统设置而非重试)
var isPermissionDenied: Bool {
switch self {
case .wifiDenied, .cellularDenied: return true
default: return false
}
}
}
private static func classifyBootError(_ error: Error) -> BootErrorKind {
private static func classifyBootError(_ error: Error, path: NWPath?) -> BootErrorKind {
// 剥洋葱:RemoteConfigError.allRetriesFailed(underlying:) → 内层
var actual: any Error = error
if case let RemoteConfigError.allRetriesFailed(inner) = error {
@@ -490,9 +553,24 @@ public final class WebContainerViewController: UIViewController {
// 非网络错误(FileManager / Codable 等)视作本地资源问题
return .localResourceMissing
}
// 优先看 NWPath.unsatisfiedReason 区分权限拒/真断网(iOS 14.2+)。
// iOS 把这两类都映射成 NSURLError -1009,只有 NWPath 能给出"为什么不可用"。
if let path, path.status == .unsatisfied {
if #available(iOS 14.2, *) {
switch path.unsatisfiedReason {
case .wifiDenied: return .wifiDenied
case .cellularDenied: return .cellularDenied
case .notAvailable: return .noNetworkAccess
default: break
}
}
}
// Fallback:按 NSURLError code 分
switch ns.code {
case NSURLErrorNotConnectedToInternet:
return .networkOffline
return .noNetworkAccess
case NSURLErrorTimedOut:
return .networkTimeout
case NSURLErrorCannotFindHost,
@@ -507,6 +585,24 @@ public final class WebContainerViewController: UIViewController {
return .networkCannotReach
}
}
/// 同步快照当前 NWPath:start 一个临时 NWPathMonitor 等首次 pathUpdateHandler 触发。
/// 用于在错误态 UI 展示前判断"是真没网还是权限被拒"。
private static func currentPathSnapshot() async -> NWPath {
await withCheckedContinuation { (cont: CheckedContinuation<NWPath, Never>) in
let monitor = NWPathMonitor()
// resumed 标记避免 monitor 多次 update 触发重复 resume(Network framework
// 在某些设备上启动期会回调多次)
nonisolated(unsafe) var resumed = false
monitor.pathUpdateHandler = { path in
guard !resumed else { return }
resumed = true
monitor.cancel()
cont.resume(returning: path)
}
monitor.start(queue: DispatchQueue.global(qos: .userInitiated))
}
}
}
// MARK: - WKNavigationDelegate
@@ -612,15 +708,19 @@ private final class SplashOverlay: UIView {
}
/// 显示错误态。隐藏 imageView / label / progress,背景改为浅色系统背景,
/// 展示 title + message + 单个"重试"按钮(点击触发 onRetry)。
/// 展示 title + message + 单个按钮(点击触发 onRetry)。
/// 按钮文字由调用方按错误 kind 决定("重试" / "前往设置"),同一个按钮承担双语义。
/// 启动图是浅色 + 复杂图案(不适合在上面叠文字),错误态下改纯净浅背景对比清晰。
func showError(title: String, message: String) {
func showError(title: String, message: String, actionTitle: String = "重试") {
imageView.isHidden = true
label.isHidden = true
progressView.isHidden = true
backgroundColor = .systemBackground
errorTitleLabel.text = title
errorMessageLabel.text = message
var cfg = retryButton.configuration
cfg?.title = actionTitle
retryButton.configuration = cfg
errorContainer.isHidden = false
}