启动期错误态精确分类: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:
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 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 = "重试"
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
/// 同步快照当前 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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user