启动期错误态精确分类: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,53 +181,104 @@ 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 alertraw 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 = "重试"
}
splash.onRetry = { [weak self] in
Task { @MainActor in await self?.runBootPipeline() }
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() }
}
// path satisfied
//
startBootRetryWaiting()
}
splash.showError(title: title, message: message)
//
// Wi-Fi
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
@@ -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
}
}
/// NWPathstart NWPathMonitor pathUpdateHandler
/// UI ""
private static func currentPathSnapshot() async -> NWPath {
await withCheckedContinuation { (cont: CheckedContinuation<NWPath, Never>) in
let monitor = NWPathMonitor()
// resumed monitor update resumeNetwork 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
}