Widget 的生命周期短暂且不可控,数据获取可能随时失败(网络不可达、数据源为空、权限不足等)。一个健壮的 Widget 需要优雅地处理各种异常状态,而不是直接显示空白或崩溃。
本篇将讲解 Widget 中常见异常状态的分类、设计模式和实战实现。
1. Widget 的可能状态
| 状态 | 描述 | 典型场景 |
|---|---|---|
| 加载中 | 数据正在获取中 | 首次添加到桌面时 |
| 正常展示 | 数据获取成功,正常展示 | 常规状态 |
| 数据为空 | 请求成功但无数据 | 待办列表为空、无新消息 |
| 数据过期 | 缓存数据已超过 TTL,但网络不可用 | 网络断开、服务端故障 |
| 网络错误 | Timeline 构建时网络请求失败 | 飞行模式、API 不可达 |
| 权限不足 | 缺少必要权限(如位置、健康数据) | 用户拒绝授权 |
| 系统错误 | Widget 进程被终止、内存超限等 | 系统资源紧张 |
| 未配置 | 用户还未完成 Widget 配置 | 可配置 Widget 首次添加 |
2. 在 Entry 中表达状态
swift
enum WidgetState: Equatable {
case loading
case success
case empty
case error(errorType: WidgetErrorType, message: String)
case needsConfiguration
}
enum WidgetErrorType {
case network
case permissionDenied
case unknown
}
struct AdaptiveEntry: TimelineEntry {
let date: Date
let state: WidgetState
let content: WidgetContent? // .success 时才有值
}
3. 在 Provider 中构建状态 Entry
swift
func timeline(for configuration: Intent, in context: Context) async -> Timeline<AdaptiveEntry> {
let now = Date()
// 检查权限
guard hasRequiredPermissions() else {
let entry = AdaptiveEntry(date: now, state: .error(errorType: .permissionDenied, message: "需要位置权限"), content: nil)
let retry = Calendar.current.date(byAdding: .minute, value: 30, to: now)!
return Timeline(entries: [entry], policy: .after(retry))
}
// 尝试获取数据
do {
let data = try await fetchData()
if data.items.isEmpty {
let entry = AdaptiveEntry(date: now, state: .empty, content: nil)
let retry = Calendar.current.date(byAdding: .minute, value: 15, to: now)!
return Timeline(entries: [entry], policy: .after(retry))
}
let entry = AdaptiveEntry(date: now, state: .success, content: WidgetContent(items: data.items))
let retry = Calendar.current.date(byAdding: .minute, value: 30, to: now)!
return Timeline(entries: [entry], policy: .after(retry))
} catch let error as URLError {
// 网络错误,尝试使用缓存
if let cached = loadFromCache() {
let entry = AdaptiveEntry(date: now, state: .success, content: cached)
let retry = Calendar.current.date(byAdding: .minute, value: 10, to: now)!
return Timeline(entries: [entry], policy: .after(retry))
}
let entry = AdaptiveEntry(date: now, state: .error(errorType: .network, message: error.localizedDescription), content: nil)
let retry = Calendar.current.date(byAdding: .minute, value: 5, to: now)!
return Timeline(entries: [entry], policy: .after(retry))
} catch {
let entry = AdaptiveEntry(date: now, state: .error(errorType: .unknown, message: error.localizedDescription), content: nil)
let retry = Calendar.current.date(byAdding: .minute, value: 5, to: now)!
return Timeline(entries: [entry], policy: .after(retry))
}
}
4. 在视图中渲染不同状态
swift
struct AdaptiveWidgetView: View {
var entry: AdaptiveEntry
var body: some View {
switch entry.state {
case .loading:
loadingView
case .success:
contentView(content: entry.content!)
case .empty:
emptyView
case .error(let type, let message):
errorView(type: type, message: message)
case .needsConfiguration:
configurationPromptView
}
}
// 加载视图
private var loadingView: some View {
VStack(spacing: 8) {
ProgressView()
.scaleEffect(0.8)
Text("加载中...")
.font(.caption)
.foregroundColor(.secondary)
}
}
// 空数据视图
private var emptyView: some View {
VStack(spacing: 8) {
Image(systemName: "tray")
.font(.title2)
.foregroundColor(.secondary)
Text("暂无数据")
.font(.caption)
.foregroundColor(.secondary)
}
}
// 错误视图
private func errorView(type: WidgetErrorType, message: String) -> some View {
VStack(spacing: 6) {
Image(systemName: iconForError(type))
.font(.title2)
.foregroundColor(.orange)
Text(message)
.font(.caption2)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
.lineLimit(2)
}
.padding(.horizontal, 4)
}
// 配置提示视图
private var configurationPromptView: some View {
VStack(spacing: 6) {
Image(systemName: "gearshape")
.font(.title2)
.foregroundColor(.accentColor)
Text("长按编辑以选择内容")
.font(.caption)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
}
}
// 正常内容视图
private func contentView(content: WidgetContent) -> some View {
VStack(alignment: .leading, spacing: 4) {
Text(content.title)
.font(.headline)
ForEach(content.items.prefix(3)) { item in
Text(item.name)
.font(.caption)
.lineLimit(1)
}
}
}
private func iconForError(_ type: WidgetErrorType) -> String {
switch type {
case .network: return "wifi.slash"
case .permissionDenied: return "lock.shield"
case .unknown: return "exclamationmark.triangle"
}
}
}
5. 不同尺寸的差异化错误处理
swift
@ViewBuilder
func adaptiveBody(for family: WidgetFamily, entry: AdaptiveEntry) -> some View {
switch family {
case .systemSmall:
// 小屏:精简展示,只显示图标
compactStateView(entry: entry)
case .systemMedium, .systemLarge:
// 中大屏:可以展示更多信息
fullStateView(entry: entry)
case .accessoryCircular:
// 锁屏圆形:极简处理
if case .success = entry.state {
Image(systemName: "checkmark")
} else {
Image(systemName: "xmark")
}
default:
fullStateView(entry: entry)
}
}
6. 降级策略与内置兜底
始终为本地的 placeholder 和 getSnapshot 提供合理的数据兜底:
swift
func placeholder(in context: Context) -> AdaptiveEntry {
AdaptiveEntry(
date: Date(),
state: .loading,
content: WidgetContent(title: "---", items: [])
)
}
func snapshot(for configuration: Intent, in context: Context) async -> AdaptiveEntry {
// 优先使用缓存
if let cached = loadFromCache() {
return AdaptiveEntry(date: Date(), state: .success, content: cached)
}
// 兜底:展示空状态而非报错
return AdaptiveEntry(date: Date(), state: .empty, content: nil)
}
7. 设计原则总结
| 原则 | 说明 |
|---|---|
| 永远有余地 | 每个状态都要有对应的视图,不给用户展示空白 |
| 失败要可恢复 | 错误状态使用较短的刷新间隔(5-10 分钟),快速重试 |
| 缓存是生命线 | 网络请求失败时优先使用缓存,而不是直接展示错误 |
| 体面降级 | 网络 → 缓存 → 默认值 → 错误提示,逐级降级 |
| 信息有区分度 | 不同错误类型给出有意义的提示,而不是通用的"出错了" |
| 避免恐慌 | 错误状态下不需要红色警告图标,使用 secondary 色调 |
小结
- 使用
enum WidgetState让 Entry 携带明确的状态信息 - 每种状态都需要设计对应的视图模板
- 在 Provider 中做好分层降级:网络 → 缓存 → 默认 → 错误
- 错误状态下使用较短的刷新间隔以便尽快恢复
- 小尺寸下的错误展示要更精简