Improving UserDefaults in Swift With Key-Value Observing

A little while ago I wrote a post about improving NSUserDefaults in Objective-C using the Objective-C runtime. This greatly simplified the string-based API of NSUserDefaults to make it much more convenient and safe to use. Having now primarily switched over to using Swift for iOS projects, I wanted to take a look at how to go about improving UserDefaults in the same way.

At first it didn’t look like it was possible to achieve the same simplified API I ended up with for NSUserDefaults because Objective-C properties and Swift properties are fundamentally different. In Objective-C, when you declare a property the compiler generates a setter and getter method along with a backing instance variable. This allows us to dynamically provide the implementation methods for the setter and getter of dynamic properties using the -(BOOL)resolveInstanceMethod: method of NSObject. (For a more detailed look at this, see the old post on NSUserDefaults.)

In Swift, properties do not have setters and getters generated for them, and the dynamic keyword is used for Key-Value Observing. That means we can’t provide method implementations at run-time that effectively set and get values from UserDefaults when a property is accessed. However, KVO can be used to achieve the same effect.

In addition to a base FSUserDefaults class that we will inherit from (similarly to how it was done in Objective-C), an observer class is needed. It’s purpose will be to observe changes to the properties of our own user defaults class that inherits from FSUserDefaults that will call the appropriate set method of UserDefaults with the new value. This will happen in the init method of the FSDefaultsObserver class. Here is the first part of that method where we retrieve the properties of our custom defaults class:

