SwiftUI 中的 UserDefaults 与 Observation:如何实现精准响应

发表于

为您每周带来有关 Swift 和 SwiftUI 的精选资讯!

在 SwiftUI 中,苹果提供的 @AppStorage 属性包装器极大地简化了开发者在视图中响应和修改 UserDefaults 内容的过程。然而,随着 Observation 框架的引入,这一领域出现了新的挑战——苹果尚未为 Observation 提供相应的 UserDefaults 管理方案。本文将探讨如何在 Observation 框架下高效且便捷地管理 UserDefaults 中的数据,并提出一个完整而实用的解决方案。

AppStorage 与 ObservableObject:优势与局限

@AppStorage 为 SwiftUI 开发者提供了一种高效响应和编辑单个 UserDefaults 键值的方法。然而,当需要在同一页面中管理多个值时,单独引入每个键值可能导致代码臃肿,并增加键名拼写错误的风险。

幸运的是,@AppStorage@Published 具备相似的机制。这使得我们可以将多个 @AppStorage 封装在一个 ObservableObject 中,实现统一管理和响应:

Swift
class Defaults: ObservableObject {
    @AppStorage("name") public var name = "fatbobman"
    @AppStorage("age") public var age = 12
}

// 在视图中使用
@StateObject var defaults = Defaults()
...
Text(defaults.name)
TextField("name", text: defaults.$name)

然而,ObservableObject 的通知机制存在局限性:任何包装其中的值(使用 @Published@AppStorage 标注)发生变化都会触发整个视图的重绘。

Observation 框架的引入为解决 ObservableObject 通知不精准的问题带来了希望。遗憾的是,苹果尚未提供适用于 Observation 的 UserDefaults 包装方案。

请阅读 @AppStorage 研究 了解更多细节。

在 Observable 中使用 UserDefaults:挑战与局限

有读者可能会指出,在 Observation 框架中响应并修改 UserDefaults 似乎并不复杂,只需重新构建 getset 方法即可。以下是一个示例实现:

Swift
@Observable
class Settings {
    @ObservationIgnored
    var name: String {
        get {
            access(keyPath: \.name)
            return UserDefaults.standard.string(forKey: "name") ?? _nameDefault
        }
        set {
            withMutation(keyPath: \.name) {
                UserDefaults.standard.set(newValue, forKey: "name")
            }
        }
    }

    @ObservationIgnored
    let _nameDefault: String = "Fatbobman 1"
}

struct SettingTestView: View {
    @State var settings: Settings = .init()
    var body: some View {
        Text(settings.name)
        Button("Change Name") {
            settings.name = "Fatbobman \(Int.random(in: 0 ... 1000))"
        }
    }
}

这种实现的基本逻辑是:在 get 方法中通过 access 注册观察者并从 UserDefaults 获取数据,在 set 方法中将数据保存到 UserDefaults 并通过 withMutation 通知观察者数据变化。这与 @Observable 宏生成的代码原理相似,只是将数据存储位置从内部私有变量改为了 UserDefaults。

网上可以找到许多类似的实现,一些开发者甚至创建了相应的宏来自动生成这类代码。然而,这种方法存在一个重大缺陷:它只能响应通过同一个可观察实例进行的修改,而无法捕捉到从实例外部对 UserDefaults 内容的更改,即使当前实例中存在对应的键。例如:

Swift
struct SettingTestView: View {
    @State var settings: Settings = .init()
    var body: some View {
        VStack(spacing: 30) {
            Text(settings.name)
            Button("Modify Instance Property") {
                settings.name = "Fatbobman \(Int.random(in: 0 ... 1000))"
            }
            Button("Modify UserDefaults Directly") {
                // 不会对直接修改 UserDefaults 的操作进行响应
                UserDefaults.standard.set("\(Int.random(in: 0 ... 1000))", forKey: "name")
            }
        }
        .buttonStyle(.bordered)
    }
}

这个局限性严重影响了 UserDefaults 的实用价值。作为 Apple 生态系统中广泛使用的观察者模式代表,如果提供的方案无法应对来自不同渠道的修改,显然是不可接受的。这也是长期以来,我一直犹豫采用这种方式的原因。

从 Observable 实例外触发通知

对于 ObservableObject 实例,开发者可以通过其 objectWillChangeObservableObjectPublisher)属性从外部通知该实例的所有订阅者。唯一的缺陷是,订阅者无法确定具体哪个属性发生了变化。

