Introduction to NSKeyed(Un)Archiver: enable offline mode

April 2019 ยท 7 minute read

Most of the time, our applications are dealing with network calls in other to fetch data coming from APIs, created by us or another provider. This dependency on the network has the main downfall. Obviously, no network means that we cannot communicate with the API and we will not be able to access the data we were looking for to displays to the user.

In this case, we have several choices (ordered from worst to best):

If we can all agree that the two first choices are really bad practices, we can argue that depending on the use cases, the third choice can be more appropriate that the fourth one. Moreover, if we decide to choose the fourth one, the third approach must also be implemented when the data hasn’t been loaded yet, in case of the first launch of an app or if we decide to clear the data at a certain time to not display outdated data to the user.

There are many ways to store data locally in an iOS application, depending on the use case:

To accomplish the offline support in this tutorial, we will use NSKeyesArchiver / NSKeyesUnarchiver approaches that is a good compromise between easy implementation and getting the job done, but this could have been also implemented with UserDefaults or CoreData for example.

Project overview

We will implement a simple weather application that is getting the current weather of AccuWeather Top Cities โ†’ API REFERENCE

The application will look like this:

The code is available on GitHub.

In order to run the project on your machine, you will have to obtain an AccuWeather API key. Sign up to the AccuWeather Developer Portal to get one.

I won’t do a step by step tutorial here but instead take the time to present each part of the completed project. I would be interested to see if you prefer this format as a more traditional step by step tutorial.

Let’s dive in!

The complete project tree is the following:

Introduction to the NSKeyedArchiverManager

Let’s first take a moment to analyze the NSKeyedArchiverManager object, responsible for archive and unarchive objects.

struct NSKeyedArchiverManager {
    
    enum Paths {
        static let weatherFeed = "WeatherFeed"
    }
    
    static private func documentDirectory(with path: String) -> String {
        let paths = NSSearchPathForDirectoriesInDomains(.documentDirectory, .userDomainMask, true)
        let documentDirectory = paths[0] as String
        return documentDirectory + "/" + path
    }
    
    static func archive<T: Encodable>(object: T, toFile path: String) throws {
        do {
            let data = try PropertyListEncoder().encode(object)
            NSKeyedArchiver.archiveRootObject(data, toFile: documentDirectory(with: path))
        } catch {
            throw error
        }
    }
    
    static func unarchive<T: Decodable>(fromFile path: String) throws -> T? {
        guard let data = NSKeyedUnarchiver.unarchiveObject(withFile: documentDirectory(with: path)) as? Data else { return nil }
        do {
            let result = try PropertyListDecoder().decode(T.self, from: data)
            return result
        } catch {
            throw error
        }
    }
    
}

The NSKeyedArchiverManager is a simple struct containing:

Our model layer

Let’s take a first look at our models. The JSON payload received from /v1/topcities/50?apikey=YOUR_API_KEY for a success state looks like the following:

[
        .......
    {
        "Key": "226396",
        "LocalizedName": "Tokyo",
        "EnglishName": "Tokyo",
        "Country": {
            "ID": "JP",
            "LocalizedName": "Japan",
            "EnglishName": "Japan"
        },
        "TimeZone": {
            "Code": "JST",
            "Name": "Asia/Tokyo",
            "GmtOffset": 9,
            "IsDaylightSaving": false,
            "NextOffsetChange": null
        },
        "GeoPosition": {
            "Latitude": 35.68301,
            "Longitude": 139.809,
            "Elevation": {
                "Metric": {
                    "Value": 1,
                    "Unit": "m",
                    "UnitType": 5
                },
                "Imperial": {
                    "Value": 3,
                    "Unit": "ft",
                    "UnitType": 0
                }
            }
        },
        "LocalObservationDateTime": "2019-04-06T12:50:00+09:00",
        "EpochTime": 1554522600,
        "WeatherText": "Sunny",
        "WeatherIcon": 1,
        "HasPrecipitation": false,
        "PrecipitationType": null,
        "IsDayTime": true,
        "Temperature": {
            "Metric": {
                "Value": 17.8,
                "Unit": "C",
                "UnitType": 17
            },
            "Imperial": {
                "Value": 64,
                "Unit": "F",
                "UnitType": 18
            }
        },
        "MobileLink": "http://m.accuweather.com/en/jp/tokyo/226396/current-weather/226396?lang=en-us",
        "Link": "http://www.accuweather.com/en/jp/tokyo/226396/current-weather/226396?lang=en-us"
    },
        .......
]

We can create the following models to get the properties we need for the app:

// TopCityWeather.swift

import Foundation

struct TopCityWeather: Codable {
    let Key: String
    let LocalizedName: String
    let EnglishName: String
    let WeatherText: String
    let WeatherIcon: Int
    let IsDayTime: Bool
    let Country: Country
    let Temperature: Temperature
}

