Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 62 additions & 10 deletions Sources/Defaults/Defaults.swift
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,13 @@ extension Defaults {
public let name: String
public let suite: UserDefaults

/**
Whether this key uses external storage.

This property exists on the base class to allow reset() to access it without knowing the generic type.
*/
var usesExternalStorage: Bool { false }

@_alwaysEmitIntoClient
fileprivate init(name: String, suite: UserDefaults) {
runtimeWarn(
Expand All @@ -64,7 +71,17 @@ extension Defaults {
Reset the item back to its default value.
*/
public func reset() {
suite.removeObject(forKey: name)
// Clean up external storage if applicable
if usesExternalStorage {
ExternalStorage.lock(for: name).with {
if let fileID = suite.string(forKey: name) {
ExternalStorage.delete(fileID: fileID, forKey: name)
}
suite.removeObject(forKey: name)
}
} else {
suite.removeObject(forKey: name)
}
}
}

Expand Down Expand Up @@ -101,13 +118,24 @@ extension Defaults {

public var defaultValue: Value { defaultValueGetter() }

/**
Whether this key stores its value externally on disk instead of in UserDefaults.

When enabled, only a reference UUID is stored in UserDefaults, while the actual data is written to disk.
*/
@usableFromInline
let _usesExternalStorage: Bool

override var usesExternalStorage: Bool { _usesExternalStorage }

/**
Create a key.

- Parameter name: The name must be ASCII, not start with `@`, and cannot contain a dot (`.`).
- Parameter defaultValue: The default value.
- Parameter suite: The `UserDefaults` suite to store the value in.
- Parameter iCloud: Automatically synchronize the value with ``Defaults/iCloud``.
- Parameter externalStorage: Store the value externally on disk instead of in UserDefaults. Only works with the `.standard` suite.

The `default` parameter should not be used if the `Value` type is an optional.
*/
Expand All @@ -116,18 +144,27 @@ extension Defaults {
_ name: String,
default defaultValue: Value,
suite: UserDefaults = .standard,
iCloud: Bool = false
iCloud: Bool = false,
externalStorage: Bool = false
) {
defer {
if iCloud {
Defaults.iCloud.add(self)
}
}
runtimeWarn(
!externalStorage || suite == .standard,
"External storage only works with UserDefaults.standard suite"
)

self._usesExternalStorage = externalStorage && suite == .standard
self.defaultValueGetter = { defaultValue }

super.init(name: name, suite: suite)

if iCloud {
if _usesExternalStorage {
runtimeWarn(false, "iCloud is not supported with externalStorage for key '\(name)'.")
} else {
Defaults.iCloud.add(self)
}
}

if (defaultValue as? (any _DefaultsOptionalProtocol))?._defaults_isNil == true {
return
}
Expand All @@ -154,6 +191,7 @@ extension Defaults {
- Parameter name: The name must be ASCII, not start with `@`, and cannot contain a dot (`.`).
- Parameter suite: The `UserDefaults` suite to store the value in.
- Parameter iCloud: Automatically synchronize the value with ``Defaults/iCloud``.
- Parameter externalStorage: Store the value externally on disk instead of in UserDefaults. Only works with the `.standard` suite.
- Parameter defaultValueGetter: The dynamic default value.

- Note: This initializer will not set the default value in the actual `UserDefaults`. This should not matter much though. It's only really useful if you use legacy KVO bindings.
Expand All @@ -163,14 +201,25 @@ extension Defaults {
_ name: String,
suite: UserDefaults = .standard,
iCloud: Bool = false,
externalStorage: Bool = false,
default defaultValueGetter: @escaping () -> Value
) {
runtimeWarn(
!externalStorage || suite == .standard,
"External storage only works with UserDefaults.standard suite"
)

self._usesExternalStorage = externalStorage && suite == .standard
self.defaultValueGetter = defaultValueGetter

super.init(name: name, suite: suite)

if iCloud {
Defaults.iCloud.add(self)
if _usesExternalStorage {
runtimeWarn(false, "iCloud is not supported with externalStorage for key '\(name)'.")
} else {
Defaults.iCloud.add(self)
}
}
}
}
Expand All @@ -184,17 +233,20 @@ extension Defaults.Key {
- Parameter name: The name must be ASCII, not start with `@`, and cannot contain a dot (`.`).
- Parameter suite: The `UserDefaults` suite to store the value in.
- Parameter iCloud: Automatically synchronize the value with ``Defaults/iCloud``.
- Parameter externalStorage: Store the value externally on disk instead of in UserDefaults. Only works with the `.standard` suite.
*/
public convenience init<T>(
_ name: String,
suite: UserDefaults = .standard,
iCloud: Bool = false
iCloud: Bool = false,
externalStorage: Bool = false
) where Value == T? {
self.init(
name,
default: nil,
suite: suite,
iCloud: iCloud
iCloud: iCloud,
externalStorage: externalStorage
)
}

Expand Down
1 change: 1 addition & 0 deletions Sources/Defaults/Documentation.docc/Documentation.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ struct ContentView: View {
### Customize Storage

- <doc:AdvancedUsage>
- <doc:ExternalStorage>
- ``Defaults/Serializable``
- ``Defaults/Bridge``
- ``Defaults/CollectionSerializable``
Expand Down
64 changes: 64 additions & 0 deletions Sources/Defaults/Documentation.docc/ExternalStorage.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# External Storage

Store large values on disk instead of in UserDefaults.

## Overview

For large values like images or documents, you can use external storage to store the data on disk instead of in UserDefaults. This prevents UserDefaults from becoming bloated and keeps memory usage low.

When external storage is enabled, only a UUID reference is stored in UserDefaults, while the actual data is written to disk in the Application Support directory.

## Usage

Enable external storage by setting the `externalStorage` parameter to `true` when creating a key:

```swift
import Defaults

extension Defaults.Keys {
static let largeData = Key<Data>("largeData", default: Data(), externalStorage: true)
}

// Store large data
let data = Data(repeating: 0, count: 1_000_000) // 1MB
Defaults[.largeData] = data

// Retrieve it
let retrieved = Defaults[.largeData]
```

## How It Works

When `externalStorage: true` is enabled:

- Only a UUID reference is stored in UserDefaults
- The actual data is written to disk in the Application Support directory under `.Defaults_EXTERNAL_STORAGE/<key-name>/<uuid>`
- Data is automatically cleaned up when the key is reset or overwritten
- Files are excluded from iCloud and Time Machine backups
- Per-key locks ensure thread-safe concurrent access

## Limitations

- Only works with `UserDefaults.standard` suite
- Cannot be used with ``Defaults/iCloud`` synchronization (UUID references would sync, but not the actual files)
- Files are stored locally and not automatically synchronized across devices

## Performance Considerations

External storage is designed for large binary data. For small values (< 100 KB), storing directly in UserDefaults is usually faster due to:

- No disk I/O overhead
- No file system fragmentation
- No additional UUID lookup

Use external storage when:

- Values are larger than 100 KB
- You need to reduce UserDefaults memory footprint
- You're storing binary data like images, documents, or media files

## Topics

### Enabling External Storage

- ``Defaults/Key/init(_:default:suite:iCloud:externalStorage:)``
77 changes: 74 additions & 3 deletions Sources/Defaults/UserDefaults.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,62 @@
}

public subscript<Value: Defaults.Serializable>(key: Defaults.Key<Value>) -> Value {
get { _get(key.name) ?? key.defaultValue }
get {
if key.usesExternalStorage {
return Defaults.ExternalStorage.lock(for: key.name).with {
guard let fileID: String = _get(key.name) else {
return key.defaultValue
}

return Defaults.ExternalStorage.load(
fileID: fileID,
forKey: key.name,
defaultValue: key.defaultValue,
suite: self
)
}
}

return _get(key.name) ?? key.defaultValue
}
set {
if key.usesExternalStorage {
// Use per-key lock to prevent concurrent access issues
Defaults.ExternalStorage.lock(for: key.name).with {
// Handle nil values
if (newValue as? (any _DefaultsOptionalProtocol))?._defaults_isNil == true {
// Clean up old external file if it exists
if let oldFileID: String = _get(key.name) {
Defaults.ExternalStorage.delete(fileID: oldFileID, forKey: key.name)
}

removeObject(forKey: key.name)
} else {
do {
// Save new file first (with new UUID) before deleting old file
// This prevents data loss if app crashes during the operation
let newFileID = try Defaults.ExternalStorage.save(newValue, forKey: key.name)

// Get old file ID before updating UserDefaults
let oldFileID: String? = _get(key.name)

// Update UserDefaults with new file ID
_set(key.name, to: newFileID)

// Now safe to delete old file - if this fails, we just have an orphaned file which is better than losing data
if let oldFileID = oldFileID {

Check warning on line 65 in Sources/Defaults/UserDefaults.swift

View workflow job for this annotation

GitHub Actions / lint

Shorthand Optional Binding Violation: Use shorthand syntax for optional binding (shorthand_optional_binding)

Check warning on line 65 in Sources/Defaults/UserDefaults.swift

View workflow job for this annotation

GitHub Actions / lint

Shorthand Optional Binding Violation: Use shorthand syntax for optional binding (shorthand_optional_binding)
Defaults.ExternalStorage.delete(fileID: oldFileID, forKey: key.name)
}
} catch {
runtimeWarn(false, "Failed to save external storage for '\(key.name)': \(error)")
// Keep existing value and reference on failure - do not nuke the old value
}
}
}

return
}

_set(key.name, to: newValue)
}
}
Expand All @@ -34,8 +88,25 @@
*/
public func removeAll() {
// We're not using `.removePersistentDomain(forName:)` as it requires knowing the suite name and also because it doesn't emit change events for each key, but rather just `UserDefaults.didChangeNotification`, which we don't subscribe to.
for key in dictionaryRepresentation().keys {
removeObject(forKey: key)

for (key, value) in dictionaryRepresentation() {
// Check if this might be an external storage reference (UUID string)
if
let stringValue = value as? String,
UUID(uuidString: stringValue) != nil
{

Check warning on line 97 in Sources/Defaults/UserDefaults.swift

View workflow job for this annotation

GitHub Actions / lint

Opening Brace Spacing Violation: Opening braces should be preceded by a single space and on the same line as the declaration (opening_brace)

Check warning on line 97 in Sources/Defaults/UserDefaults.swift

View workflow job for this annotation

GitHub Actions / lint

Opening Brace Spacing Violation: Opening braces should be preceded by a single space and on the same line as the declaration (opening_brace)
// Acquire per-key lock to prevent race with concurrent writes
Defaults.ExternalStorage.lock(for: key).with {
// Re-read inside lock to ensure consistency
if let currentFileID = string(forKey: key) {
Defaults.ExternalStorage.delete(fileID: currentFileID, forKey: key)
}

removeObject(forKey: key)
}
} else {
removeObject(forKey: key)
}
}
}
}
Loading
Loading