Observation 框架实际上提供了类似的机制,但未对 Observable 实例外部公开:

Swift
@ObservationIgnored private let _$observationRegistrar = Observation.ObservationRegistrar()

然而,我们可以通过转接方式调用 ObservationRegistrar,从而向特定属性的观察者发出通知:

Swift
var observationRegistrar: ObservationRegistrar {
    _$observationRegistrar
}

Button("Modify UserDefaults Directly") {
    UserDefaults.standard.set("\(Int.random(in: 0 ... 1000))", forKey: "name")
    // 在保存后,通知所有 name 属性的订阅者
    settings.observationRegistrar.withMutation(of: settings, keyPath: \.name){}
}

按照 Observation 框架的原则,这个通知行为应在 willSet 时完成。但为避免写入 UserDefaults 出现延迟,我们调整了顺序。经此改动,可观察实例的 name 属性观察者也将在实例外对 UserDefaults 中 name 值进行修改后收到通知,并重绘视图。

深入了解 Observation 的工作原理和使用技巧,请参阅 深度解读 Observation —— SwiftUI 性能提升的新途径 一文。

读者可能会考虑使用 Publisher 来自动化上述行为。在下面的示例中,我们暂不对 UserDefaults 的通知进行筛选,假设通知仅来自 name 属性的修改:

Swift
Button("Modify UserDefaults Directly") {
    UserDefaults.standard.set("Fatbobman \(Int.random(in: 0 ... 1000))", forKey: "name")
}
.onReceive(NotificationCenter.default.publisher(for: UserDefaults.didChangeNotification)){ _ in
    print("received user defaults notification")
    settings.observationRegistrar.withMutation(of: settings, keyPath: \.name){}
}

显然,只要我们将对 UserDefaults 通知的响应、筛选以及触发 observationRegistrar 通知观察者的逻辑集中到一起,并在可观察对象内部实现,就可以彻底解决之前的痛点。这种方法既能响应外部修改,又能实现精准通知,减少视图重绘。

@ObservableDefaults:适用于 Observation 时代的 UserDefaults 整合方案

虽然网络上已经有不少用于 Observable 声明的宏,但大多无法响应外部对 UserDefaults 的修改。考虑到我目前的项目对此有需求,只能自己动手,打造一个更加完善的解决方案。

@ObservableDefaults 宏不仅具备 @Observable 的所有功能,还做了进一步增强。除非开发者特意标记属性,否则所有声明的存储变量都会自动关联到 UserDefaults 的键,并且能够响应来自任何渠道对 UserDefaults 内容的修改。

你可以在 此处下载 ObservableDefaults 库。

使用 @ObservableDefaults 后,代码将大大简化,例如:

Swift
import ObservableDefaults

@ObservableDefaults
class Settings {
    var name: String = "Fatbobman"
    var age: Int = 20
}

显而易见,@ObservableDefaults 减少了大量开发工作。

除了 @ObservableDefaults 宏之外,该库还提供了其他几个实用的宏:

  • @ObservableOnly:只保留 Observable 特性,不将值持久化到 UserDefaults 中。
  • @Ignore:不进行观察也不进行持久化,保持原始状态。
  • @DefaultsKey:指定属性对应的 UserDefaults 键名,默认使用属性名作为键名。
Swift
@ObservableDefaults
class Settings {
    @DefaultsKey(userDefaultsKey: "fullName")
    var name: String = "Fatbobman"

    @ObservableOnly
    var age: Int = 20

    @Ignore
    var city: String = "Dalian"
}

如果所有属性都有默认值,可以直接使用自动生成的构造方法,该构造方法会自动启动对外部 UserDefaults 修改的监听。

Swift
// 有宏自动构建
public init(
    userDefaults: Foundation.UserDefaults? = nil,
    ignoreExternalChanges: Bool? = nil,
    prefix: String? = nil
) {
    if let userDefaults {
        _userDefaults = userDefaults
    }
    if let ignoreExternalChanges {
        _isExternalNotificationDisabled = ignoreExternalChanges
    }
    if let prefix {
        _prefix = prefix
    }
    assert(!_prefix.contains("."), "Prefix '\(_prefix)' should not contain '.' to avoid KVO issues!")
    if !_isExternalNotificationDisabled {
        observer = DefaultsObservation(host: self, userDefaults: _userDefaults, prefix: _prefix)
    }
}