struct Country: Codable {
    let ID: String
    let LocalizedName: String
    let EnglishName: String
}

struct Temperature: Codable {
    let Metric: TemperatureParameters
    let Imperial: TemperatureParameters
}

struct TemperatureParameters: Codable {
    let Value: Double
    let Unit: String
    let UnitType: Int
}

Since I also want to display the time when the data has been fetched from the API, we can create a WeatherFeed object to store the array of TopCityWeather and the formatted date like so:

// WeatherFeed.swift

struct WeatherFeed: Codable {
    let fetchedAt: String
    var topCityWeathers: [TopCityWeather]
    
    static var dateFormatter: DateFormatter {
        let df = DateFormatter()
        df.dateStyle = .medium
        df.timeStyle = .medium
        return df
    }
    
    init(topCityWeathers: [TopCityWeather]) {
        let now = Date()
        self.fetchedAt = WeatherFeed.dateFormatter.string(from: now)
        self.topCityWeathers = topCityWeathers
    }
}

Since the AccuWeather API can also return an error payload like this:

{
    "Code": "Unauthorized",
    "Message": "Api Authorization failed",
    "Reference": "/currentconditions/v1/topcities/50?apikey=ynSLZePZor51M0AcMyEKVBx7GRoTxEB"
}

Let’s also create an object to encapsulate this error:

// AccuWeatherError.swift

import Foundation

struct AccuWeatherError: Codable {
    let Code: String
    let Message: String
}

extension AccuWeatherError {
    func toError(statusCode code: Int) -> Error {
        let error = NSError(domain: "AccuWeather",
                            code: code,
                            userInfo: [
                                NSLocalizedDescriptionKey : "\(self.Code) | \(self.Message)"
                            ]
        )
        return error as Error
    }
}

Usage

Our application is composed of a UITableViewController that I simplified below but can be found on GitHub:

// WeatherViewController.swift

import UIKit

class WeatherViewController: UITableViewController {
    
    // 1
    let apiProvider: APIProvider
    
    var weatherFeed: WeatherFeed?
    
    init(apiProvider: APIProvider) {
        self.apiProvider = apiProvider
        super.init(nibName: nil, bundle: nil)
    }
    
    .......

    // 2
    private func loadTop50CitiesCurrentWeather() {
        apiProvider.loadTop50CitiesCurrentWeather { [weak self] result in
            guard let strongSelf = self else { return }
            switch result {
            case .success(var weatherFeed):

                // 3
                let filteredTopCityWeathers = weatherFeed.topCityWeathers.sorted(by: { (left, right) -> Bool in
                    return left.EnglishName < right.EnglishName
                })
                weatherFeed.topCityWeathers = filteredTopCityWeathers
                strongSelf.weatherFeed = weatherFeed
                strongSelf.reloadData()
                
                // 4
                do {
                    try NSKeyedArchiverManager.archive(object: strongSelf.weatherFeed!, toFile: NSKeyedArchiverManager.Paths.weatherFeed)
                } catch {
                    strongSelf.displayError(title: error.localizedDescription)
                }
                        
            case .failure(let requestError):
                
                // 5
                do {
                    if let weatherFeedUnarchived: WeatherFeed = try NSKeyedArchiverManager.unarchive(fromFile: NSKeyedArchiverManager.Paths.weatherFeed) {
                        strongSelf.weatherFeed = weatherFeedUnarchived
                        strongSelf.reloadData()
                    }
                    
                    strongSelf.displayError(title: requestError.localizedDescription)
                    
                } catch {
                    strongSelf.displayError(title: requestError.localizedDescription, message: error.localizedDescription)
                }
                
            }
        }
    }

    .......
}

Some explanation on this code following the numbered comments:

  1. An APIProvider object responsible for fetching the data from the AccuWeather API is passed by Dependency Injection to the Controller. I am not going to detail this part but it can be found here.
  2. The APIProvider is called to fetch the data from the AccuWeather API.
  3. If the network response is successful, we get a WeatherFeed object that is filtered by the EnglishName property before being stored in the weatherFeed variable. The UITableView is reloaded to display the data.
  4. We finally call archive(object:toFile:) from the NSKeyedArchiverManager to store the WeatherFeed object locally.
  5. In case of failure in the network response, we try to find if a WeatherFeed object is stored locally by calling unarchive(fromFile:) from the NSKeyedArchiverManager to display to the user. We finally display an error.

Wrap up

Implementing an NSKeyedArchiverManager make it really easy to archive and unarchive objects locally. You can now use those objects to provide an offline interaction to your users when the use case makes it relevant.

I hope you like this blog post and as always, do not hesitate to share your thoughts and feedback on it. It would be interesting to know which approach do you use to display content while being offline. Thank you.