Serializing & Archiving Structs in Swift with Archiver

Goal: Serialize server response objects to Swift structs and archive them to property lists.

Archiver is a Swift framework I developed for solving the problem discussed in the "Overview" section below. You can find it here.

Overview

There are many options for persisting data between launches in Cocoa apps, including NSUserDefaults, NSKeyedArchiver / NSKeyedUnarchiver, Core Data, and a range of third-party frameworks. NSKeyedArchiver is a popular choice for projects that require more than the basic key-value storage NSUserDefaults offers, but not the complex object graph capabilities Core Data provides. It can be a great option for projects that rely on a server for most app data and just need a caching mechanism to display information to the user before server requests return.

However, there's a problem. NSKeyedArchiver requires objects to conform to NSCoding, which is a class protocol. This means you can't use the NSCoding approach with custom Swift value types (e.g. structs, enums) that you define.

Archiver is a Swift framework that provides a protocol-oriented solution to this problem, requiring value types to provide NSCoding-compliant representations to be used with NSKeyedArchiver. Because NSKeyedArchiver is often used to archive data that originally came from a server, Archiver also provides a system for serializing and archiving server response objects in one line of code.


In this tutorial, we'll build a model for a restaurant reviews app and use Archiver to serialize and archive a sample server response object.

Model

Let's start by looking at some sample JSON that has been serialized to a Swift dictionary.

let json: ResponseObject = [
    "id": "abcd-efgh-ijkl-mnop",
    "name": "George Costanza",
    "user_reviews": [
        ["id": "123-456",
         "restaurant": [
            "id": "abcd-1234",
            "name": "The Original Soup Man"
            ],
         "star_rating": "4",
         "text": "Good soup! One star off for forgeting the bread."
        ],
        ["id": "789-012",
         "restaurant": [
            "id": "efgh-5678",
            "name": "La Boîte en Bois"
            ],
         "star_rating": "4",
         "text": "I've heard the risotto is very good here."
        ],
        ["id": "345-678",
         "restaurant": [
            "id": "ijkl-9012",
            "name": "Monk's Café"
            ],
         "star_rating": "5",
         "text": "Always a consistent experience. Great atmosphere."
        ]
    ]
]

Here we have a root "user" object, which has an id, name, and array of restaurant reviews. Each "review" object has an id, restaurant, star rating, and text, and each restaurant has an id and name. Here's how we might model these relationships in Swift.

User.swift

struct User {
    let id: String
    let name: String
    let reviews: [Review]
}

Review.swift

struct Review {
    let id: String
    let restaurant: Restaurant
    let rating: Int
    let text: String
}

Restaurant.swift

struct Restaurant {
    let id: String
    let name: String   
}

Serialization

The first step in archiving the User is to serialize the response object into a User value. For this Archiver includes a "Serializable" protocol that each of our value types can conform to. Serializable is a simple protocol. It's only requirement is that types must be initializable with a ResponseObject, which is a type alias for [String: Any]. This should be easy, as our JSON above is already in [String: Any] form.

Let's start serializing User by working backwards. Here's our Serializable conformance for Restaurant:

extension Restaurant: Serializable {

    private struct ServerKey {
        static let id = "id"
        static let name = "name"
    }

    public init?(responseObject: ResponseObject) {
        guard let id = responseObject[ServerKey.id] as? String else { return nil }
        guard let name = responseObject[ServerKey.name] as? String else { return nil }
        self.id = id
        self.name = name
    }

}

Note that we use a failable initializer here. We could have default values for id and name and use a non-failable initializer, but that wouldn't make much sense for the Restaurant type. Also, we have a private struct (only available in this extension) that defines the keys used in the response JSON. This pattern of private structs helps make our code safer and ensures that we only have to manage server and archive keys in one place.

Next let's continue up the chain and serialize Review.

extension Review: Serializable {

    private struct ServerKey {
        static let id = "id"
        static let restaurant = "restaurant"
        static let rating = "star_rating"
        static let text = "text"
    }

    public init?(responseObject: ResponseObject) {
        guard let id = responseObject[ServerKey.id] as? String else { return nil }
        self.id = id
        guard let restaurant = Restaurant.serialized(from: responseObject, withKey: ServerKey.restaurant) else { return nil }
        self.restaurant = restaurant
        self.rating = Int(responseObject[ServerKey.rating] as? String ?? "") ?? 0
        self.text = responseObject[ServerKey.text] as? String ?? ""
    }

}

