UserDefaults and Observation in SwiftUI: How to Achieve Precise Responsiveness

Published on

Get weekly handpicked updates on Swift and SwiftUI!

In SwiftUI, Apple’s @AppStorage property wrapper greatly simplifies the process for developers to respond to and modify UserDefaults content within views. However, with the introduction of the Observation framework, new challenges have arisen—Apple has yet to provide a UserDefaults management solution for Observation. This article will explore how to efficiently and conveniently manage data in UserDefaults under the Observation framework and propose a complete and practical solution.

@AppStorage and ObservableObject: Advantages and Limitations

@AppStorage provides SwiftUI developers with an efficient way to respond to and edit individual UserDefaults key-values. However, when managing multiple values on the same page, introducing each key-value individually can lead to bloated code and increase the risk of misspelling key names.

Fortunately, @AppStorage and @Published have similar mechanisms. This allows us to encapsulate multiple @AppStorage in an ObservableObject to achieve unified management and response:

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

// Use in view
@StateObject var defaults = Defaults()
...
Text(defaults.name)
TextField("name", text: defaults.$name)

However, the notification mechanism of ObservableObject has limitations: any change in the values wrapped within it (marked with @Published or @AppStorage) will trigger a redraw of the entire view.

The introduction of the Observation framework brings hope to solve the imprecise notification problem of ObservableObject. Unfortunately, Apple has not yet provided a UserDefaults packaging solution suitable for Observation.

Please read Mastering @AppStorage in SwiftUI for more details.

Using UserDefaults in Observable: Challenges and Limitations

Some readers may point out that responding to and modifying UserDefaults in the Observation framework doesn’t seem complicated—just rebuild the get and set methods. Here is an example implementation:

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))"
        }
    }
}

The basic logic of this implementation is: in the get method, register the observer via access and retrieve data from UserDefaults; in the set method, save data to UserDefaults and notify observers of data changes via withMutation. This is similar to the code generated by the @Observable macro, except that the data storage location is changed from an internal private variable to UserDefaults.

You can find many similar implementations online; some developers have even created corresponding macros to automatically generate such code. However, this method has a major flaw: it can only respond to modifications made through the same observable instance and cannot capture changes made to the UserDefaults content from outside the instance, even if the current instance has the corresponding key. For example:

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") {
                // Will not respond to direct modifications of UserDefaults
                UserDefaults.standard.set("\(Int.random(in: 0 ... 1000))", forKey: "name")
            }
        }
        .buttonStyle(.bordered)
    }
}

This limitation severely affects the practicality of UserDefaults. As a widely used observer pattern representative in the Apple ecosystem, if the provided solution cannot handle modifications from different channels, it is obviously unacceptable. This is also why I have been hesitant to adopt this approach for a long time.

Triggering Notifications from Outside an Observable Instance

For an ObservableObject instance, developers can notify all its subscribers from outside through its objectWillChange (ObservableObjectPublisher) property. The only drawback is that subscribers cannot determine which specific property has changed.

The Observation framework actually provides a similar mechanism but is not exposed externally to the Observable instance:

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

However, we can invoke ObservationRegistrar through a proxy method to notify observers of specific properties:

Swift
var observationRegistrar: ObservationRegistrar {
    _$observationRegistrar
}

Button("Modify UserDefaults Directly") {
    UserDefaults.standard.set("\(Int.random(in: 0 ... 1000))", forKey: "name")
    // After saving, notify all subscribers of the 'name' property
    settings.observationRegistrar.withMutation(of: settings, keyPath: \.name) {}
}

According to the principles of the Observation framework, this notification behavior should occur in willSet. But to avoid delays in writing to UserDefaults, we adjusted the order. With this modification, the observers of the name property in the observable instance will also be notified and the view will be redrawn after modifying the name value in UserDefaults from outside the instance.

For an in-depth understanding of the working principles and usage tips of Observation, please refer to the article A Deep Dive Into Observation: A New Way to Boost SwiftUI Performance.

Readers may consider using Publisher to automate the above behavior. In the example below, we temporarily do not filter the notifications from UserDefaults, assuming that notifications only come from modifications of the name property:

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) {}
}

Obviously, as long as we centralize the logic of responding to, filtering, and triggering observationRegistrar to notify observers of UserDefaults notifications, and implement it within the observable object, we can completely solve the previous pain points. This method can respond to external modifications and achieve precise notifications, reducing view redraws.

@ObservableDefaults: A UserDefaults Integration Solution for the Observation Era

Although there are already many macros available for Observable declarations online, most cannot respond to external modifications of UserDefaults. Considering that my current project needs this, I had to roll up my sleeves and create a more complete solution.

The @ObservableDefaults macro not only has all the features of @Observable but also makes further enhancements. Unless specifically marked by the developer, all declared stored variables will automatically associate with UserDefaults keys and can respond to modifications of UserDefaults content from any channel.

