SwiftUI Preferences 完全指南:从“向上传值”到 Swift 6 并发安全

为什么需要 Preferences?

在 SwiftUI 里,向下传值有 @State@Binding@Environment,但向上传值一直是个空白。

典型痛点:

  1. 深层嵌套子视图想告诉祖先"我有多高"、"我是否出错"
  2. 如果逐层传递 Binding,会出现 Prop-Drilling(属性钻井)噩梦
  3. 祖先并不关心中间层,只想聚合子孙的信息

SwiftUI 给出的答案就是 Preferences:"子视图把数据塞进信封,祖先统一签收。"

官方已默默用它实现 navigationTitlepreferredColorSchemetabItem 等我们每天都在用的 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",但目前两种写法均官方合法。

进阶技巧合集

  1. 层级覆盖策略

reduce 决定"谁说了算"。

例如 preferredColorScheme 采用"后者覆盖前者"策略:

swift 复制代码
static func reduce(value: inout Value, nextValue: () -> Value) {
    value = nextValue()   // 直接覆盖
}

想实现"最深层优先"就把新值放后面;想"最祖先优先"就保留旧值。

  1. 一次性收集几何信息
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)
    }
)

祖先即可拿到"实际渲染尺寸",常用于瀑布流、瀑布布局。

  1. 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 偷窥,但不建议,随时被苹果改。

性能 & 调试小贴士

  1. Preference 传播是懒加载

    只有当祖先订阅了 onPreferenceChange 才会触发 reduce,所以不用担心子视图过多导致瞬间爆炸。

  2. 调试打印

    reduce 里加 print 即可观察传播顺序:

swift 复制代码
   static func reduce(value: inout [String], nextValue: () -> [String]) {
       let new = nextValue()
       print(#function, "current:", value, "next:", new)
       value.append(contentsOf: new)
   }
  1. 巨量数据请用 Equatable 优化

    只要 Value 遵守 Equatable,SwiftUI 会自动 diff,避免重复刷新。

总结 & checklist

Preferences 是 SwiftUI 唯一原生、官方、线程安全的"向上传值"机制。

当你遇到以下场景,请第一时间想到它:

  • 子视图想告诉祖先"我有多高 / 我出错 / 我要标题"
  • 祖先需要聚合多个子孙的信息
  • 不想引入 ObservableObjectBinding 钻井
  • Swift 6 并发模式,需要编译器帮你排雷

口诀:"子写信,父收信,reduce 管合并;Swift 6 改 let,闭包加 @MainActor。"

相关推荐
东坡肘子4 小时前
苹果正在为系统级支持 MCP 做准备 | 肘子的 Swift 周报 #0104
swiftui·swift·apple
Dream_Ji21 小时前
Swift 入门(一 - 基础语法)
开发语言·ios·swift
HarderCoder1 天前
async let 也能调度同步函数?——Swift 并发隐藏小技巧详解
swiftui·swift
HarderCoder2 天前
深入理解 SwiftUI 中的 `@Observable` 与 `@Bindable`:从原理到实践
swiftui·swift
00后程序员张3 天前
iOS 26 兼容测试实战,机型兼容、SwiftUI 兼容性改动
android·ios·小程序·uni-app·swiftui·cocoa·iphone
大熊猫侯佩4 天前
雪山飞狐之 Swift 6.2 并发秘典:@concurrent 的江湖往事
swiftui·swift·apple
胎粉仔6 天前
Objective-C —— APIs declaration 自定义
ios·objective-c·swift
用户096 天前
Swift Concurrency 中的 Threads 与 Tasks
ios·swiftui·swift