Creating a Synchronized UserDefaults Property Wrapper
Since the birth of Swift’s property wrappers, I have seen numerous articles discussing how to create a wrapped variable that can persist itself to the UserDefaults sector. While many of the code examples suffice for the simplest case, I’ve still had some questions gnawing at the back of my head.
- If several of these instances would ever need to be alive at the same time, how will they synchronize?
- How do they synchronize if another process accesses the UserDefaults file, perhaps directly through the file system?
In this article, we look at how to make a property wrapper that persists your variables in UserDefaults. They also stay on top of any value changes, regardless of where they originate.
Analyzing a Simple Implementation
Let’s take a look at a simple implementation of a property wrapper for persisting values in UserDefaults, to get a sense of what it does well and where it fails. We then use these realizations to build a wrapper that handles synchronization like a charm.
The above is the most straightforward wrapper I’ve seen so far. It does not attempt to cache the currently persisted value; it merely relies on the caching mechanisms of UserDefaults. This solution is not terrible if that’s all you need. However, it becomes a headache as soon as you want to be able to reactively propagate changes out to, for example, a UI.
Making Our Improvements
To be able to publish any changes made, we need to cache the value inside of our object. We can then listen to the notifications that UserDefault sends through NotificationCenter, and update our cached value whenever we receive one of those. This approach alleviates the problem of synchronization between multiple wrapper instances since each of them triggers a notification when they set a new value. It does, however, require us to switch from writing a struct to writing a class, since NotificationCenter can only register reference type objects.
As we can see in the above implementation, we change a few things so that the object makes use of the cached
value property. We also conform to the
ObservableObject protocol and make use of Combine’s
Published construct to let others react to any changes.
The real improvement here is that the wrapper has a chance to react to any changes made through an instance of UserDefaults. Since all other wrapper objects go through UserDefaults, they are covered too.
Does this mean that we are safe?
Taking Care of Changes From Unexpected Places
What would happen if someone set a new value for our key from another process? Or updates the default values by writing straight to the property list file that holds them? With this implementation, we don’t have a way of handling that. It looks like we’ve spotted a potential improvement!
The UserDefaults property list is the least common denominator that may cause our wrappers or the UserDefaults instance to go out-of-sync. This file is a classic key-value list that can be read and written to, just like any other. To pick up on changes that originate from strange places, we need to monitor that list and react to any write operations that occur. To do that, we are going to engineer a
There are probably many things going on in this piece of code that some may not have seen before. The
FileWriteMonitor keeps a
DispatchSourceFileSystemObject instance. That is a long word to describe an object that lets us observe events in a file descriptor.
Speaking of which, our monitor also keeps track of a file descriptor. A file descriptor is a POSIX construct that allows us to get an abstract handle to access a file. It is this beauty, together with the
DispatchSourceFileSystemObject, that is going to make magic for us.
Focus your attention on the
.connectDispatchSource method. If we have obtained a file descriptor and created an event source before, we cancel and close them. These actions make sure we don’t leave a lot of loose ends hanging around.
Once that is done, we obtain a new file descriptor for the file we are interested in. In our case, this is the UserDefaults property list file. From the descriptor, we create a new event source that will observe and propagate write and delete operations to us. In the event handler, we check for the event that triggered our handler. We reconnect our event source to a new file descriptor if we observe a
delete event. If the event source passes us a write event, we call the
onWrite closure that we got in the initializer.
Now, let’s take this thing for a spin. Let’s see how we can use it to complete our property wrapper.
Pay special attention to the initializer in this piece of code. We first obtain a URL for the UserDefaults list, which we pass into our
FileWriteMonitor together with a closure. That closure calls an update method which reads out the new value from the file and caches it. You may also notice that it passes the new value to UserDefaults, which allows it to update its cache and send a notification to the other property wrappers that may want to update their caches as well.
In case you are interested to learn more, I post a fair share of articles. Feel free to follow me to get an update when there’s something new to read!
Until next time, you may be interested in reading about how to perform Monte Carlo simulations on a mobile device, or how to program in a protocol-oriented fashion. Below are links to those articles!
Thanks for reading!
Protocol Oriented Programming Concepts in Swift
Leveraging protocols to increase code reuse