The power of UserDefaults in Swift
Available since the very first release of the iOS SDK, the UserDefaults
API can at first glance appear to be both really simple and somewhat limited. While it provides an easy-to-use way to save and store persisted values, the way it stores those values in a single plist
file can make it impractical for larger datasets — resulting in many developers dismissing it in favor of more full-featured databases, or some form of custom solution.
However, appearances can be deceiving, and it turns out that the power of UserDefaults
extends far beyond simply storing and loading basic value types. This week, let’s take a look at what some of that power comes from, and how we can appropriately make use of it in the apps that we build.
Database or not?
Very often UserDefaults
is positioned as an alternative to a database solution — such as CoreData or SQLite. While it’s true that the user defaults API is able to act as a database, its main use case is more centered around values that relate to user preferences — which, when looking more closely at the its various functionality and how it integrates with the system, makes it look much less like a limited database — and more like a focused API that does a core set of things really well.
Let’s start by taking a look at an example, in which we’re building a ThemeController
that’ll be responsible for keeping track of the current Theme
that our app will use to render its UI. Since this is something that users can select themselves, it makes a lot of sense to treat it as a user preference, and to store its value in UserDefaults
.
To do that, we’ll inject an instance into ThemeController
(which’ll default to the standard
set of defaults), and use it to save and load Theme
values — like this:
enum Theme: String {
case light
case dark
case black
}
class ThemeController {
private(set) lazy var currentTheme = loadTheme()
private let defaults: UserDefaults
private let defaultsKey = "theme"
init(defaults: UserDefaults = .standard) {
self.defaults = defaults
}
func changeTheme(to theme: Theme) {
currentTheme = theme
defaults.setValue(theme.rawValue, forKey: defaultsKey)
}
private func loadTheme() -> Theme {
let rawValue = defaults.string(forKey: defaultsKey)
return rawValue.flatMap(Theme.init) ?? .light
}
}
The above implementation may seem simple, but the way we make use of UserDefaults
for this kind of setting will soon turn out to unlock quite a lot of interesting features — both for our users, and for us as developers.
Sharing data within an app group
The first thing that using UserDefaults
will allow us to do, is to easily share data between multiple apps and app extensions. For example, let’s say that we’re either shipping two apps (if we’re building a ride sharing service, that could be one app for drivers and one app for customers), or that a single app we’re building also includes an extension that presents some form of UI.
To enable our users to only have to pick their preferred theme once — and then make that value propagate throughout all of our UI — we can set up a defaults suite. For example, if we’re building two apps that are both within the same app group, then we could create a UserDefaults
instance with a suite name that matches our app group’s identifier:
extension UserDefaults {
static var shared: UserDefaults {
return UserDefaults(suiteName: "group.johnsundell.app")!
}
}
Another option would be to simply add our app group suite to the standard defaults — creating a combined UserDefaults
instance, like this:
extension UserDefaults {
static var shared: UserDefaults {
let combined = UserDefaults.standard
combined.addSuite(named: "group.johnsundell.app")
return combined
}
}
The difference between the above two options, is that when basing the UserDefaults
instance on the standard set of defaults (like we do in the last example), the values within the standard defaults will always override the ones from our shared suite. That can both be useful (in case we want to enable local overrides on a per-app basis), but can also make propagating shared settings more difficult.
Regardless of which approach we’ll take, all we have to do to make use of our new suite-based UserDefaults
instance is to replace .standard
with .shared
in our ThemeController
initializer:
class ThemeController {
...
init(defaults: UserDefaults = .shared) {
self.defaults = defaults
}
...
}
The beauty of this aspect of UserDefaults
is that it still offers us a 100% synchronous API, even though changes are propagated to multiple apps or extensions asynchronously in the background. That enables our local code to immediately continue after updating a value, without sacrificing performance while waiting for all instances to be updated.
Values stored this way are persisted until all the apps within the app group have been removed from a user’s device.
Overriding values at launch
Like we took a look at in “Launch arguments in Swift”, having an app accept arguments when launched can be a great way to enable it to be customized for both manual and automated testing — and UserDefaults
automatically parses any arguments passed into an app, and uses those values as local overrides.
That both enables us to use Xcode’s scheme editor (Product > Scheme > Edit Scheme...
) to easily customize what theme our app will use by adding the -theme
argument followed by the name of one of our themes — but it also enables us to do the same thing within a UI test.
For example, let’s say that we want to write a test that verifies that our settings screen’s theme picker correctly displays the current theme as selected. To do that in a very predictable way, all we have to do is to pass a theme as a launch argument to XCUIApplication
, and then verify that the theme we passed is indeed marked as selected in the UI — by using accessibility identifiers, like this:
class ThemingUITests: XCTestCase {
func testThemePickerShowingCurrentTheme() {
let app = XCUIApplication()
app.launchArguments = ["-theme", "dark"]
app.launch()
// Querying our theme picker table view by its
// accessibility identifier.
let picker = app.tables["Theme.Picker"]
// Here we give each cell a different accessibility
// identifier both depending on what theme it represents,
// and also whether or not it's selected.
let cells = (
light: picker.cells["Theme.Light"],
dark: picker.cells["Theme.Dark.Selected"],
black: picker.cells["Theme.Black"]
)
XCTAssertTrue(cells.light.exists)
XCTAssertTrue(cells.dark.exists)
XCTAssertTrue(cells.black.exists)
}
}
To learn more about UI testing using XCTest, check out “Getting started with Xcode UI testing in Swift”.
Being able to easily configure various aspects of an app can really be a big productivity booster when both writing tests and while debugging — and launch arguments can be a great way of achieving that, especially considering how UserDefaults
automatically does all of the parsing and overriding for us. Values overridden this way also don’t affect the values that were previously persisted, making it easy to go back to our app’s previous state — by simply removing launch arguments.
Mock-free tests
Continuing on the topic of testing, another useful way to use custom UserDefaults
suites is to be able to easily unit test code that persists data, without causing flakiness or unpredictable results.
Let’s say that we want to write a test that verifies that calling the changeTheme
method on our ThemeController
correctly updates the current theme. An initial idea might be to simply create an instance of our controller, call the method in question, and then verify the outcome — like this:
class ThemeControllerTests: XCTestCase {
func testChangingTheme() {
let controller = ThemeController()
controller.changeTheme(to: .dark)
XCTAssertEqual(controller.currentTheme, .dark)
}
}
The above test works, and will successfully pass. However, it turns out that — after the very first time it’s run — it doesn’t actually test anything. Since we’re persisting the selected theme, our test will continue to pass even if we remove the call to changeTheme
— which isn’t great.
Like we took a look at in “Making Swift tests easier to debug”, verifying our initial state can be really important in order to write more robust tests that are easier to maintain. Let’s do just that, by adding a second assert that verifies that the initial theme is what we’d expect, right after we create our ThemeController
instance:
class ThemeControllerTests: XCTestCase {
func testChangingTheme() {
let controller = ThemeController()
XCTAssertEqual(controller.currentTheme, .light)
controller.changeTheme(to: .dark)
XCTAssertEqual(controller.currentTheme, .dark)
}
}
With the additional assert in place, our test will now start failing — which is a good thing, since it’ll prompt us to improve things. What we’ll need to do is to make sure that any persisted Theme
value is cleared out before our test is run, and at first it might seem like that’ll require some form of mocking, but since we’re using UserDefaults
we’ll be able to keep our test mock-free while still solving the persistence problem.
To do that, we’ll first add an extension on UserDefaults
, which’ll enable us to easily create an instance that has had all of its persisted values completely cleared. We’ll use the same suite name-based initializer that we previously used to share values between different apps, but instead of using an app group’s identifier, we’ll instead base the identifier on the names of the file and test function that the instance will be used in. Finally, we call removePersistentDomain
on our defaults object in order to clear out its persistence — like this:
extension UserDefaults {
static func makeClearedInstance(
for functionName: StaticString = #function,
inFile fileName: StaticString = #file
) -> UserDefaults {
let className = "\(fileName)".split(separator: ".")[0]
let testName = "\(functionName)".split(separator: "(")[0]
let suiteName = "com.johnsundell.test.\(className).\(testName)"
let defaults = self.init(suiteName: suiteName)!
defaults.removePersistentDomain(forName: suiteName)
return defaults
}
}
Above we’re automatically gathering the name of the calling function and the name of the file that function is defined in, by using #function
and #file
. We’ll then strip the .swift
extension from the file name and the ()
from the function name.
With the above in place, we can now update our test to again pass, but this time by actually verifying our functionality, rather than relying on any previously persisted value:
class ThemeControllerTests: XCTestCase {
func testChangingTheme() {
let controller = ThemeController(defaults: .makeClearedInstance())
XCTAssertEqual(controller.currentTheme, .light)
controller.changeTheme(to: .dark)
XCTAssertEqual(controller.currentTheme, .dark)
}
}
Pretty cool! 😎 The benefit of not mocking UserDefaults
in this case is that we don’t have to introduce any additional types or protocols for the sole purpose of mocking, and can instead use the real object that our actual production code will be interacting with — giving us a test that also executes under much more real-life conditions.
Conclusion
UserDefaults
is a lot more powerful than what it first might seem like, and although it shouldn’t be considered as a complete database solution, using it for simple settings values — such as themes and other preferences — can unlock several different features that can prove to be incredibly useful when both writing tests, and when debugging.
So the question is, where to draw the line? When does a use case “outgrow” UserDefaults
, and for what kind of data is using a proper database a more appropriate choice? For me, it all comes down to the expected size of the data in question. Is it a simple Bool
, Int
or String
— or are we talking about an array of objects that could easily grow several orders of magnitude depending on the user? UserDefaults
is great, as long as the size of the dataset can be kept limited, but when that’s no longer true — it’s most likely time for another solution.
We’ll take a closer look at various database solutions available in Swift in future articles — but until then, if you have any questions, comments or feedback — feel free to contact me, or find me on Twitter @johnsundell.
Thanks for reading! 🚀