You can download the ObservableDefaults library here.

After using @ObservableDefaults, the code will be greatly simplified, for example:

Swift
import ObservableDefaults

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

Obviously, @ObservableDefaults reduces a lot of development work.

In addition to the @ObservableDefaults macro, the library also provides several other practical macros:

  • @ObservableOnly: Retains only the Observable feature and does not persist the value to UserDefaults.
  • @Ignore: Neither observe nor persist, keeping the original state.
  • @DefaultsKey: Specifies the UserDefaults key name corresponding to the property; by default, the property name is used as the key name.
Swift
@ObservableDefaults
class Settings {
    @DefaultsKey(userDefaultsKey: "fullName")
    var name: String = "Fatbobman"

    @ObservableOnly
    var age: Int = 20

    @Ignore
    var city: String = "Dalian"
}

If all properties have default values, you can directly use the automatically generated initializer, which will automatically start listening to external modifications of UserDefaults.

Swift
// Automatically constructed by macro
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)
    }
}

Developers can not only set the UserDefaults instance, key name prefix, and other information through the initializer but also directly set them via the @ObservableDefaults macro parameters:

  • userDefaults: UserDefaults instance.
  • ignoreExternalChanges: Whether to ignore external modifications to UserDefaults. When not used for views or when ensuring all modifications are made through the same instance, this option can be enabled. The default value is false, meaning it will respond to external modifications.
  • prefix: Key name prefix for UserDefaults, default is empty. If a prefix is set, the key name will be prefix + property name. The prefix should not contain the ’.’ character.
  • autoInit: Whether to automatically generate an initializer, default is true.
Swift
@State var settings: Settings = Settings(userDefaults: .standard, ignoreExternalChanges: false, prefix: "myApp_")
// Or
@ObservableDefaults(autoInit: false, ignoreExternalChanges: true, suiteName: nil, prefix: "myApp_")
class Settings {
    @DefaultsKey(userDefaultsKey: "fullName")
    var name: String = "Fatbobman"
}

If the initializer and macro parameters provide the same configuration items simultaneously, the parameters in the initializer have higher priority.

It is particularly important to note that if you choose to create the initializer yourself (autoInit = false), you must explicitly start listening to UserDefaults in the initializer to respond to external modifications.

Swift
init() {
   // Start listening
   observerStarter()
}

Although @ObservableDefaults has all the features of @Observable, I still recommend focusing it on managing UserDefaults data rather than replacing @Observable as the macro for generating the main state container of the application. The precise notification mechanism of the Observation framework allows us to manage states of different functions in multiple independent instances while conveniently integrating them.

Swift
@Observable
class AppState {
    var selection = 10
    var isLogin = false
    let settings = Settings() // Placed in a separate instance
}

@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: A Love-Hate Development Experience

Although I have written some simple Swift macros before, I still encountered many challenges when implementing a complex project that requires multiple macros to work together. These challenges are mainly reflected in the following aspects:

  1. Strict Sandbox Mechanism: Swift macros adopt an extremely safe processing method, making it difficult for macros within the same library to exchange data. Developers need to deeply understand the characteristics and functions of each macro. The new code and new types created by macros must strictly follow their execution order and rules to recognize each other and be used by other macros.

  2. Complex Debugging: Compared to standard Swift projects, the debugging process of macros is more tortuous. The more complex the macro, the more difficult the debugging. Although some third-party libraries can simplify the testing process, there is still a lack of efficient debugging tools, making problem positioning difficult and severely affecting development efficiency.

  3. Challenges in Code Formatting: When dealing with multi-line string arrays and adding them to the code, you need to set the indentation for elements at different positions separately, even if these elements are basically the same. As the code increases, the places that need to be adjusted manually also increase.

  4. Learning Curve of Swift Syntax: Swift macros are closely tied to the swift-syntax version, and the syntax differences between versions increase the learning difficulty. Most developers are not familiar with syntax parsing, and this method of constructing new code based on syntax trees, although improving safety, also poses higher requirements for developers. Although the current knowledge base may not be updated in time, mainstream AI services can still be a powerful assistant to help developers complete syntax tree parsing.

Despite these challenges, Swift macros are undoubtedly a powerful tool, injecting new vitality into the Swift ecosystem. Proficient use of macros can significantly improve development efficiency and reduce the mental burden on developers.

Conclusion

Since its introduction at WWDC 2023, the Observation framework has been increasingly favored by developers, especially in the SwiftUI development community. How to effectively utilize this framework and fully exploit its potential has become an important issue for every developer. The solutions and insights provided in this article hope to offer useful references for developers in their exploration in this field.

Weekly Swift & SwiftUI insights, delivered every Monday night. Join developers worldwide.
Easy unsubscribe, zero spam guaranteed