为什么需要 Preferences?
在 SwiftUI 里,向下传值有 @State
→ @Binding
→ @Environment
,但向上传值一直是个空白。
典型痛点:
- 深层嵌套子视图想告诉祖先"我有多高"、"我是否出错"
- 如果逐层传递
Binding
,会出现 Prop-Drilling(属性钻井)噩梦 - 祖先并不关心中间层,只想聚合子孙的信息
SwiftUI 给出的答案就是 Preferences:"子视图把数据塞进信封,祖先统一签收。"
官方已默默用它实现 navigationTitle
、preferredColorScheme
、tabItem
等我们每天都在用的 API。
核心概念速览
名词 | 作用 | 类比 |
---|---|---|
PreferenceKey |
定义"信封"格式 | 字典的 key |
.preference(key:value:) |
子视图写信 | 塞进信封 |
.onPreferenceChange(_:perform:) |
祖先收信 | 读信封 |
reduce |
多封信合并策略 | 合并冲突规则 |
自定义 Preference
目标:做一个表单错误统计,子字段把错误信息抛给外层,外层统一显示"共 3 处错误"。
定义信封
swift
struct ValidationMessagesKey: PreferenceKey {
static let defaultValue: [String] = [] // ① Swift 6 必须用 let
static func reduce(
value: inout [String], // ② 累加容器
nextValue: () -> [String] // ③ 子节点新值
) {
value.append(contentsOf: nextValue()) // ④ 合并策略:追加
}
}
子视图写信
swift
struct ValidatedField: View {
@Binding var text: String
let rules: [ValidationRule]
private var error: String? {
rules.lazy.compactMap { $0.validate(text) }.first
}
var body: some View {
VStack(alignment: .leading) {
TextField("Input", text: $text)
.textFieldStyle(.roundedBorder)
if let error {
Text(error)
.font(.caption)
.foregroundColor(.red)
}
}
// ⑤ 把局部错误抛上去
.preference(key: ValidationMessagesKey.self,
value: error.map { [$0] } ?? [])
}
}
祖先收信
swift
struct SignupForm: View {
@State private var username = ""
@State private var password = ""
@State private var errors: [String] = []
var body: some View {
VStack(spacing: 16) {
if !errors.isEmpty {
Label("\(errors.count) 处错误", systemImage: "exclamationmark.triangle")
.foregroundColor(.red)
}
ValidatedField(text: $username, rules: [.minLength(3)])
ValidatedField(text: $password, rules: [.minLength(8), .complex])
}
// ⑥ 收信并刷新 UI
.onPreferenceChange(ValidationMessagesKey.self) { new in
errors = new
}
}
}
运行效果:
用户在第一个字段输入"ab"→ 立即显示"1 处错误";再输入密码"123"→ 显示"2 处错误"。
全程零 Binding 钻井,子视图完全解耦。
Swift 6 并发模式下的坑与官方解法
从 Swift 6 开始,编译器把所有全局可变状态视为潜在数据竞争。
Preferences 刚好踩中两条红线:
报错信息 | 原因 | 官方修复 |
---|---|---|
Static property 'defaultValue' is not concurrency-safe |
static var 被视为共享可变状态 |
把 var 改成 let |
Main actor-isolated property 'errors' can not be mutated... |
onPreferenceChange 的闭包默认 @Sendable 未限定 @MainActor |
① Task { @MainActor in ... } ② 捕获 @Binding 直接改 wrappedValue |
两种写法对比:
swift
// 写法 1:显式切回主线程
.onPreferenceChange(ValidationMessagesKey.self) { messages in
Task { @MainActor in
errors = messages
}
}
// 写法 2:利用 Binding 的 Sendable 能力
.onPreferenceChange(ValidationMessagesKey.self)
{ [binding = $errors] messages in
binding.wrappedValue = messages
}
苹果把后者标记为 @preconcurrency
,意味着"以后会再review",但目前两种写法均官方合法。
进阶技巧合集
- 层级覆盖策略
reduce
决定"谁说了算"。
例如 preferredColorScheme
采用"后者覆盖前者"策略:
swift
static func reduce(value: inout Value, nextValue: () -> Value) {
value = nextValue() // 直接覆盖
}
想实现"最深层优先"就把新值放后面;想"最祖先优先"就保留旧值。
- 一次性收集几何信息
swift
struct SizeKey: PreferenceKey {
static let defaultValue: CGSize = .zero
static func reduce(value: inout CGSize, nextValue: () -> CGSize) {
value = nextValue() // 只保留最后一个
}
}
// 子视图
.background(
GeometryReader { proxy in
Color.clear
.preference(key: SizeKey.self, value: proxy.size)
}
)
祖先即可拿到"实际渲染尺寸",常用于瀑布流、瀑布布局。
- 与
anchorPreference
联动拿到 CGRect
swift
.anchorPreference(key: BoundsKey.self, value: .bounds) { $0 }
再用 .overlayPreferenceValue
即可在祖先层绘制箭头指示器、气泡引导等效果。
官方用 Preferences 实现的 API 速查
修饰符 | 对应 PreferenceKey | 备注 |
---|---|---|
.navigationTitle(_:) |
_NavigationTitleKey |
私有,但可观测 |
.navigationBarTitleDisplayMode(_:) |
_NavigationTitleDisplayModeKey |
私有 |
.preferredColorScheme(_:) |
PreferredColorSchemeKey |
公有,可手动 .preference(key:value:) |
.tabItem(_:) |
_TabItemKey |
私有 |
.badge(_:) |
_TabItemBadgeKey |
私有 |
公有 key 可直接使用,私有 key 可通过 Mirror 偷窥,但不建议,随时被苹果改。
性能 & 调试小贴士
-
Preference 传播是懒加载
只有当祖先订阅了
onPreferenceChange
才会触发reduce
,所以不用担心子视图过多导致瞬间爆炸。 -
调试打印
在
reduce
里加print
即可观察传播顺序:
swift
static func reduce(value: inout [String], nextValue: () -> [String]) {
let new = nextValue()
print(#function, "current:", value, "next:", new)
value.append(contentsOf: new)
}
-
巨量数据请用
Equatable
优化只要
Value
遵守Equatable
,SwiftUI 会自动 diff,避免重复刷新。
总结 & checklist
Preferences 是 SwiftUI 唯一原生、官方、线程安全的"向上传值"机制。
当你遇到以下场景,请第一时间想到它:
- 子视图想告诉祖先"我有多高 / 我出错 / 我要标题"
- 祖先需要聚合多个子孙的信息
- 不想引入
ObservableObject
或Binding
钻井 - Swift 6 并发模式,需要编译器帮你排雷
口诀:"子写信,父收信,reduce 管合并;Swift 6 改 let,闭包加 @MainActor。"