The Advanced Guide to UserDefaults in Swift
UserDefaults
has been around in iOS SDK for as long as anyone can recall. The Swift language evolves quickly, bringing new features into the play and changing the ways we used to work with the existing ones. The introduction of property wrappers in Swift 5 offers a great opportunity to revisit the conventional approach to UserDefaults
and freshen the fundamentals along the way.
In this article we’ll cover:
- What is
UserDefaults
? - What kind of data should we save to
UserDefaults
? - How
UserDefaults
is implemented internally? - Design type-safe key-value storage, based on
UserDefaults
and Swift property wrappers. - Observe
UserDefaults
value changes.
UserDefaults Overview
UserDefaults
manages the persistent storage of key-value pairs in a .plist
file.
UserDefaults
storage is limited to the so-called property-list data types [1]: Data
, String
, Date
, Bool
, Int
, Double
, Float
, Array
, Dictionary
and URL
(the only non-property-list-type). It is also possible to store arbitrary objects by encoding them into a Data
object first.
Here is the full list of supported types from the
UserDefaults
source code: common Swift types, uncommon Swift types, NS- / CF- bridging types, plusNSNumber
(non-bridging, but still supported).
There is no hard limit on the size of data that we can store to UserDefaults
, except for the 1MB on tvOS.
Nonetheless, it is not recommended to store large chunks of data, because reads and writes become more expensive the more data UserDefaults
contains. The reason for that is that the defaults use a single .plist
file per domain (and typically per app), which becomes bloated if we store big chunks of data there.
It is also not recommended to store custom objects for the following reasons:
- Arbitrary data types must be archived and unarchived to and from
Data
, which is expensive. - Arbitrary data types will likely become incompatible with newer versions of your app.
According to Apple, the best approach to UserDefaults
is to store user preferences and app configuration as simple values [1], [2].
UserDefaults Internal Structure
Let’s dig into swift-corelibs-foundation to find out how UserDefaults
works behind the scenes.
UserDefaults
saves data on a per-domain basis. This means that each domain has a corresponding .plist
file, where the associated data is persisted.
Domain is just a plain string. If you peek into UserDefaults
internals, you’ll discover that it’s also called suite. Both names refer to the same concept, so we’ll continue calling it domain.
By default, every app has 8 domains, which are organized into the so-called search list. The search list is initialized when we read or write values for the first time. We are free to add more domains if we want more granular control over UserDefaults
storage.
The domains from the search list are merged into a single dictionary, which is an expensive operation. The dictionary is re-computed each time the value is added, updated or removed from the user defaults. This gives us another insight about the user defaults performance:
UserDefaults
performs best when the writes are rare and reads are frequent.
When we set a value to UserDefaults
, it calls _CFApplicationPreferencesSet from CFApplicationPreferences.c, which is a bunch of scary C code with some comments dated 1999. CFApplicationPreferences
manages the dictionary representation of user defaults across all registered domains with some caching involved.
The app preferences delegate per-domain work to CFPreferences.c. It reads and writes XML files to the disk and does the caching.
UserDefaults
has two levels of caching: the domain level and the app level.
Implementing Key-Value Storage
Now that we know what is UserDefaults
, let’s do some actual work and implement key-value storage, based on UserDefaults
and property wrappers.
The current section requires basic understanding of property wrappers. I recommend reading The Complete Guide to Property Wrappers in Swift 5 to get yourself up to speed.
First, let’s implement a wrapper, which saves and loads values to and from UserDefaults
:
@propertyWrapper
struct UserDefault<T: PropertyListValue> {
let key: Key
var wrappedValue: T? {
get { UserDefaults.standard.value(forKey: key.rawValue) as? T }
set { UserDefaults.standard.set(newValue, forKey: key.rawValue) }
}
}
Notice that we are constraining T
to be of PropertyListValue
type. It is the marker protocol for all property-list data types:
This code is inspired by the
UserDefault
implementation from Burritos.
// The marker protocol
protocol PropertyListValue {}
extension Data: PropertyListValue {}
extension String: PropertyListValue {}
extension Date: PropertyListValue {}
extension Bool: PropertyListValue {}
extension Int: PropertyListValue {}
extension Double: PropertyListValue {}
extension Float: PropertyListValue {}
// Every element must be a property-list type
extension Array: PropertyListValue where Element: PropertyListValue {}
extension Dictionary: PropertyListValue where Key == String, Value: PropertyListValue {}
The UserDefault
wrapper uses statically typed keys, declared as follows:
struct Key: RawRepresentable {
let rawValue: String
}
extension Key: ExpressibleByStringLiteral {
init(stringLiteral: String) {
rawValue = stringLiteral
}
}
The new keys can be conveniently declared in an extension:
extension Key {
static let isFirstLaunch: Key = "isFirstLaunch"
}
Storage
is a thin abstraction layer on top of UserDefaults
:
struct Storage {
@UserDefault(key: .isFirstLaunch)
var isFirstLaunch: Bool?
}
We can use Storage
as follows:
var storage = Storage()
storage.isFirstLaunch = true
print(storage.isFirstLaunch) // true
Observing UserDefaults Value Changes
Since UserDefaults
typically represent system-wide preferences, it’s common to respond to their changes from different parts of your app. In this section let’s extend the UserDefault
property wrapper to allow observation of value changes.
We begin by implementing DefaultsObservation
, which listens to UserDefaults
changes via KVO:
class DefaultsObservation: NSObject {
let key: Key
private var onChange: (Any, Any) -> Void
// 1
init(key: Key, onChange: @escaping (Any, Any) -> Void) {
self.onChange = onChange
self.key = key
super.init()
UserDefaults.standard.addObserver(self, forKeyPath: key.rawValue, options: [.old, .new], context: nil)
}
// 2
override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey: Any]?, context: UnsafeMutableRawPointer?) {
guard let change = change, object != nil, keyPath == key.rawValue else { return }
onChange(change[.oldKey] as Any, change[.newKey] as Any)
}
// 3
deinit {
UserDefaults.standard.removeObserver(self, forKeyPath: key.rawValue, context: nil)
}
}
- The observation accepts a type-safe
Key
and anonChange
callback. It eagerly begins listening to theUserDefaults
value changes, specified by the key. - The
observeValue()
method is called by the KVO system automatically, when the value, specified by thekey
, is changed. The method accepts achange
dictionary, from where we extract the old and new values and pass them to theonChange
callback. - Unsubscribe from KVO when the observation is deallocated.
We add the new method observe()
to the property wrapper, which returns an instance of observation. To be able to call it from the outside of Storage
, we expose the wrapper type itself via the projectedValue
:
@propertyWrapper
struct UserDefault<T: PropertyListValue> {
var projectedValue: UserDefault<T> { return self }
func observe(change: @escaping (T?, T?) -> Void) -> NSObject {
return DefaultsObservation(key: key) { old, new in
change(old as? T, new as? T)
}
}
// The rest of the code is unchanged
}
Now we can subscribe to UserDefaults
changes like this:
var storage = Storage()
var observation = storage.$isFirstLaunch.observe { old, new in
print("Changed from: \(old) to \(new)")
}
storage.isFirstLaunch = true
storage.isFirstLaunch?.toggle()
It will print:
Changed from: Optional(false) to Optional(true)
Changed from: Optional(true) to Optional(false)
Source Code
You can find the final project here. It is published under the “Unlicense”, which allows you to do whatever you want with it.
Summary
Here are the key things to remember about UserDefaults
:
UserDefaults
is built around dictionaries and.plist
files.UserDefaults
is best suited for small subsets of data and simple data types.UserDefaults
performs best when the writes are rare and the reads are frequent.
Swift 5 is the game-changer for UserDefaults
. With the help of property wrappers, we’ve designed type-safe key-value storage, which allows us to observe value changes.
Thanks for reading!
If you enjoyed this post, be sure to follow me on Twitter to keep up with the new content. There I write daily on iOS development, programming, and Swift.