init(object: NSObject) {
    observedObject = object
    super.init()

    var propertyCount: UInt32 = 0
    if let properties = class_copyPropertyList(object_getClass(observedObject), &propertyCount) {
        for idx in 0..<propertyCount {
            let property = properties[Int(idx)]
            let name = String(cString: property_getName(property))
            let attr = String(cString: property_getAttributes(property)!)
            
            var keyPath = #keyPath(observedObject)
            keyPath = keyPath.appending(".").appending(name)
            
            let typeIdx = attr.index(attr.startIndex, offsetBy: 1)
            let type = attr[typeIdx]
            ...

The init method is initialized with the instance of our custom user defaults object, and then proceeds to get a list of properties on that class using the Objective-C runtime. We then iterate through the properties and retrieve the name and attribute string of each property to determine the key-path and the type. The key-path is used as the key for storing the value in UserDefaults. Here is an example of an attribute string for an Int property: “Tq,N,VanInteger”. The second character (in this case ‘q’) maps to a type encoding (documented here) that tells us the type of the property.

The addObserver method of NSObject takes a context parameter that will be used to store the type of the property so that the proper set method of UserDefaults can be called, so the next task is to switch on the property type and set the context parameter accordingly.

let context = UnsafeMutableRawPointer.allocate(bytes: 1, alignedTo: 1)

switch type {
case "c", "B":
    context.storeBytes(of: "b", as: Character.self)
case "s", "i", "l", "q", "S", "I", "L", "Q":
    context.storeBytes(of: "i", as: Character.self)
case "f":
    context.storeBytes(of: "f", as: Character.self)
case "d":
    context.storeBytes(of: "d", as: Character.self)
case "@":
    context.storeBytes(of: "@", as: Character.self)
default:
    assertionFailure("[FSUserDefaults] Unhandled property type.")
}

The context parameter is just a raw pointer, and for the time being I’m just storing character literals to differentiate the types (this will be improved later), with “b” for boolean, “i” for Int, “f” for float, “d” for double, and “@” for object. After this, the FSDefaultsObserver instance adds itself as an observer of the property’s key-path, along with storing the key-path and context so that it can remove itself as an observer when it is de-initialized:

addObserver(self, forKeyPath: keyPath, options: [.new], context: context)
observations.append((keyPath, context))

Now let’s look at FSDefaultsObserver‘s implementation of the observeValue: method, which gets called whenever one of the observed properties changes value:

override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
    if let type = context?.load(as: Character.self) {
        let value = change![.newKey]
        switch type {
        case "b": UserDefaults.standard.set(value as! Bool, forKey: keyPath!)
        case "i": UserDefaults.standard.set(value as! Int, forKey: keyPath!)
        case "f": UserDefaults.standard.set(value as! Float, forKey: keyPath!)
        case "d": UserDefaults.standard.set(value as! Double, forKey: keyPath!)
        case "u": UserDefaults.standard.set((value as! URL), forKey: keyPath!)
        case "@": UserDefaults.standard.set(value, forKey: keyPath!)
        }
    } else {
        super.observeValue(forKeyPath: keyPath, of: object, change: change, context: context)
    }
}

This takes care of when properties are changed (i.e. the setter part), but what about retrieving the defaults values? This is where things got a little dicey because we have no getter method that we can use as in the Objective-C case where a call can be made to UserDefaults to retrieve the value. However, properties themselves in Swift are the instance variables, so when we set a value on them, it will be synchronized between the value set in UserDefaults and on the property itself. What about when the app launches, though, either for the first time or after it has been terminated and our user defaults object is no longer in memory? For that, in addition to setting up the observers for each property, we can also look for the existence of a value in UserDefaults for that property’s key-path during initialization of the defaults observer object and set it on the property of our custom defaults object if one is found. Effectively this synchronizes the property values with values found in UserDefaults on initialization. Here is the additional code needed for that:

var value: Any?

switch type {
case "c", "B":
    value = UserDefaults.standard.bool(forKey: keyPath)
case "s", "i", "l", "q", "S", "I", "L", "Q":
    value = UserDefaults.standard.integer(forKey: keyPath)
case "f":
    value = UserDefaults.standard.float(forKey: keyPath)
case "d":
    value = UserDefaults.standard.double(forKey: keyPath)
case "@":
    <determine type of object, and call corresponding getter on UserDefaults>
    ...
}

if let val = value {
    observedObject.setValue(val, forKey: name)
}

UserDefaults has additional getter methods for retrieving certain types of object like Strings, Arrays, URLs, etc., so for object types we do some additional logic to determine the exact type of the object before looking for it in UserDefaults. I won’t go over that here, but it is included in the full source code at the end of the post.

The last thing to be done is to define the FSUserDefaults class that we inherit from. The only thing it will do is initialize an instance of FSDefaultsObserver.

class FSUserDefaults: NSObject {
    private var _observer: FSDefaultsObserver!
    
    override init() {
        super.init()
        _observer = FSDefaultsObserver(object: self)
    }
}

Now we can create our custom defaults class, and the only thing that needs to be done when adding new default values is to declare a new dynamic property on the class. That’s it. It can then be used in code like this:

var anInteger = MyDefaults.shared.anInteger
...
MyDefaults.shared.anInteger = anInteger
...
MyDefaults.shared.aString = "aString"
...

Here is an example of a MyDefaults class:

class MyDefaults: FSUserDefaults {
    @objc dynamic var anInteger: Int = 0
    @objc dynamic var aString: String?
    
    static let shared: MyDefaults = {
        let instance = MyDefaults()
        return instance
    }()
}

Finally, here is the full source code for FSUserDefaults & FSDefaultsObserver, in which character literals are replaced with enums, as well as some other code-cleanup.

8 thoughts on “Improving UserDefaults in Swift With Key-Value Observing

  1. AM

    Dear Mister Christian Floisand, do You still have plans to rebuild Your awesome plugins?? That would be very much appreciated. 🙂

    Reply
    1. Chris Post author

      No, I’m afraid not. There just turned out to be too much fix-up work required with legacy dependencies. Plus I no longer have enough hosts to test on as I am no longer doing much on the music side of things.

      Reply
      1. AM

        Thank You very much for replying. That is so sad to know…. Is it possible that You would in the far future make spiritual successors of Your plugins, especially a spiritual successor of the Radverb?? Or are You absolutely done with audio plugins?? 🙂

      2. Chris Post author

        I wouldn’t say never. I enjoy audio programming and had fun making RadVerb and Dyner. If I did decide to remake them I would probably start over (just keeping the core DSP code intact). There have been some big changes to Core Audio on the Mac side as far as I know, so I would have to read up on how to do AU/VST on the Mac now. For instance, back when I released these plug-ins, they relied on some old Carbon stuff that has since been completely removed on macOS. I don’t work as much in Windows so I’m even less familiar with how the status of VST is on that side.

        And if that wasn’t enough, VST2.x has been deprecated and is no longer supported either, so yeah.. I’d basically be starting from scratch again (apart from the core DSP code).

      3. AM

        Oh yes I forgot, Steinberg are going to end and terminate the VST2 plugin format on October 1st 2018. If You fill, sign and send to Steinberg this VST2 License Agreement form before October 1st 2018, You will be able to continue developing and distributing VST2 plugins without problems with Steinberg. Here is a direct link to the VST2 License Agreement: http://www.synthedit.com/files/VST2_License_Agreement.pdf . It is included in the current VST3SDK available at Steinberg’s website or their Github webpage. You should download this VST3SDK before October 1st, 2018, and save it, back it up, because after that the VST SDK will no longer include the VST2 plugin development part. This is just in case You get bitten by the plugin developing bug again. 🙂

      4. AM

        Also, if You will be thinking about adding new features or parameters to Your plugins please do so. The more the better. 🙂

Leave a comment