iOS Persistence with UserDefaults

Persistence can be defined as saving data to a place where it can be re-accessed and retrieved upon restart of the device or app. This can mean non-volatile memory on a device or saving data to remote servers. Persistence is necessary for any app that wants to store data in the long term, that is longer than a lifecycle of a running app, and keep it available to the user.

There are several options available for creating persistent iOS apps. This post focuses on UserDefaults.

UserDefaults is a dictionary that periodically saves its contents to a device’s permanent storage (SSD). It is great for storing user preferences and other simple things. It saves data in a Property List file (plist). It stores the following types: Data, String, Number, Date, Array, and Dictionary.

When writing or reading a file, UserDefaults does it all at once, possibly creating long I/O time. Thus, it’s a good idea to keep the file under 1MB. Thousands of notes, images, and whatever else a user might store would be way too much information for UserDefaults to handle. Trying to store all this in UserDefaults would have negative performance implications.

A device has a defaults database where it stores everything from system-wide defaults, to language defaults, to app-specific defaults and more. App-specific defaults is what we will be discussing.

To access the values, we must call standard on UserDefaults, which will return the shared defaults object that we need. UserDefaults.standard will return a reference to the same user default regardless of where we invoke it in our program. This is the singleton design pattern. Once we have the shared user defaults object, we can then use keys to get values for this default’s object just like a dictionary.

To illustrate, let’s use UserDefaults to check for the first launch of an app. In the code to follow the bool(forKey:) returns Boolean value associated with the hasLaunchedBefore key. If the specified key doesn‘t exist, this method returns false.

The code contains a comment to set the default values for the keys. This is important so that one can check this user default throughout the rest of the code without wondering whether the value is set or not.

func checkIfFirstLaunch() {
    if UserDefaults.standard.bool(forKey: "hasLaunchedBefore") {
        print("App has launched before")
    } else {
        print("This is the first launch ever!")
        UserDefaults.standard.set(true, forKey: "hasLaunchedBefore")

        // Set the default values for the rest of the keys in the app
    }
}

How to delete UserDefaults Keys

To test the code and simulate running the app as if it was the first time it has ever been launched on a device is to reset the content settings of the simulator by going to Hardware > Erase All Content and Settings.

Other ways exists for deleting UserDefaults keys. The first thing to consider is whether one wants to remove one key or to nuke them all.

To remove the value of a selected key invoke removeObject(forKey:).

UserDefaults.standard.removeObject(forKey: "Key")

To remove all the contents of the specified persistent domain from the user’s defaults call removePersistentDomain(forName:).

Calling this method is equivalent to initializing a user defaults object with init(suiteName:) passing domainName, and calling the removeObject(forKey:) method on each of its keys.

Apple Developer Documentation
UserDefaults.standard.removePersistentDomain(forName: Bundle.main.bundleIdentifier!)

Testing User Defaults

The fact that Apple provides and maintains UserDefaults makes testing the behavior of that class obsolete. The exception to this point is when we do not want to trust a class! That leaves us testing the interaction with the class.

One can choose between different testing strategies including mocking with protocol, mocking by subclassing, and avoiding mocking by creating a real UserDefaults instance.

Mocking with a protocol boils down to mimicking the desired methods signatures we would like to spy on. However, adding a protocol solely for testing UserDefaults introduces noise in the production code and strongly couples it with UserDefaults interface.

Testing the UserDefaults implementation by subclassing and spying its methods is fast and reliable. On the downside, subclassing types we do not own poses a risk. Having no access to the third party code can lead to wrongful assumptions about mocked behavior.

class MockUserDefaults: UserDefaults {
    var settingUnderTest = false

    convenience init() {
        self.init(suiteName: #file)!
    }

    override init?(suiteName suitename: String?) {
        UserDefaults().removePersistentDomain(forName: #file)
        super.init(suiteName: suitename)
    }

    override func set(_ value: Float, forKey defaultName: String) {
        if defaultName == "key name" {
            settingUnderTest = true
        }
    }
}

Another approach is to avoid mocking UserDefaults and to create a real instance.

class ClassTests: XCTestCase {
    private var userDefaults: UserDefaults!
    private var sut: SystemUnderTest!

    override func setUp() {
        super.setup()

        userDefaults = UserDefaults(suiteName: #file)
        userDefaults.removePersistentDomain(forName: #file)

        sut = SystemUnderTest(userDefaults: userDefaults)
    }
}

Conclusion

There is a consensus that UserDefaults satisfy the need for storing small pieces of data, and that items demanding more memory require using other mechanisms.

References

Comment Rules: The goal is to become better at our jobs. To post code, insert it between the tags <code></code> Critical is fine, but if you’re rude, I'll delete your stuff. Please do not put your URL in the comment text and please use your PERSONAL name or initials and not your business name, as the latter comes off like spam. Have fun and thanks for adding value to the converstaion!

Leave a Reply

Your email address will not be published. Required fields are marked *