This time one of the properties is also a Serializable value, an instance of Restaurant. Archiver has a helper function for this, which takes the original response object and the key for the restaurant object. Because Restaurant initialization can fail, we guard to make sure initialization succeeds before setting the restaurant property.

Review initialization can also fail. In this implementation, a valid id and Restaurant value are required, but rating and text can have default values. Again, this is a choice and is not required by the Archiver framework or the Serializable protocol.

Finally, let's serialize our root value, User.

extension User: Serializable {

    private struct ServerKey {
        static let id = "id"
        static let name = "name"
        static let reviews = "user_reviews"
    }

    public init?(responseObject: ResponseObject) {
        guard let id = responseObject[ServerKey.id] as? String else { return nil }
        self.id = id
        self.name = responseObject[ServerKey.name] as? String ?? NSLocalizedString("Guest", comment: "")
        self.reviews = Review.serializedCollection(from: responseObject, withKey: ServerKey.reviews)
    }

}

Here we're serializing an array of Serializable values, our user's reviews. Archiver includes a helper function for this as well. It takes our root response object and a key, iterates through the next level of response objects, and returns an array of non-optional, serialized values. Note that since initialization can fail for Review and Restaurant, we could end up with 0 reviews.

Archiving

Now that we've implemented serialization, we're ready to archive the User value. When we archive our user, Archiver is going to create a folder for us in the app file system for User archives if one doesn't already exist. By default, Archiver uses the Caches directory and creates a root folder for all app archives using your app's bundle name, but these settings can be configured. Archiver will then save a property list (using the user's id as the file name) to that folder. The property list will include all of the information that we just serialized. Therefore, while all three of the value types we defined (User, Review, Restaurant) need to be archived, we only need one property list for the root value (User).

When we're finished, our file path to the User archive will look like this:

Library/Caches/com.AppBundleName.archives/User/abcd-efgh-ijkl-mnop.plist

Because we only need one plist, but all three value types need to be included in the archive, Archiver includes two protocols. The first is ArchiveRepresentable, which requires that conforming types supply NSCoding-compliant representations of themselves and be initializable with an Archive (another type alias for [String: Any]). As we go from User to Review to Restaurant, this ensures that all properties can be archived. The second protocol is Archivable, which inherits from ArchiveRepresentable. Archivable manages saving the archive as a property list to disk and deleting the archive if needed.

Archiver includes a protocol extension for Archivable with a default implementation of its requirements, so in practice you may not need to add anything to your ArchiveRepresentable implementation to conform. However, if you want to set a custom directory name or location for your archives, you can override the defaults.

Let's once again work backwards starting with Restaurant.

extension Restaurant: ArchiveRepresentable {
    
    private struct ArchiveKey {
        static let id = "id"
        static let name = "name"
    }
    
    public var archiveValue: Archive {
        return [
            ArchiveKey.id: id,
            ArchiveKey.name: name
        ]
    }
    
    public init?(archive: Archive) {
        guard let id = archive[ArchiveKey.id] as? String else { return nil }
        guard let name = archive[ArchiveKey.name] as? String else { return nil }
        self.id = id
        self.name = name
    }
    
}

Note here that we only need to conform to ArchiveRepresentable since Restaurant won't be stored in a separate property list. You'll notice that the pattern for initialization here looks familiar. We're just using an "ArchiveKey" instead of a "ServerKey" and initializing from an Archive instead of a ResponseObject. The new piece is that we're supplying an NSCoding-compliant representation of Restaurant, a computed property called "archiveValue". This is the model Archive (a.k.a [String: Any]) that will be saved as part of the property list.

At this point it may not be clear why we have separate initializers and key structs for serializing and archiving. We do this because the keys, data types, and organization the backend uses may not be what we want to use on the client, and the server's endpoints and response objects may change. Keeping archiving separate allows us to maintain a consistent model and protect our code from these potential changes.

Let's look at the ArchiveRepresentable conformance for Review to see this more clearly.

extension Review: ArchiveRepresentable {

    private struct ArchiveKey {
        static let id = "id"
        static let restaurant = "restaurant"
        static let rating = "rating"
        static let text = "text"
    }

    var archiveValue: Archive {
        return [
            ArchiveKey.id: id,
            ArchiveKey.restaurant: restaurant.archiveValue,
            ArchiveKey.rating: rating,
            ArchiveKey.text: text
        ]
    }

    public init?(archive: Archive) {
        self.id = archive[ArchiveKey.id] as? String ?? ""
        guard let restaurant = Restaurant.unarchived(from: archive, withKey: ArchiveKey.restaurant) else { return nil }
        self.restaurant = restaurant
        self.rating = archive[ArchiveKey.rating] as? Int ?? 0
        self.text = archive[ArchiveKey.text] as? String ?? ""
        if id.isEmpty { return nil }
    }
}

Here we see that while the server used the key "star_rating", we just use "rating" as the archive key. First, we use camel case in Swift, not underscores. Second, the "star" in "star_rating" is not necessary. In fact our app doesn't even display ratings using stars, only the web app does that. Aside from naming, there's another important difference. The server sends us ratings as String values, but they should really be modeled as Int values. In the archive, we store rating correctly as an Int.

Notice that we use another helper function from Archiver to unarchive the Restaurant value in the Review initializer. This again is similar to what we did when we serialized Review.

We also use the restaurant property's archiveValue when defining Review's archiveValue to ensure that all properties can be archived properly.

We're now finally ready to archive the User value.

extension User: Archivable {

    private struct ArchiveKey {
        static let id = "id"
        static let name = "name"
        static let reviews = "reviews"
    }

    var archiveValue: Archive {
        return [
            ArchiveKey.id: id,
            ArchiveKey.name: name,
            ArchiveKey.reviews: reviews.archiveValue
        ]
    }

    public init?(archive: Archive) {
        guard let id = archive[ArchiveKey.id] as? String else { return nil }
        self.id = id
        self.name = archive[ArchiveKey.name] as? String ?? ""
        self.reviews = Review.unarchivedCollection(from: archive, withKey: ArchiveKey.reviews)
    }

}

The pattern is once again familiar. Note that "reviews", which is an array of ArchiveRepresentable values, has an "archiveValue" property. Archiver provides an Array extension to do this for convenience. All it does is map an array of ArchiveRepresentable values to an array including each element's archiveValue. There's also a counterpart to "serializedCollection" called "unarchivedCollection", which we use here to unarchive our user's reviews.

Now that all values are fully serializable and archivable, we have one more protocol to tie everything together. It's called Cachable, it inherits from both Serializable and Archivable, and it has one requirement. All it says is that conforming types must be initializable with a ResponseObject and callers can optionally specify whether the value should be archived.

public protocol Cachable: Archivable, Serializable {

    init?(responseObject: ResponseObject, shouldArchive: Bool)

}

Cachable actually has a default implementation for it's only requirement which serializes and archives the value (if shouldArchive is true) in one step. So unless you want to override that behavior you only need to call the initializer and specify that a value type conforms to Cachable. Let's do this for User. Where you declared the struct, just add Cachable conformance:

struct User: Cachable

One final note. Archiver uses a value's id as the file name for its archived property list. To enforce this, Archiver includes a UniquelyIdentifiable protocol which requires an id property. All Archivable values must conform to UniquelyIdentifiable. If your value type wouldn't normally have an id but you want to archive it, you can either generate a UUID or use some other unique identifier. To generate a UUID, just run the following:

UUID().uuidString

If you've been following along, here's where we actually archive the User.

Create a simple Xcode project, a single view iOS app will work (although Archiver supports macOS, tvOS, and watchOS as well). In viewDidLoad, copy the json dictionary from the beginning of the tutorial. Then, to serialize and archive in one step, add the following:

guard let user = User(responseObject: json, shouldArchive: true) else { return }
print(user)

Go to your device's Caches directory, where you should see the folders generated by Archiver and the abcd-efgh-ijkl-mnop.plist file representing the archived User value. To find the Caches directory URL, breakpoint in viewDidLoad and run the following in the Console:

po FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first!

To unarchive, just initialize a User with its id:

guard let user = User(resourceID: "abcd-efgh-ijkl-mnop") else { return }
print(user)

As we've seen, there is a decent amount of prep work involved in making your value types Serializable and Archivable. This is also true when conforming to NSCoding in classes. However, once you've conformed to the relevant protocols, serializing and archiving your value types with Archiver and then retrieving them later is easy.

Once again, the framework is available on Github here. It can be used with CocoaPods, Carthage, and Swift Package Manager.