[SC]()

iOS. Apple. Indies. Plus Things.

Quick and Painless Persistency on iOS

// Written by Jordan Morgan // Mar 29th, 2024 // Read it in about 4 minutes // RE: Swift

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 ✌️.

···

Spot an issue, anything to add?

Reach Out.