Quick and Painless Persistency on iOS
This post is brought to you by Emerge Tools, the best way to build on mobile.
When you need to save stuff quick and dirty, the vast world of iOS affords us several attractive options. Here’s a few that I’ve used over the years. None are novel, but every once in a blue moon I find that I have to copy and paste this kind of thing into a project — so here it is on Swiftjective-C for all to benefit from.
let concatenatedThoughts = """
Omitted from this list are saving sensitive data with Keychain Services and interacting with SQLite directly. Both are other options, but a bit more involved. This post evangelizes the super easy, "I just need to save something real quick!" type of situations.
"""
Codable
Use the magic and convenience of Codable
to write .json data blobs to disk, and later deserialize them:
struct Person: Codable {
let name: String
}
let people: [Person] = [.init(name: "Jordan") ,
.init(name: "Jansyn")]
let saveURLCodable: URL = .documentsDirectory
.appending(component: "people")
.appendingPathExtension("json")
func saveWithCodable() {
do {
let peopleData = try JSONEncoder().encode(people)
try peopleData.write(to: saveURLCodable)
print("Saved.")
} catch {
print(error.localizedDescription)
}
}
func loadWithCodable() {
do {
let peopleData = try Data(contentsOf: saveURLCodable)
let people = try JSONDecoder().decode([Person].self, from: peopleData)
print("Retrieved: \(people)")
} catch {
print(error.localizedDescription)
}
}
Codable with @Observable
Of course, many of us are using SwiftUI. And, if we’re using SwiftUI — you’re likely also taking advantage of @Observable
. If won’t work with Codable
out of the box, but it’s trivial to fix.
@Observable
class Food: Codable, CustomStringConvertible {
enum CodingKeys: String, CodingKey {
case genre = "_genre"
}
let genre: String
var description: String {
genre
}
init(genre: String) {
self.genre = genre
}
}
let foods: [Food] = [.init(genre: "American"),
.init(genre: "Italian")]
let saveURLCodableObservable: URL = .documentsDirectory
.appending(component: "foods")
.appendingPathExtension("json")
func saveWithCodableObservable() {
do {
let foodData = try JSONEncoder().encode(foods)
try foodData.write(to: saveURLCodableObservable)
print("Saved.")
} catch {
print(error.localizedDescription)
}
}
func loadWithCodableObservable() {
do {
let foodData = try Data(contentsOf: saveURLCodableObservable)
let foods: [Food] = try JSONDecoder().decode([Food].self, from: foodData)
print("Retrieved: \(foods)")
} catch {
print(error.localizedDescription)
}
}
If you’re curious about why we need to do this, check out Paul Hudson’s quick video explainer.
NSKeyedArchiver
If you’re dealing with objects, any holdover Objective-C code or simply are dipping around in your #NSWays, NSKeyedArchiver
is a good choice.
class Job: NSObject, NSSecureCoding {
static var supportsSecureCoding: Bool = true
let name: String
override var description: String {
name
}
init(name: String) {
self.name = name
}
func encode(with coder: NSCoder) {
coder.encode(name, forKey: "name")
}
required init?(coder: NSCoder) {
self.name = coder.decodeObject(of: NSString.self, forKey: "name") as? String ?? ""
}
}
let jobs: [Job] = [.init(name: "Developer"),
.init(name: "Designer")]
let saveURLKeyedrchiver: URL = .documentsDirectory
.appending(component: "jobs")
func saveWithKeyedArchiver() {
do {
let jobsData: Data = try NSKeyedArchiver.archivedData(withRootObject: jobs,
requiringSecureCoding: true)
try jobsData.write(to: saveURLKeyedrchiver)
print("Saved.")
} catch {
print(error.localizedDescription)
}
}
func loadWithKeyedArchiver() {
do {
let jobsData = try Data(contentsOf: saveURLKeyedrchiver)
let decodedJobs: [Job] = try NSKeyedUnarchiver.unarchivedArrayOfObjects(ofClass: Job.self,
from: jobsData) ?? []
print("Retrieved: \(decodedJobs)")
} catch {
print(error.localizedDescription)
}
}
If you’re curious what the NSSecureCoding
dance is all about, check this out.
UserDefaults
The easiest of them all. It can save off primitive types in a hurry, or even custom models using the NSKeyedArchiver
route above (though that is not advised).
let names: [String] = ["Steve", "Woz"]
func saveWithUserDefaults() {
let defaults = UserDefaults.standard
defaults.set(names, forKey: "names")
print("Saved.")
}
func loadWithUserDefaults() {
let defaults = UserDefaults.standard
if let names = defaults.object(forKey: "names") as? [String] {
print("Retrieved: \(names)")
} else {
print("Unable to retrieve names.")
}
}
Local .plist, .json or other file types
If you’ve got a local .plist, .json or other file type hanging around — you can simply decode those the same way you would any other data blob. Consider this cars.json
file:
[
{
"make": "Toyota"
},
{
"make": "Ford"
},
{
"make": "Chevrolet"
},
{
"make": "Honda"
},
{
"make": "BMW"
},
{
"make": "Mercedes-Benz"
},
{
"make": "Volkswagen"
},
{
"make": "Audi"
},
{
"make": "Hyundai"
},
{
"make": "Mazda"
}
]
struct Car: Codable {
let make: String
}
func loadWithLocalJSON() {
guard let carsFile = Bundle.main.url(forResource: "cars.json", withExtension: nil)
else {
fatalError("Unable to find local car file.")
}
do {
let carsData = try Data(contentsOf: carsFile)
let cars = try JSONDecoder().decode([Car].self, from: carsData)
print("Retrieved: \(cars)")
} catch {
print(error.localizedDescription)
}
}
AppStorage
Of course, true to SwiftUI’s “ahhh that’s so easy” mantra, try using its app storage attribute. Simply create a variable and mutate it like anything else, and it’ll be automatically persisted using UserDefaults
under the hood:
struct AppSettings {
@AppStorage("hasOnboarded") var hasOnboarded: Bool = false
}
Final Thoughts
Content warning: these techniques shouldn’t be used for your entire data graph. There are other, more sturdy ways, to amalgamate your data on disk which are more suited to the task. Though, in my first app, I saved everything in user defaults. In reality, only about a half megabyte is supposed to hang out in there. The more you know, right? For the most part, these APIs embody brevity being the soul of wit.
Until next time ✌️.