fatbobman/observabledefaults
English | [中文](README_zh.md)
Motivation
Managing multiple UserDefaults keys and cloud-synchronized data in SwiftUI can lead to bloated code and increase the risk of errors. While @AppStorage simplifies handling single UserDefaults keys, it doesn't scale well for multiple keys, lacks cloud synchronization capabilities, or offer precise view updates. With the introduction of the Observation framework, there's a need for a comprehensive solution that efficiently bridges both local and cloud storage with SwiftUI's state management.
ObservableDefaults was created to address these challenges by providing a complete data persistence solution. It leverages macros to reduce boilerplate code and ensures that your SwiftUI views respond accurately to changes in both UserDefaults and iCloud data.
For an in-depth discussion on the limitations of @AppStorage and the motivation behind ObservableDefaults, you can read the full article on my blog.
Don't miss out on the latest updates and excellent articles about Swift, SwiftUI, Core Data, and SwiftData. Subscribe to Fatbobman's Swift Weekly and receive weekly insights and valuable content directly to your inbox.
Features
- Dual Storage Support: Seamless integration with both
UserDefaultsandNSUbiquitousKeyValueStore(iCloud) - SwiftUI Observation: Full integration with the SwiftUI Observation framework
- Automatic Synchronization: Properties automatically sync with their respective storage systems
- Cross-Device Sync: Cloud-backed properties automatically synchronize across user's devices
- Precise Notifications: Property-level change notifications, reducing unnecessary view updates
- Development Mode: Testing support without CloudKit container requirements
- Customizable Behavior: Fine-grained control through additional macros and parameters
- Custom Keys and Prefixes: Support for property-specific storage keys and global prefixes
- Codable Support: Complex data persistence for both local and cloud storage
- Optional Type Support: Full support for Optional properties with nil values
Installation
You can add ObservableDefaults to your project using Swift Package Manager:
- In Xcode, go to File > Add Packages...
- Enter the repository URL:
https://github.com/fatbobman/ObservableDefaults - Select the package and add it to your project.
Usage
### UserDefaults Integration with @ObservableDefaults
After importing `ObservableDefaults`, you can annotate your class with `@ObservableDefaults` to automatically manage `UserDefaults` synchronization:
```swift
import ObservableDefaults
@ObservableDefaults
class Settings {
var name: String = "Fatbobman"
var age: Int = 20
var nickname: String? = nil // Optional support
}
```
<https://github.com/user-attachments/assets/469d55e8-7468-44ac-b591-804c40815724>
This macro automatically:
- Associates the `name` and `age` properties with `UserDefaults` keys.
- Listens for external changes to these keys and updates the properties accordingly.
- Notifies SwiftUI views of changes precisely, avoiding unnecessary redraws.
### Cloud Storage Integration with @ObservableCloud
For cloud-synchronized data that automatically syncs across devices, use the `@ObservableCloud` macro:
```swift
import ObservableDefaults
@ObservableCloud
class CloudSettings {
var number = 1
var color: Colors = .red
var style: FontStyle = .style1
var cloudName: String? = nil // Optional support
}
```
<https://github.com/user-attachments/assets/7e8dcf6b-3c8f-4bd3-8083-ff3c4a6bd6b0>
[Demo Code](https://gist.github.com/fatbobman/5ab86c35ac8cee93c8ac6ac4228a28a9)
This macro automatically:
- Associates properties with `NSUbiquitousKeyValueStore` for iCloud synchronization
- Listens for external changes from other devices and updates properties accordingly
- Provides the same precise SwiftUI observation as `@ObservableDefaults`
- Supports development mode for testing without CloudKit container setup
### Using in SwiftUI Views
Both `@ObservableDefaults` and `@ObservableCloud` classes work identically in SwiftUI views:
```swift
import SwiftUI
struct ContentView: View {
@State var settings = Settings() // UserDefaults-backed
@State var cloudSettings = CloudSettings() // iCloud-backed
var body: some View {
VStack {
// Local settings
Text("Name: \(settings.name)")
TextField("Enter name", text: $settings.name)
// Cloud-synchronized settings
Text("Username: \(cloudSettings.username)")
TextField("Enter username", text: $cloudSettings.username)
}
.padding()
}
}
```
### Customizing Behavior with Additional Macros
#### For @ObservableDefaults (UserDefaults)
The library provides additional macros for finer control:
- `@ObservableOnly`: The property is observable but not stored in `UserDefaults`.
- `@Ignore`: The property is neither observable nor stored in `UserDefaults`.
- `@DefaultsKey`: Specifies a custom `UserDefaults` key for the property.
- `@DefaultsBacked`: The property is stored in `UserDefaults` and observable.
- `@DefaultsBacked` does not support `willSet` / `didSet`.
```swift
@ObservableDefaults
public class LocalSettings {
@DefaultsKey(userDefaultsKey: "firstName")
public var name: String = "fat"
public var age = 109 // Automatically backed by UserDefaults
@ObservableOnly
public var height = 190 // Observable only, not persisted
@Ignore
public var weight = 10 // Neither observable nor persisted
}
```
#### For @ObservableCloud (iCloud Storage)
Similar macro support with cloud-specific options:
- `@ObservableOnly`: The property is observable but not stored in `NSUbiquitousKeyValueStore`.
- `@Ignore`: The property is neither observable nor stored.
- `@CloudKey`: Specifies a custom `NSUbiquitousKeyValueStore` key for the property.
- `@CloudBacked`: The property is stored in `NSUbiquitousKeyValueStore` and observable.
- `@CloudBacked` does not support `willSet` / `didSet`.
```swift
@ObservableCloud
public class CloudSettings {
@CloudKey(keyValueStoreKey: "user_display_name")
public var username: String = "Fatbobman"
public var theme: String = "light" // Automatically cloud-backed
@ObservableOnly
public var localCache: String = "" // Observable only, not synced to cloud
@Ignore
public var temporaryData: String = "" // Neither observable nor persisted
}
```
### Initializer and Parameters
#### @ObservableDefaults Parameters
If all properties have default values, you can use the automatically generated initializer:
```swift
public init(
userDefaults: UserDefaults? = nil,
ignoreExternalChanges: Bool? = nil,
prefix: String? = nil
)
```
**Parameters:**
- `userDefaults`: The `UserDefaults` instance to use (default is `.standard`).
- `ignoreExternalChanges`: If `true`, the instance ignores external `UserDefaults` changes (default is `false`).
- `prefix`: A prefix for all `UserDefaults` keys associated with this class.
#### @ObservableCloud Parameters
The cloud version provides similar initialization options:
```swift
public init(
prefix: String? = nil,
syncImmediately: Bool = false,
developmentMode: Bool = false
)
```
**Parameters:**
- `prefix`: A prefix for all `NSUbiquitousKeyValueStore` keys.
- `syncImmediately`: If `true`, forces immediate synchronization after each change.
- `developmentMode`: If `true`, uses memory storage instead of iCloud for testing.
#### Example Usage
```swift
// UserDefaults-backed settings
@State var settings = Settings(
userDefaults: .standard,
ignoreExternalChanges: false,
prefix: "myApp_"
)
// Cloud-backed settings
@State var cloudSettings = CloudSettings(
prefix: "myApp_",
syncImmediately: true,
developmentMode: false
)
```
### Macro Parameters
#### @ObservableDefaults Macro Parameters
You can set parameters directly in the `@ObservableDefaults` macro:
- `userDefaults`: The `UserDefaults` instance to use.
- `ignoreExternalChanges`: Whether to ignore external changes.
- `prefix`: A prefix for `UserDefaults` keys.
- `autoInit`: Whether to automatically generate the initializer (default is `true`).
- `observeFirst`: Observation priority mode (default is `false`).
- `limitToInstance`: Whether to limit observations to the specific UserDefaults instance (default is `true`). Set to `false` for App Group cross-process synchronization.
```swift
@ObservableDefaults(autoInit: false, ignoreExternalChanges: true, prefix: "myApp_")
class Settings {
@DefaultsKey(userDefaultsKey: "fullName")
var name: String = "Fatbobman"
}
// For App Group cross-process synchronization
@ObservableDefaults(
suiteName: "group.myapp",
prefix: "myapp_",
limitToInstance: false
)
class SharedSettings {
var lastUpdate: Date = Date()
}
```
#### @ObservableCloud Macro Parameters
The cloud macro provides similar configuration options:
- `autoInit`: Whether to automatically generate the initializer (default is `true`).
- `prefix`: A prefix for `NSUbiquitousKeyValueStore` keys.
- `observeFirst`: Observation priority mode (default is `false`).
- `syncImmediately`: Whether to force immediate synchronization (default is `false`).
- `developmentMode`: Whether to use memory storage for testing (default is `false`).
```swift
@ObservableCloud(
autoInit: true,
prefix: "myApp_",
observeFirst: false,
syncImmediately: true,
developmentMode: false
)
class CloudSettings {
@CloudKey(keyValueStoreKey: "user_theme")
var theme: String = "light"
}
```
### Development Mode for Cloud Storage
The `@ObservableCloud` macro supports development mode for testing without CloudKit setup:
```swift
@ObservableCloud(developmentMode: true)
class CloudSettings {
var setting1: String = "value1" // Uses memory storage
var setting2: Int = 42 // Uses memory storage
}
```
Development mode is automatically enabled when:
- Explicitly set via `developmentMode: true`
- Running in SwiftUI Previews (`XCODE_RUNNING_FOR_PREVIEWS` environment variable)
- `OBSERVABLE_DEFAULTS_DEV_MODE` environment variable is set to "true"
### Custom Initializer
If you set `autoInit` to `false` for either macro, you need to create your own initializer:
```swift
// For @ObservableDefaults
init() {
observerStarter() // Start listening for UserDefaults changes
}
// For @ObservableCloud
init() {
// Start Cloud Observation only in production mode
if !_developmentMode_ {
_cloudObserver = CloudObservation(host: self, prefix: _prefix)
}
}
```
### Observe First Mode
Both macros support "Observe First" mode, where properties are observable by default but only explicitly marked properties are persisted:
#### UserDefaults Observe First Mode
```swift
@ObservableDefaults(observeFirst: true)
public class LocalSettings {
public var name: String = "fat" // Observable only
public var age = 109 // Observable only
@DefaultsBacked(userDefaultsKey: "myHeight")
public var height = 190 // Observable and persisted to UserDefaults
@Ignore
public var weight = 10 // Neither observable nor persisted
}
```
#### Cloud Observe First Mode
```swift
@ObservableCloud(observeFirst: true)
public class CloudSettings {
public var localSetting: String = "local" // Observable only
public var tempData = "temp" // Observable only
@CloudBacked(keyValueStoreKey: "user_theme")
public var theme: String = "light" // Observable and synced to iCloud
@Ignore
public var cache = "cache" // Neither observable nor persisted
}
```
### Property Observers (`willSet` / `didSet`)
- `@DefaultsBacked` and `@CloudBacked` do not support `willSet` / `didSet`.
- `@ObservableOnly` supports `willSet` / `didSet`.
- In Observe First mode, properties automatically marked as `@ObservableOnly` also support `willSet` / `didSet`.
### Supporting Optional Types
Both macros fully support Optional properties:
```swift
@ObservableDefaults
class SettingsWithOptionals {
var username: String? = nil
var age: Int? = 25
var isEnabled: Bool? = true
@DefaultsKey(userDefaultsKey: "custom-optional-key")
var customOptional: String? = nil
}
@ObservableCloud
class CloudSettingsWithOptionals {
var cloudUsername: String? = nil
var preferences: [String]? = nil
@CloudKey(keyValueStoreKey: "user-settings")
var userSettings: [String: String]? = nil
}
```
### Supporting Codable Types
Both macros support properties conforming to `Codable` for complex data persistence:
#### UserDefaults with Codable
```swift
@ObservableDefaults
class LocalStore {
var people: People = .init(name: "fat", age: 10)
}
struct People: Codable {
var name: String
var age: Int
}
```
#### Cloud Storage with Codable
```swift
@ObservableCloud
class CloudStore {
var userProfile: UserProfile = .init(name: "fat", preferences: .init())
}
struct UserProfile: Codable {
var name: String
var preferences: UserPreferences
}
struct UserPreferences: Codable {
var theme: String = "light"
var fontSize: Int = 14
}
```
### Enum RawRepresentable Types
Enums whose `RawValue` already conforms to the property-list set (for example `String`, `Int`, etc.) are persisted automatically via their raw value:
```swift
enum Theme: String {
case light
case dark
case system
}
@ObservableDefaults
class AppearanceSettings {
var theme: Theme = Theme.system
}
```
When a type conforms to both `RawRepresentable` and `Codable`, the library will prioritize the `RawRepresentable` storage method, storing values using their raw representation rather than JSON encoding. This ensures backward compatibility with existing data and provides more efficient storage for enum types.
### Storage Resolution Rules (Important for Direct Key Access)
These rules apply to both `@ObservableDefaults` (`UserDefaults`) and `@ObservableCloud` (`NSUbiquitousKeyValueStore`).
When a type matches multiple constraints, the implementation chooses the most specific path in this order:
1. `RawRepresentable & PropertyListValue & Codable`
2. `RawRepresentable & PropertyListValue`
3. `RawRepresentable` (where `RawValue` is a PropertyList-compatible type)
4. `PropertyListValue & Codable`
5. `PropertyListValue`
6. `Codable` only (JSON `Data` path; intentionally lower priority)
#### Persisted Format by Type Combination
- `RawRepresentable`-based paths: persist `rawValue`.
- Example: `String`/`Int` raw values are stored directly as `String`/`Int`.
- `PropertyListValue` paths: persist the value directly as PropertyList-compatible objects.
- `Codable`-only path: persist JSON-encoded `Data`.
- Optional values:
- non-`nil`: stored using the same rules above
- `nil`: key is removed
#### Read Fallback for Compatibility
For `RawRepresentable & PropertyListValue` (including `RawRepresentable & PropertyListValue & Codable`):
- Read attempts `rawValue` format first.
- If that fails, read falls back to direct `PropertyListValue` casting.
This fallback keeps older data readable when a property was previously persisted via direct PropertyList format and later evolved to a `RawRepresentable` type.
#### Consistency for Manual `UserDefaults` / Cloud Reads and Writes
If you also read/write these keys directly outside the macros, use the same format rules to avoid mismatches.
- Use `rawValue` for all `RawRepresentable`-based properties.
- Use direct PropertyList values for PropertyList paths.
- Use JSON `Data` only for `Codable`-only properties.
- Key naming follows macro key resolution:
- default: `prefix + propertyName`
- custom key: `@DefaultsKey` / `@CloudKey`
Example (`UserDefaults`):
```swift
// For RawRepresentable-backed property (rawValue: String)
defaults.set(theme.rawValue, forKey: "app_theme")
// For Codable-only property
defaults.set(try JSONEncoder().encode(profile), forKey: "app_profile")
```
### Integrating with Other Observable Objects
It's recommended to manage storage data separately from your main application state:
```swift
@Observable
class ViewState {
var selection = 10
var isLogin = false
let localSettings = LocalSettings() // UserDefaults-backed
let cloudSettings = CloudSettings() // iCloud-backed
}
struct ContentView: View {
@State var state = ViewState()
var body: some View {
VStack(spacing: 30) {
// Local settings
Text("Local Name: \(state.localSettings.name)")
Button("Modify Local Setting") {
state.localSettings.name = "User \(Int.random(in: 0...1000))"
}
// Cloud settings
Text("Cloud Username: \(state.cloudSettings.username)")
Button("Modify Cloud Setting") {
state.cloudSettings.username = "CloudUser \(Int.random(in: 0...1000))"
}
}
.buttonStyle(.bordered)
}
}
```Important Notes
### Using with SwiftUI #Preview
When using `@ObservableCloud` classes with SwiftUI's `#Preview` and `@Previewable`, you may encounter an error: "cannot be constructed because it has no accessible initializers". This is because `@Previewable` requires a parameter-less initializer. Here are two solutions:
#### Solution 1: Add a Convenience Initializer
```swift
@ObservableCloud
class CloudSettings {
var item: Bool = true
// Add this convenience initializer for Preview support
convenience init() {
self.init(prefix: nil, syncImmediately: false, developmentMode: true)
}
}
#Preview {
@Previewable var settings = CloudSettings()
ContentView()
.environment(settings)
}
```
Note: Setting `developmentMode: true` in the convenience initializer ensures the Preview uses memory storage instead of requiring CloudKit, which is ideal for Preview environments.
#### Solution 2: Use a Singleton Pattern
```swift
@ObservableCloud
class CloudSettings {
var item: Bool = true
static let shared = CloudSettings()
}
#Preview {
@Previewable var settings = CloudSettings.shared
ContentView()
.environment(settings)
}
```
### CI/CD Configuration
When using ObservableDefaults in CI/CD environments, you may need to add the `-skipMacroValidation` flag to your build commands to avoid macro validation issues:
```bash
# For Swift CLI
swift build -Xswiftc -skipMacroValidation
swift test -Xswiftc -skipMacroValidation
# For xcodebuild
xcodebuild build OTHER_SWIFT_FLAGS="-skipMacroValidation"
# For fastlane
build_app(
xcargs: "OTHER_SWIFT_FLAGS='-skipMacroValidation'"
)
```
This flag helps bypass macro validation in CI environments where the full macro compilation context might not be available.
### Default Value Behavior for UserDefaults and iCloud Key-Value Store
All persistent properties (those marked with @DefaultsBacked or @CloudBacked, either explicitly or implicitly) must be declared with default values. The framework captures these declaration-time defaults and maintains them as immutable model defaults throughout the object's lifetime.
Fallback order depends on the backing store:
- `@ObservableDefaults` (`UserDefaults`)
1. Persisted value in the selected `UserDefaults` domain
2. Value provided by `UserDefaults.register(defaults:)`
3. Declaration-time model default captured by ObservableDefaults
- `@ObservableCloud` (`NSUbiquitousKeyValueStore`)
1. Persisted cloud value
2. Declaration-time model default captured by ObservableDefaults
This means `removeObject(forKey:)` does not always revert to the declaration default for `UserDefaults`. If the key has a registered default, that registered default is used first.
```swift
@ObservableDefaults(autoInit: false) // @ObservableCloud(autoInit: false) is the same
class User {
var username = "guest" // ← Declaration default: "guest"
var age: Int = 18 // ← Declaration default: 18
init(username: String, age: Int) {
self.username = username // Current value: "alice", default remains: "guest"
self.age = age // Current value: 25, default remains: 18
// ... other initialization code, like observerStarter(observableKeysBlacklist: [])
}
}
let user = User(username: "alice", age: 25)
// Current state:
// - username current value: "alice"
// - username default value: "guest" (immutable)
// - age current value: 25
// - age default value: 18 (immutable)
user.username = "bob" // Changes current value, default value stays "guest"
let defaults = UserDefaults.standard
defaults.register(defaults: ["username": "registered-user"])
defaults.set("bob", forKey: "username")
defaults.set(25, forKey: "age")
defaults.removeObject(forKey: "username")
defaults.removeObject(forKey: "age")
print(user.username) // "registered-user" (registered default wins)
print(user.age) // 18 (no registered default, so declaration default is used)
```
> **Recommendation**: Unless you have specific requirements, use `autoInit: true` (default) to generate the standard initializer automatically. This helps avoid the misconception that default values can be modified through custom initializers.
### Swift 6.2 and Default Actor Isolation
**Important**: If your project or target has `defaultIsolation` set to `MainActor`, you **must** set the `defaultIsolationIsMainActor` parameter to `true` for proper Swift 6 concurrency compatibility:
```swift
// For projects with defaultIsolation = MainActor
@ObservableDefaults(defaultIsolationIsMainActor: true)
class Settings {
var name: String = "Fatbobman"
var age: Int = 20
}
@ObservableCloud(defaultIsolationIsMainActor: true)
class CloudSettings {
var username: String = "Fatbobman"
var theme: String = "light"
}
```
**Why this is required**:
- Swift 6.2's `defaultIsolation MainActor` setting affects how the compiler handles concurrency
- Without this parameter, you may encounter `@Sendable` conflicts in MainActor environments
- The parameter ensures proper notification handling and deinit isolation
**When to use**:
- ✅ Your project has `defaultIsolation` set to `MainActor` in build settings
- ✅ You're experiencing Swift 6 concurrency compilation errors
- ❌ Your project uses the default `nonisolated` setting (parameter not needed)
### App Groups and Cross-Process Synchronization
When using App Groups to share UserDefaults between your main app and extensions (widgets, app extensions), you need special configuration to ensure proper cross-process notification handling.
#### The Problem
By default, `@ObservableDefaults` only listens to UserDefaults change notifications from its specific UserDefaults instance. When using App Groups:
- Your main app creates: `UserDefaults(suiteName: "group.myapp")`
- Your widget creates: `UserDefaults(suiteName: "group.myapp")`
Even though both access the same data store, they are different object instances. When the widget modifies data, the main app won't automatically receive notifications about the changes.
#### The Solution
Use the `limitToInstance: false` parameter to enable cross-process notifications:
```swift
@ObservableDefaults(
suiteName: "group.com.yourcompany.app",
prefix: "myapp_", // IMPORTANT: Use a unique prefix
limitToInstance: false // Enable cross-process notifications
)
class SharedSettings {
var lastUpdate: Date = Date()
var displayCount: Int = 0
}
```
#### Critical: Always Use a Unique Prefix
When `limitToInstance: false`, the macro listens to ALL UserDefaults change notifications from the entire system, not just your specific suite. This means it will receive notifications from:
- `UserDefaults.standard`
- Other App Groups (`group.otherapp`)
- Any other UserDefaults instances in your app
**The prefix acts as a filter** to ensure your class only responds to changes from your intended suiteName:
```swift
// App Group suite
@ObservableDefaults(
suiteName: "group.myapp",
prefix: "myapp_", // Only respond to keys starting with "myapp_"
limitToInstance: false
)
class AppGroupSettings {
var sharedData: String = "data" // Stored as "myapp_sharedData"
}
// Different App Group suite
@ObservableDefaults(
suiteName: "group.anotherapp",
prefix: "anotherapp_", // Only respond to keys starting with "anotherapp_"
limitToInstance: false
)
class AnotherAppSettings {
var sharedData: String = "other" // Stored as "anotherapp_sharedData"
}
```
Without unique prefixes, your `AppGroupSettings` might incorrectly react to changes from `group.anotherapp` or `UserDefaults.standard`.
#### Performance Considerations
- **Default (`limitToInstance: true`)**: Better performance, only monitors changes from the specific UserDefaults instance. Recommended for single-process apps.
- **Cross-Process (`limitToInstance: false`)**: Necessary for App Groups but receives ALL system UserDefaults notifications. The prefix is essential to filter only relevant changes from your target suite.
### General Notes
- **External Changes**: By default, both macros respond to external changes in their respective storage systems.
- **Key Prefixes**: Use the `prefix` parameter to prevent key collisions when multiple classes use the same property names.
- **Custom Keys**: Use `@DefaultsKey` or `@CloudKey` to specify custom keys for properties.
- **Prefix Characters**: The prefix must not contain '.' characters.
### Cloud-Specific Notes
- **iCloud Account**: Cloud storage requires an active iCloud account and network connectivity.
- **Storage Limits**: `NSUbiquitousKeyValueStore` has a 1MB total storage limit and 1024 key limit.
- **Synchronization**: Changes may take time to propagate across devices depending on network conditions.
- **Development Mode**: Use development mode for testing without CloudKit container setup.
- **Data Migration**: Changing property names or custom keys after deployment may cause cloud data to become inaccessible.
- **Direct NSUbiquitousKeyValueStore Modifications**: Directly modifying values using `NSUbiquitousKeyValueStore.default.set()` will not trigger local property updates in ObservableCloud classes. This is due to NSUbiquitousKeyValueStore's communication mechanism, which does not send notifications for local modifications. Always modify properties through the ObservableCloud instance to ensure proper synchronization and view updates.License
ObservableDefaults is released under the MIT License. See LICENSE for details.
Acknowledgments
Special thanks to the Swift community for their continuous support and contributions.
Support the project
Star History
[[Star History Chart]](https://star-history.com/#fatbobman/ObservableDefaults&Date)
Package Metadata
Repository: fatbobman/observabledefaults
Default branch: main
README: README.md