September 17, 2015
Sometimes we want to keep data between runs of our apps. NSUserDefaults
is fine for a little bit of data. If there’s a lot of data then a full blown database makes sense, maybe Core Data, SQLite or Realm. But what if it’s a middling amount of data? Then use NSKeyedArchiver
. It’s a lot simpler to set up than a database and can hold a lot more data than NSUserDefaults
. If you don’t need fancy database features like querying then NSKeyedArchiver
will save you a lot of coding time.
Today we’ll use NSKeyedArchiver
to handle not having an internet connection in our Stock Quote Page View Controller demo app. We’ll do that by persisting the stock quotes each time we fetch them from the web service.
This tutorial has been updated to use Swift 2.0, Xcode 7.0, Alamofire 3.0.0, and SwiftyJSON 2.3.0.
To start we’ll grab the code from GitHub:
git clone git@github.com:cmoulton/grokRESTPageViewController.git
cd grokRESTPageViewController
git checkout alamofire-3.0
The git checkout alamofire-3.0
bit is needed to make sure we get the code as it was at the end of the last tutorial.
If you’ve been working through the previous tutorials make sure your Podfile is up to date:
source 'https://github.com/CocoaPods/Specs.git'
platform :ios, '9.0'
use_frameworks!
pod 'Alamofire', '~> 3.0.0'
pod 'SwiftyJSON', '~> 2.3.0'
Install the CocoaPods:
pod install
Open the .xcworkspace file in Xcode 7 to start coding.
Adding a Date
Since we’re going to be displaying stock quotes that might be a bit old we should save the date that they were fetched. We’ll add a property to the StockQuoteItem
class for that:
class StockQuoteItem: ResponseJSONObjectSerializable {
let symbol: String?
let ask: String?
let yearHigh: String?
let yearLow: String?
let timeSaved: NSDate?
...
}
We can initialize it when we create a new StockQuoteItem
:
required init?(json: SwiftyJSON.JSON) {
self.symbol = json["symbol"].string
self.ask = json["Ask"].string
self.yearHigh = json["YearHigh"].string
self.yearLow = json["YearLow"].string
self.timeSaved = NSDate()
}
Calling NSDate()
will give us the current date and time.
Implement NSCoding
NSKeyedArchiver
saves and loads objects & values using the NSCoding
protocol. Lots of types like Array
, String
, and all of the numeric types already implement NSCoding
so those are easy to save. In the next section we’ll sort out just how to save and load the objects. First we need to make our custom objects implement NSCoding
so we don’t get crashes when we load and save.
There are 2 required functions in the NSCoding
protocol:
public protocol NSCoding {
public func encodeWithCoder(aCoder: NSCoder)
public init?(coder aDecoder: NSCoder)
}
NSCoder
is a super class of NSKeyedArchiver
and NSKeyedUnarchiver
, which is why we don’t see NSKeyedArchiver
or NSKeyedUnarchiver
here.
The first NSCoding
function is to save the object and the second method function is to re-create the object when we load it. We’ll be saving & loading our StockQuoteItem
objects so we need to make that class conform to this protocol:
class StockQuoteItem: NSObject, NSCoding, ResponseJSONObjectSerializable {
...
// MARK: - NSCoding
func encodeWithCoder(aCoder: NSCoder) {
// TODO: implement
}
required convenience init?(coder aDecoder: NSCoder) {
// TODO: implement
}
}
We need to add NSObject
as well since NSCoding
uses it behind the scenes. Maybe someday we’ll get a more Swift-y alternative.
Those two functions are used to convert our object to and from an NSCoder
. To encode we can use encodeObject: forKey:
:
func encodeWithCoder(aCoder: NSCoder) {
aCoder.encodeObject(self.symbol, forKey: "symbol")
aCoder.encodeObject(self.ask, forKey: "ask")
aCoder.encodeObject(self.yearHigh, forKey: "yearHigh")
aCoder.encodeObject(self.yearLow, forKey: "yearLow")
aCoder.encodeObject(self.timeSaved, forKey: "timeSaved")
}
Then to decode the object we can use decodeObjectForKey:
:
required convenience init?(coder aDecoder: NSCoder) {
let symbol = aDecoder.decodeObjectForKey("symbol") as? String
let ask = aDecoder.decodeObjectForKey("ask") as? String
let yearHigh = aDecoder.decodeObjectForKey("yearHigh") as? String
let yearLow = aDecoder.decodeObjectForKey("yearLow") as? String
let timeSaved = aDecoder.decodeObjectForKey("timeSaved") as? NSDate
// TODO: create object from decode values
}
If you have other types of properties then you can use similar functions for your type, like encodeBool: forKey:
and encodeInteger: forKey:
.
Now we need to create an instance at the end of the new init
function. Here’s our current init
:
required init?(json: SwiftyJSON.JSON) {
self.symbol = json["symbol"].string
self.ask = json["Ask"].string
self.yearHigh = json["YearHigh"].string
self.yearLow = json["YearLow"].string
self.timeSaved = NSDate()
}
We need to create an initializer that takes each property as an argument so let’s refactor the existing one:
// MARK: Initializers
required init(symbol: String?, ask: String?, yearHigh: String?, yearLow: String?, timeSaved: NSDate?) {
self.symbol = symbol
self.ask = ask
self.yearHigh = yearHigh
self.yearLow = yearLow
self.timeSaved = timeSaved
}
required convenience init?(json: SwiftyJSON.JSON) {
self.init(symbol: json["symbol"].string, ask: json["Ask"].string, yearHigh: json["YearHigh"].string, yearLow:json["YearLow"].string, timeSaved: NSDate())
}
Then we can finish up the NSCoding
protocol implementation:
required convenience init?(coder aDecoder: NSCoder) {
let symbol = aDecoder.decodeObjectForKey("symbol") as? String
let ask = aDecoder.decodeObjectForKey("ask") as? String
let yearHigh = aDecoder.decodeObjectForKey("yearHigh") as? String
let yearLow = aDecoder.decodeObjectForKey("yearLow") as? String
let timeSaved = aDecoder.decodeObjectForKey("timeSaved") as? NSDate
self.init(symbol: symbol, ask: ask, yearHigh: yearHigh, yearLow: yearLow, timeSaved: timeSaved)
}
Create a Persistence Manager
To keep our code organized we’ll create a new class that’s responsible for saving and loading objects. Add a PersistenceManager.swift
file to your project:
import Foundation
class PersistenceManager {
}
To save objects we’ll use this NSKeyedArchiver
function:
public class func archiveRootObject(rootObject: AnyObject, toFile path: String) -> Bool
And this NSKeyedUnarchiver
function will load them:
public class func unarchiveObjectWithFile(path: String) -> AnyObject?
Both require a path so let’s set up our PersistenceManager
to be able to create the path:
enum Path: String {
case StockQuotes = "StockQuotes"
}
class PersistenceManager {
class private func documentsDirectory() -> NSString {
let paths = NSSearchPathForDirectoriesInDomains(.DocumentDirectory, .UserDomainMask, true)
let documentDirectory = paths[0] as String
return documentDirectory
}
}
We’ll be able to get the file path like this:
let path = .StockQuotes
let file = documentsDirectory().stringByAppendingPathComponent(path.rawValue)
path.rawValue
gives us the value that we set for the enum, like "StockQuotes"
. We’re using the enum so that we could add more objects with different paths later without polluting the code base with hard-coded file names.
The objects that we want to save and load are NSArray
objects. We couldn’t use Swift arrays since they don’t have an indexOfObject
function and that function made our UIPageViewController
implementation a lot cleaner. So let’s set up loading and saving an NSArray
:
class PersistenceManager {
class private func documentsDirectory() -> NSString {
let paths = NSSearchPathForDirectoriesInDomains(.DocumentDirectory, .UserDomainMask, true)
let documentDirectory = paths[0] as String
return documentDirectory
}
class func saveNSArray(arrayToSave: NSArray, path: Path) {
let file = documentsDirectory().stringByAppendingPathComponent(path.rawValue)
NSKeyedArchiver.archiveRootObject(arrayToSave, toFile: file)
}
class func loadNSArray(path: Path) -> NSArray? {
let file = documentsDirectory().stringByAppendingPathComponent(path.rawValue)
let result = NSKeyedUnarchiver.unarchiveObjectWithFile(file)
return result as? NSArray
}
}
In both cases we just get the file path and then call the NSKeyedArchiver
or NSKeyedUnarchiver
function. When unarchiving we also cast the result back to the expected type before returning it.
We could generalize these function to accept any object that implements NSCoding
using generics but we don’t need to so let’s not do that. Generics are very powerful but they can make code more challenging to read. Like most programming it’s a trade-off whether the complexity is worth the benefit. I don’t think it is here but we could always change it later.
Save and Load the Data
We have the power to archive and unarchive arrays but we need to hook that up in our code.
For archiving, we’ll do it right after we load new stock quotes. We just need to add a call to PersistenceManager.saveNSArray
once we’ve saved the objects:
func loadStockQuoteItems(completionHandler: (NSError?) -> Void) {
let symbols = ["AAPL", "GOOG", "YHOO"]
StockQuoteItem.getFeedItems(symbols, completionHandler:{ (result) in
if let error = result.error {
completionHandler(error)
return
}
if let stocks = result.value {
let mutableObjects = NSMutableArray()
for stock in stocks { // because we're getting back a Swift array but it's easier to do the PageController with an NSArray
mutableObjects.addObject(stock)
}
self.dataObjects = mutableObjects
// save them to the device
PersistenceManager.saveNSArray(self.dataObjects, path: .StockQuotes)
}
// success
completionHandler(nil)
})
}
While working on that code I noticed that we had declared the array of StockQuoteItems
as an NSMutableArray
but we weren’t really adding and removing items from it. So I’ve switched it to var dataObjects = NSArray()
and used a temporary let mutableObjects = NSMutableArray()
to collect up the stocks from the web service before setting them to the property: self.dataObjects = mutableObjects
.
Unarchiving is a little more complicated. Right now when we get an error we just call the completionHandler and return without setting a value for dataObjects
:
if let error = result.error as? NSError {
completionHandler(error)
return
}
We need to change that to get the saved stock quotes, if we have any:
if let error = result.error {
// show the saved values
if let values = PersistenceManager.loadNSArray(.StockQuotes) {
self.dataObjects = values
}
completionHandler(error)
return
}
And we need to update the code that calls that function so it knows that it might have stock quotes to display, even though it got an error. Here’s what it looks like now:
dataController.loadStockQuoteItems{ (error) in
if error != nil {
let alert = UIAlertController(title: "Error", message: "Could not load stock quotes \(error?.localizedDescription)", preferredStyle: UIAlertControllerStyle.Alert)
alert.addAction(UIAlertAction(title: "Click", style: UIAlertActionStyle.Default, handler: nil))
self.presentViewController(alert, animated: true, completion: nil)
} else {
// set first view controller to display
if let firstVC = self.viewControllerAtIndex(0) {
let viewControllers = [firstVC]
self.setViewControllers(viewControllers, direction: UIPageViewControllerNavigationDirection.Forward, animated: false, completion: nil)
}
}
}
Before we add displaying the saved quotes let’s fix that error dialog so it doesn’t show the text like Optional("")
:
dataController.loadStockQuoteItems{ (error) in
if error != nil {
let alert:UIAlertController
if let description = error?.localizedDescription {
alert = UIAlertController(title: "Error", message: "Could not load stock quotes \(description)", preferredStyle: UIAlertControllerStyle.Alert)
} else {
alert = UIAlertController(title: "Error", message: "Could not load stock quotes", preferredStyle: UIAlertControllerStyle.Alert)
}
alert.addAction(UIAlertAction(title: "Ok", style: UIAlertActionStyle.Default, handler: nil))
self.presentViewController(alert, animated: true, completion: nil)
} else {
...
}
}
To tell the page view controller that it has data we can treat the case with an error just like the case without an error:
dataController.loadStockQuoteItems{ (error) in
if error != nil {
...
}
// set first view controller to display
if self.dataController.dataObjects.count > 0 {
if let firstVC = self.viewControllerAtIndex(0) {
let viewControllers = [firstVC]
self.setViewControllers(viewControllers, direction: UIPageViewControllerNavigationDirection.Forward, animated: false, completion: nil)
}
}
}
Notice the change? We just removed the else
from the if error != nil
case. Now the app will try to load stock quotes as long as it has at least one stock quote in dataObjects
, regardless of whether there was an error.
Update the Interface
Now that we’re potentially showing stock quotes that are out of date we should show the time they were fetched to the user. Let’s add the date to the text on each page. In SinglePageViewController
, we’ll create an NSDateFormatter
, use it to convert the timeSaved
to a string and append it to the label’s text:
override func viewWillAppear(animated: Bool) {
super.viewWillAppear(animated)
if let aSymbol = stock?.symbol, anAsk = stock?.ask, high = stock?.yearHigh, low = stock?.yearLow {
dataLabel.text = aSymbol + ": " + anAsk + "\n" + "Year High: " + high + "\n" + "Year Low: " + low
let dateFormatter = NSDateFormatter()
dateFormatter.dateStyle = .ShortStyle
dateFormatter.timeStyle = .ShortStyle
if let date = stock?.timeSaved {
let dateAsString = dateFormatter.stringFromDate(date)
dataLabel.text = dataLabel.text! + "\n" + "Loaded: " + dateAsString
}
} else {
dataLabel.text = stock?.symbol
}
}
Nothing too fancy. You might need to open your storyboard and change the label’s Autoshrink setting so that the text can get smaller to fit it all in:
Save and run to test it out. Let me know if you find any bugs :)
And That’s All
That’s all it takes to load and save objects using NSKeyedArchiver
:
- Implement
NSCoding
on your custom classes - Use
NSKeyedArchiver
andNSKeyedUnarchiver
to read and write to a file - Hook up the saving and loading where you want them (maybe around a web service call to allow read-only offline mode)
- Adjust your UI if needed
Here’s the final code: grokRESTPageViewController tagged persistence.
If you’d like more Swift tutorials on topics like this one, sign up below to get them sent directly to your inbox.