启动期错误态 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 /// splash modal alert NWPathMonitor
/// raw error print Xcode console 便 /// raw error print Xcode console 便
///
/// "" runBootPipeline RemoteConfigClient URLSession
/// ** Apple **
/// - /
/// iOS
/// - iOS app
/// - NWPathMonitor
private func presentBootError(_ error: any Error) { private func presentBootError(_ error: any Error) {
let kind = Self.classifyBootError(error) let kind = Self.classifyBootError(error)
print("[Boot] presentBootError kind=\(kind) underlying:", error) print("[Boot] presentBootError kind=\(kind) underlying:", error)
let title: String let title: String
let message: String let message: String
let showSettings: Bool
switch kind { switch kind {
case .networkOffline: case .networkOffline:
title = "当前未联网" title = "当前未联网"
message = "请检查 Wi-Fi 或蜂窝数据后重试。" message = "请检查 Wi-Fi 或蜂窝数据后重试。"
showSettings = true
case .networkTimeout: case .networkTimeout:
title = "网络较慢" title = "网络较慢"
message = "连接超时,请稍后重试。" message = "连接超时,请稍后重试。"
showSettings = false
case .networkCannotReach: case .networkCannotReach:
title = "暂时无法连接服务器" title = "暂时无法连接服务器"
message = "网络似乎不太通畅,请稍后重试,或前往系统设置检查网络权限" message = "网络似乎不太通畅,请稍后重试。"
showSettings = true
case .localResourceMissing: case .localResourceMissing:
title = "资源加载失败" title = "资源加载失败"
message = "请尝试重启 app;若仍未恢复,请重新安装。" message = "请尝试重启 app;若仍未恢复,请重新安装。"
showSettings = false
case .unknown: case .unknown:
title = "启动遇到问题" title = "启动遇到问题"
message = "请稍后重试。" message = "请稍后重试。"
showSettings = false
} }
splash.onRetry = { [weak self] in splash.onRetry = { [weak self] in
Task { @MainActor in await self?.runBootPipeline() } Task { @MainActor in await self?.runBootPipeline() }
} }
splash.onOpenSettings = { splash.showError(title: title, message: message)
if let url = URL(string: UIApplication.openSettingsURLString) {
UIApplication.shared.open(url)
}
}
splash.showError(title: title, message: message, showSettings: showSettings)
// //
// Wi-Fi // Wi-Fi
@@ -543,12 +539,9 @@ private final class SplashOverlay: UIView {
private let errorTitleLabel = UILabel() private let errorTitleLabel = UILabel()
private let errorMessageLabel = UILabel() private let errorMessageLabel = UILabel()
private let retryButton = UIButton(type: .system) private let retryButton = UIButton(type: .system)
private let settingsButton = UIButton(type: .system)
/// splash /// splash
var onRetry: (() -> Void)? var onRetry: (() -> Void)?
///
var onOpenSettings: (() -> Void)?
init() { init() {
super.init(frame: .zero) super.init(frame: .zero)
@@ -614,44 +607,49 @@ private final class SplashOverlay: UIView {
} }
} }
/// label / progress title + message + + /// imageView / label / progress
func showError(title: String, message: String, showSettings: Bool) { /// title + message + "" onRetry
/// +
func showError(title: String, message: String) {
imageView.isHidden = true
label.isHidden = true label.isHidden = true
progressView.isHidden = true progressView.isHidden = true
backgroundColor = .systemBackground
errorTitleLabel.text = title errorTitleLabel.text = title
errorMessageLabel.text = message errorMessageLabel.text = message
settingsButton.isHidden = !showSettings
errorContainer.isHidden = false errorContainer.isHidden = false
} }
/// loading runBootPipeline /// splash + loading runBootPipeline
func hideError() { func hideError() {
errorContainer.isHidden = true errorContainer.isHidden = true
imageView.isHidden = false
label.isHidden = false label.isHidden = false
backgroundColor = .black
} }
private func setupErrorContainer() { private func setupErrorContainer() {
errorTitleLabel.textColor = .white // systemBackground .label / .secondaryLabel
// iOS appearance
errorTitleLabel.textColor = .label
errorTitleLabel.font = .systemFont(ofSize: 18, weight: .semibold) errorTitleLabel.font = .systemFont(ofSize: 18, weight: .semibold)
errorTitleLabel.textAlignment = .center errorTitleLabel.textAlignment = .center
errorTitleLabel.numberOfLines = 1 errorTitleLabel.numberOfLines = 1
errorMessageLabel.textColor = UIColor.white.withAlphaComponent(0.85) errorMessageLabel.textColor = .secondaryLabel
errorMessageLabel.font = .systemFont(ofSize: 14) errorMessageLabel.font = .systemFont(ofSize: 14)
errorMessageLabel.textAlignment = .center errorMessageLabel.textAlignment = .center
errorMessageLabel.numberOfLines = 0 errorMessageLabel.numberOfLines = 0
configureFilledButton(retryButton, title: "重试") var cfg = UIButton.Configuration.filled()
configureOutlineButton(settingsButton, title: "前往系统设置") 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) 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.axis = .vertical
errorContainer.alignment = .center errorContainer.alignment = .center
@@ -660,36 +658,12 @@ private final class SplashOverlay: UIView {
errorContainer.translatesAutoresizingMaskIntoConstraints = false errorContainer.translatesAutoresizingMaskIntoConstraints = false
errorContainer.addArrangedSubview(errorTitleLabel) errorContainer.addArrangedSubview(errorTitleLabel)
errorContainer.addArrangedSubview(errorMessageLabel) errorContainer.addArrangedSubview(errorMessageLabel)
errorContainer.addArrangedSubview(buttonStack) errorContainer.addArrangedSubview(retryButton)
addSubview(errorContainer) addSubview(errorContainer)
NSLayoutConstraint.activate([ NSLayoutConstraint.activate([
retryButton.heightAnchor.constraint(equalToConstant: 40), retryButton.heightAnchor.constraint(equalToConstant: 40),
retryButton.widthAnchor.constraint(greaterThanOrEqualToConstant: 96), retryButton.widthAnchor.constraint(greaterThanOrEqualToConstant: 120)
settingsButton.heightAnchor.constraint(equalToConstant: 40),
settingsButton.widthAnchor.constraint(greaterThanOrEqualToConstant: 130)
]) ])
} }
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
}
} }