Persistence
It is mind-boggling, when you stop to think about it, just how fragile the code is that we programmers create.
There is little in the way of permanence. Code gets outdated, apps become passé, and every once in a while some joker comes along and decides to throw everything out and do a complete rewrite.
Even during execution program logic — variables, state, etc. — do not last long at all. This is particularly true in the life of an iOS developer, where the baleful eyes of Watchdog are always searching for resource hogs to shut down, and users anxiously — and needlessly — kill apps with wild abandon.
Impermanence is the name of the mobile game.
⌘S
For this reason, a modern app will often need to save data to a device so that it can be used hours or days later. Saving state and model data to device is a common task, and there are numerous ways on Apple platforms to do this:
- Save relatively small key/value pairs using
UserDefaults
. - Save data to an embedded SQLite database.
- Write a ton of boilerplate so that you can use CoreData, which — under the hood — is itself probably using SQLite.
- Store data solely in the cloud, either using your own back-end infrastructure or something like CloudKit (and accepting the ramifications of users occasionally being unable to access the network).
- Serialize your data and save it directly to the user’s device.
This post is about the final option listed above, and KPCC has a shiny, new, simple-to-use framework that makes persisting data easy.
Introducing Persistence
Persistence builds off of Codable (read my last blog post for more detail on that), providing a simply, handy means to serialize model instances and write them to a device, and then to load and deserialize them as needed. There is a fair bit of boilerplate code to do this sort of thing, so having a single framework that can handle the most common use cases makes a lot of sense.
We use Persistence internally for storing cached program data, episode data, schedules, settings, and other information in the KPCC iOS app.
Want to use Persistence in your own project? It’s available today on GitHub.
Writing a File
Saving with Persistence is pretty straightforward.
Assuming you have a Codable-compliant instance (often, this will be a dictionary, array, or set made up of Codable-compliant instances), saving goes something like this:
let stringInstances = ["Steve", "Woz", "Tim", "Jony"]let fileLocation = Persistence.FileLocation.documentsDirectory(versioned: false)Persistence().write(stringInstances, toFileNamed: "Strings.json", location: fileLocation)
The above bit of code saves an array of String
instances (which are naturally Codable-conformant — most Foundation data types are) to an application’s Documents directory with a name of Strings.json.
Assuming that everything went okay, you will now have a file in your app’s Documents directory containing the following JSON:
["Steve","Woz","Tim","Jony"]
This is a pretty contrived example, but using the magic of Persistence and Codable you could have saved a massive array of very complex objects with the same three lines of code.
Reading a File
Reading a file and decoding its contents is also pretty simple.
let fileLocation = Persistence.FileLocation.documentsDirectory(versioned: false)Persistence().read(fromFileNamed: "Strings.json", asType: [String].self, location: fileLocation, completion: { result in
let strings = try? result.get()
})
This will read the file created above, calling a completion handler with a Result
, which — if successful — will contain the array of String
instances we saved earlier. You can retrieve these from the result, in the form of an optional, using the .get()
method.
Note that, when loading, you need to specify the type of instance you expect to receive (in this case, [String].self
— an array of String instances), as Swift is unable to infer what it is reading and deserializing before it has done so.
Specifying Locations
Whether you’re saving or loading a file, you’re going to have to tell Persistence where it should be located.
To do this, Persistence’s read and write methods both accept a Persistence.FileLocation
instance, created using a number of convenience methods, that specify a location for where your file is to be saved to or loaded from.
This could be the app’s Caches directory, its Documents directory, its Application Support directory, or even an App Group-specific version of any of the above (as App Groups are shared between multiple processes that might operate on the same file simultaneously, Persistence will handle the read/write coordination for you).
Versioning
One useful feature is the ability to specify versioned subdirectories. This refers to a subdirectory named after the containing binary’s build number, making it simple to store files only intended to be used for the duration of a build’s lifecycle. This is useful for temporary files whose format can change between releases.
Error Handling
If for some reason either reading or writing fail (due to a lack of space when saving or an expected file not existing when loading, as two common examples), the save or load method will return a .failure
Result with an associated SaveError
or LoadError
instance, respectively.
We mostly ignore this in the examples above for brevity, but you could just as easily handle errors explicitly if you wish. This is done using Swift 5’s nifty, new Result
type.
For example, here is how we could more explicitly handle errors in saving a file:
let stringInstances = ["Steve", "Woz", "Tim", "Jony"]let fileLocation = Persistence.FileLocation.documentsDirectory(versioned: false)let result = Persistence().write(stringInstances, toFileNamed: "Strings.json", location: fileLocation)switch result {
case .success(_):
print("Yay!")
case .failure(let error):
print("Boo! error = \(error.localizedDescription)")
}
Here is an attempt to load a file, handling errors individually:
let fileLocation = Persistence.FileLocation.documentsDirectory(versioned: false)Persistence().read(fromFileNamed: "Strings.json", asType: [String].self, location: fileLocation, completion: { result in
switch result {
case .success(let strings):
print("Yay! strings = \(strings)")
case .failure(let error):
print("Boo! error = \(error)")
}
})
Grab Bag
Persistence includes a few other tricks that may prove useful.
Debugging Output
During development it can be helpful to get a sense for what Persistence is doing behind the scenes. You can specify .disabled
, .basic
, or .verbose
depending on how much information you need. Debugging output is disabled by default.
Persistence(withDebugLevel: .verbose)
Age Checks
Often — particularly when reading files from a device as a cache— you will find yourself wanting to check to see whether a file is older than a certain age before loading it. Persistence provides a useful method for doing so:
let fileLocation = Persistence.FileLocation.documentsDirectory(versioned: false)if Persistence().file(isOlderThan: 60 * 10, fileNamed: "Strings.json", location: fileLocation) == true {
// File is older than 10 minutes...
} else {
// File is not older than 10 minutes...
}
The above method will also return true if the file does not exist.
URLs
Sometimes, you will want to see what URL a FileLocation
resolves to, either to feed it to your own code or just to get an idea what is going on behind the scenes. You can do so using FileLocation’s directoryURL()
method, like this:
let fileLocation = Persistence.FileLocation.documentsDirectory(versioned: false)let fileLocationURL = fileLocation.directoryURL()print(fileLocationURL!.path)
The Upshot
What Persistence does may not be the most exciting thing in the world, but seamless caching and recall of stored data can make all the difference when it comes to providing a great mobile experience to end users.
As there is no shortage of cases where file system access is useful, I will be adding additional functionality to Persistence over time. Keep an eye on this project if you find yourself doing the same sorts of thing in your apps.