启动期错误态 UX 调整:浅背景深色字,仅保留"重试"按钮

按用户反馈精简错误态 UI:
- splash 启动图是浅色背景(米白底 + logo + 蓝色文字),白色文字在上面
  看不清。错误态显示时改为 backgroundColor = .systemBackground(浅模式
  白、深模式黑),同时隐藏启动图 imageView,文字用 .label / .secondaryLabel
  系统语义色确保两种 appearance 下都对比清晰
- 去掉"前往系统设置"次按钮:仅保留蓝色填充主按钮"重试"
- "无法连接服务器"文案删去"或前往系统设置"尾句

"权限弹窗"诉求的实际实现:
重试按钮直接重跑 runBootPipeline → RemoteConfigClient.fetch →
URLSession.dataTask 重新发起请求。这正是 Apple 推荐的"让网络请求
自然触发权限弹窗"模式:
- 首次启动若系统网络权限弹窗未响应/被拒,重新发请求会再次触发系统
  对权限状态的评估,可能复弹
- 用户在 iOS 隐私管理里改了权限后回 app,点重试就能成功
- NWPathMonitor 也在静默监听,切到正常网络后无需点击即自动重试

不需要额外的"手动申请权限" API 代码(iOS 没有公开的 API 强制弹起
被拒绝的网络权限弹窗);现有「重新发请求」即是教科书做法。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
joywayer
2026-06-23 07:10:23 +08:00
co-authored by Claude Opus 4.7
parent 5731588d33
commit 34f76e8824
@@ -188,45 +188,41 @@ public final class WebContainerViewController: UIViewController {
/// 在 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)
print("[Boot] presentBootError kind=\(kind) underlying:", error)
let title: String
let message: String
let showSettings: Bool
switch kind {
case .networkOffline:
title = "当前未联网"
message = "请检查 Wi-Fi 或蜂窝数据后重试。"
showSettings = true
case .networkTimeout:
title = "网络较慢"
message = "连接超时,请稍后重试。"
showSettings = false
case .networkCannotReach:
title = "暂时无法连接服务器"
message = "网络似乎不太通畅,请稍后重试,或前往系统设置检查网络权限。"
showSettings = true
message = "网络似乎不太通畅,请稍后重试。"
case .localResourceMissing:
title = "资源加载失败"
message = "请尝试重启 app;若仍未恢复,请重新安装。"
showSettings = false
case .unknown:
title = "启动遇到问题"
message = "请稍后重试。"
showSettings = false
}
splash.onRetry = { [weak self] in
Task { @MainActor in await self?.runBootPipeline() }
}
splash.onOpenSettings = {
if let url = URL(string: UIApplication.openSettingsURLString) {
UIApplication.shared.open(url)
}
}
splash.showError(title: title, message: message, showSettings: showSettings)
splash.showError(title: title, message: message)
// 静默后台监听网络恢复,从无网 → 有网时自动重试一次
// (行业主流体验:用户切到 Wi-Fi 后无需手动操作即自动进入大厅)
@@ -543,12 +539,9 @@ private final class SplashOverlay: UIView {
private let errorTitleLabel = UILabel()
private let errorMessageLabel = UILabel()
private let retryButton = UIButton(type: .system)
private let settingsButton = UIButton(type: .system)
/// 用户点重试按钮时调(启动期 splash 期间)
var onRetry: (() -> Void)?
/// 用户点去设置按钮时调
var onOpenSettings: (() -> Void)?
init() {
super.init(frame: .zero)
@@ -614,44 +607,49 @@ private final class SplashOverlay: UIView {
}
}
/// 显示错误态。隐藏 label / progress,展示 title + message + 重试 + 去设置(可选)。
func showError(title: String, message: String, showSettings: Bool) {
/// 显示错误态。隐藏 imageView / label / progress,背景改为浅色系统背景,
/// 展示 title + message + 单个"重试"按钮(点击触发 onRetry)。
/// 启动图是浅色 + 复杂图案(不适合在上面叠文字),错误态下改纯净浅背景对比清晰。
func showError(title: String, message: String) {
imageView.isHidden = true
label.isHidden = true
progressView.isHidden = true
backgroundColor = .systemBackground
errorTitleLabel.text = title
errorMessageLabel.text = message
settingsButton.isHidden = !showSettings
errorContainer.isHidden = false
}
/// 关闭错误态,恢复 loading 文案展示(runBootPipeline 重新进入时调)。
/// 关闭错误态,恢复 splash 默认状态(启动图 + loading 文案;runBootPipeline 重新进入时调)。
func hideError() {
errorContainer.isHidden = true
imageView.isHidden = false
label.isHidden = false
backgroundColor = .black
}
private func setupErrorContainer() {
errorTitleLabel.textColor = .white
// 错误态在浅色 systemBackground 上展示,文字用 .label / .secondaryLabel
// 系统语义色(浅模式深、深模式浅),保证两种 iOS appearance 下都看得清。
errorTitleLabel.textColor = .label
errorTitleLabel.font = .systemFont(ofSize: 18, weight: .semibold)
errorTitleLabel.textAlignment = .center
errorTitleLabel.numberOfLines = 1
errorMessageLabel.textColor = UIColor.white.withAlphaComponent(0.85)
errorMessageLabel.textColor = .secondaryLabel
errorMessageLabel.font = .systemFont(ofSize: 14)
errorMessageLabel.textAlignment = .center
errorMessageLabel.numberOfLines = 0
configureFilledButton(retryButton, title: "重试")
configureOutlineButton(settingsButton, title: "前往系统设置")
var cfg = UIButton.Configuration.filled()
cfg.title = "重试"
cfg.baseBackgroundColor = .systemBlue
cfg.baseForegroundColor = .white
cfg.cornerStyle = .medium
cfg.contentInsets = NSDirectionalEdgeInsets(top: 8, leading: 24, bottom: 8, trailing: 24)
retryButton.configuration = cfg
retryButton.addAction(UIAction { [weak self] _ in self?.onRetry?() }, for: .touchUpInside)
settingsButton.addAction(UIAction { [weak self] _ in self?.onOpenSettings?() }, for: .touchUpInside)
let buttonStack = UIStackView(arrangedSubviews: [retryButton, settingsButton])
buttonStack.axis = .horizontal
buttonStack.spacing = 12
buttonStack.alignment = .center
buttonStack.distribution = .fillEqually
errorContainer.axis = .vertical
errorContainer.alignment = .center
@@ -660,36 +658,12 @@ private final class SplashOverlay: UIView {
errorContainer.translatesAutoresizingMaskIntoConstraints = false
errorContainer.addArrangedSubview(errorTitleLabel)
errorContainer.addArrangedSubview(errorMessageLabel)
errorContainer.addArrangedSubview(buttonStack)
errorContainer.addArrangedSubview(retryButton)
addSubview(errorContainer)
NSLayoutConstraint.activate([
retryButton.heightAnchor.constraint(equalToConstant: 40),
retryButton.widthAnchor.constraint(greaterThanOrEqualToConstant: 96),
settingsButton.heightAnchor.constraint(equalToConstant: 40),
settingsButton.widthAnchor.constraint(greaterThanOrEqualToConstant: 130)
retryButton.widthAnchor.constraint(greaterThanOrEqualToConstant: 120)
])
}
private func configureFilledButton(_ button: UIButton, title: String) {
var cfg = UIButton.Configuration.filled()
cfg.title = title
cfg.baseBackgroundColor = .systemBlue
cfg.baseForegroundColor = .white
cfg.cornerStyle = .medium
cfg.contentInsets = NSDirectionalEdgeInsets(top: 8, leading: 20, bottom: 8, trailing: 20)
button.configuration = cfg
}
private func configureOutlineButton(_ button: UIButton, title: String) {
var cfg = UIButton.Configuration.bordered()
cfg.title = title
cfg.baseForegroundColor = .white
cfg.cornerStyle = .medium
cfg.background.strokeColor = .white
cfg.background.strokeWidth = 1
cfg.background.backgroundColor = .clear
cfg.contentInsets = NSDirectionalEdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16)
button.configuration = cfg
}
}