开发者除了可以通过构造方法来设置 UserDefaults 实例、键名前缀等信息外,还可以直接通过 @ObservableDefaults 宏参数进行设置:

  • userDefaults: UserDefaults 实例。
  • ignoreExternalChanges: 是否忽略外部对 UserDefaults 的修改。当不用于视图时,或者确保所有修改都通过同一个实例进行时可以启用此选项,默认值为 false,即会响应外部修改。
  • prefix: UserDefaults 的键名前缀,默认为空。如果设置了前缀,键名将为 prefix + 属性名。前缀中不能包含 ’.’ 字符。
  • autoInit: 是否自动生成构造方法,默认为 true
Swift
@State var settings: Settings = Settings(userDefaults: .standard, ignoreExternalChanges: false, prefix: "myApp_")
// 或
@ObservableDefaults(autoInit: false, ignoreExternalChanges: true, suiteName: nil, prefix: "myApp_")
class Settings {
    @DefaultsKey(userDefaultsKey: "fullName")
    var name: String = "Fatbobman"
}

如果构造方法和宏参数同时提供相同的配置项,则构造方法中的参数优先级更高。

特别需要注意的是,如果选择自行创建构造方法(autoInit = false),必须在构造方法中显式启动对 UserDefaults 的监听,才能响应外部的修改。

Swift
init() {
   // 启动监听
   observerStarter()
}

虽然 @ObservableDefaults 具备了 @Observable 的所有功能,但我仍然建议将其专注于管理 UserDefaults 数据,而不是替代 @Observable 作为应用的主要状态容器的生成宏。Observation 框架精准的通知机制让我们可以将不同功能的状态分散到多个独立的实例中管理,同时又能方便地进行整合。

Swift
@Observable
class AppState {
    var selection = 10
    var isLogin = false
    let settings = Settings() // 放置到独立的实例中,
}

@ObservableDefaults
class Settings {
    var name: String = "Fatbobman"
}

struct SettingTestView: View {
    @State var state = AppState()
    var body: some View {
        VStack(spacing: 30) {
            Text(state.settings.name)
            Button("Modify Instance Property") {
                state.settings.name = "Fatbobman \(Int.random(in: 0 ... 1000))"
            }
            Button("Modify UserDefaults Directly") {
                UserDefaults.standard.set("Fatbobman \(Int.random(in: 0 ... 1000))", forKey: "name")
            }
        }
        .buttonStyle(.bordered)
    }
}

Swift Macro:爱恨交织的开发体验

尽管我曾编写过一些简单的 Swift 宏,但在实现一个需要多种宏协同工作的复杂项目时,仍然遇到了不少挑战。这些挑战主要体现在以下几个方面:

  1. 严格的沙盒机制:Swift 宏采用了极为安全的处理方式,即便是同一库中的宏也难以进行数据交换。开发者需要深入理解每种宏的特性和功能。通过宏创建的新代码和新类型必须严格遵循其运行顺序和规则,才能实现互相认可和被其他宏使用。
  2. 调试的复杂性:相比标准 Swift 项目,宏的调试过程更加曲折。宏的复杂度越高,调试难度就越大。虽然一些第三方库可以简化测试过程,但仍缺乏高效的调试工具,这导致问题定位困难,严重影响开发效率。
  3. 代码格式的挑战:在处理多行字符串数组并将其添加到代码中时,需要为不同位置的元素单独设置缩进,即使这些元素基本相同。随着代码量增加,需要手动调整的地方也随之增多。
  4. Swift Syntax 的学习曲线:Swift 宏与 swift-syntax 版本紧密相连,不同版本间的语法差异增加了学习难度。大多数开发者对 Syntax 解析并不熟悉,这种基于语法树构建新代码的方法虽然提高了安全性,但也对开发者提出了更高要求。尽管当前的知识库可能未及时更新,但主流 AI 服务仍可作为有力助手,协助开发者完成语法树解析。

尽管面临这些挑战,Swift 宏无疑是一项强大的工具,为 Swift 生态系统注入了新的活力。熟练运用宏可以显著提升开发效率,减轻开发者的心智负担。

结语

自 WWDC 2023 推出以来,Observation 框架日益受到开发者青睐,特别是在 SwiftUI 开发社区中。如何有效利用这个框架,充分发挥其潜力,成为每位开发者面临的重要课题。本文提供的解决方案和见解,希望能为开发者在这一领域的探索提供有益参考。

每周一晚,与全球开发者同步,掌握 Swift & SwiftUI 最新动向
可随时退订,干净无 spam