© Bruce Wade 2016

Bruce Wade, OS X App Development with CloudKit and Swift, 10.1007/978-1-4842-1880-8_8

8. Adding Local Cache to Improve Performance

Bruce Wade

(1)Suite No. 1408, North Vancouver, British Columbia, Canada

At this point we have an almost fully functional app. I say “almost” because I left you with a few challenges to complete yourself to finish this app. Your challenges include creating the default thumbnail for the park and loading the full-size image when someone double-clicks on the park image. Apple has an example that will show you exactly how to do this using the Collection View. Find the sample code: CocoaSlideCollection: Using NSCollectionView on OS X 10.11.

In this chapter we are going to focus more on performance. Currently, every time we run the app there is a short delay when the records are pulled from CloudKit, or when we select a park and the park images are downloaded. We also are following some not-so-ideal practices; for example, when we are downloading a park we are getting all the properties, including the thumbnail assets, at the same time. Even though we have generated smaller thumbnail assets they still take time to download. Finally, when we download the park images we are not only downloading the thumbnail but also the full-size image, which could be quite large.

Caching Park Records

We will first start to improve performance by creating a local cache of our parks list. This will allow us to load the cached data when the app starts instead of waiting for the query, which will make the app appear to be faster.

We need to make our Park object conform to NSCoding, so update the Park class to inherit NSCoding:

class Park: NSObject, NSCoding {

As soon as you do this you will see compiler errors because we still need to implement two required methods to conform to NSCoding, one for encoding the data and the other for decoding the data.

Add the code in Listing 8-1; it decodes the data for creating a new park object.

Listing 8-1. Decode the Park Object
required init(coder aDecoder: NSCoder) {
   let recordName = aDecoder.decodeObjectForKey("recordName") as! String
   self.recordID = CKRecordID(recordName: recordName)
   self.name = aDecoder.decodeObjectForKey("name") as! String
   self.overview = aDecoder.decodeObjectForKey("overview") as! String
   self.location = aDecoder.decodeObjectForKey("location") as! String
   self.isFenced = aDecoder.decodeBoolForKey("isFenced")
   self.hasFreshWater = aDecoder.decodeBoolForKey("hasFreshWater")
   self.allowsOffleash = aDecoder.decodeBoolForKey("allowsOffleash")
   super.init()
}

In this method we are using the NSCoder to decode the different keys used to build our class object. The only more-complicated aspect of this function is getting the recordName from the decoder and using that to create a new CKRecordID object. Everything else is self-explanatory; if you have never used NSCoder before refer to Apple’s documentation for more information.

The next function will handle encoding our object so it can be saved to disk (Listing 8-2).

Listing 8-2. Encode the Park Object
func encodeWithCoder(aCoder: NSCoder) {
    aCoder.encodeObject(recordID.recordName, forKey: "recordName")
    aCoder.encodeObject(name, forKey: "name")
    aCoder.encodeObject(overview, forKey: "overview")
    aCoder.encodeObject(location, forKey: "location")
    aCoder.encodeBool(isFenced, forKey: "isFenced")
    aCoder.encodeBool(hasFreshWater, forKey: "hasFreshWater")
    aCoder.encodeBool(allowsOffleash, forKey: "allowsOffleash")
}

This method does the opposite of the previous function. It takes our already created Park object and gets it ready to be saved out as different key values in a saveable way.

Next we need to add a few methods to our ParkListViewController to handle loading and saving our parks to disk. The cache is only going to be loaded on first load and will be updated every time the query to the server has finished returning new data. Let’s create a method that will tell our encoder where to save the park list information (Listing 8-3).

Listing 8-3. Determining the Cache Path
func cachePath() -> String {
    let paths = NSSearchPathForDirectoriesInDomains(.CachesDirectory, .UserDomainMask, true)
    let path = paths[0].stringByAppendingString("/parks.cache")
    return path
}

Here we are saying that we want to add a file called parks.cache inside the user’s cache directory for this app.

Next, let’s add another function that will be used to actually write our data to disk (Listing 8-4).

Listing 8-4. Save List of Parks to Disk
func persist() {
    let data = NSMutableData()
    let archiver = NSKeyedArchiver(forWritingWithMutableData: data)
    archiver.encodeRootObject(parks)
    archiver.finishEncoding()
    data.writeToFile(cachePath(), atomically: true)
}

Now that our Park object knows how to encode itself, we simply pass the list of parks into an NSKeyedArchiver to encode it. Once that is done we write the encoded data to our cache directory, saving it into the parks.cache file.

While it is great that we can encode and save the data, we also need a way to load it back from disk. Let’s create a function to do this now (Listing 8-5).

Listing 8-5. Load the Parks from Disk
func loadCache() {
   let path = cachePath()
   if let data = NSData(contentsOfFile: path) where data.length > 0 {
      let decoder = NSKeyedUnarchiver(forReadingWithData: data)
      let object: AnyObject! = decoder.decodeObject()
      if object != nil {
         dispatch_async(dispatch_get_main_queue(), { [unowned self] () -> Void in
            self.parks = object as! [Park]
         })
      }
   }    
}

This method checks to see whether we have any data for the parks stored in the cache directory, and if we do our code will use the NSKeyedUnarchiver to try to decode it into an object. If that is successful we then cast the object into an array of Park objects and update our local parks array on the main queue. It is important to use the main queue here as we are using data binding, and as soon as the local parks array changes, our Table View will update. It is also important that we check if the data even exists before we try to decode it, because on the first run of our app there will be no cached data.

We have a few more updates to make before this will work. First let’s update the api.fetchParks call inside viewDidLoad. After you set the local parks variable, call the persist function to write the parks to disk so that the next time we load the app we have cached data to use (Listing 8-6).

Listing 8-6. Updated fetchParks API Call to Persist the Parks That Were Returned
api.fetchParks { [unowned self] (parks) -> Void in
    dispatch_async(dispatch_get_main_queue()) {
       self.parks = parks
       self.persist()
    }
}

Lastly, we need to tell our app to try to load from the cache when we first start the app. The best place to do this is inside the MainSplitViewController. Open that file and, after setting the masterViewController’s delegate, call loadCache on the masterViewController inside the viewDidLoad method (Listing 8-7).

Listing 8-7. Updated viewDidLoad to loadCache on Startup
override func viewDidLoad() {
   super.viewDidLoad()
   masterViewController.delegate = self
   masterViewController.loadCache()
   detailViewController.delegate = self
}

Now you should be able to build and run your app. The first time you do so you will notice a delay. Stop your app and run it again, and you should notice the park list loads instantaneously, aside from the actual park images, which still have a delay before they are loaded. We will work on setting up another cache for the park thumbnail images next.

We need to update how we fetch parks before we look at caching the images; otherwise, we will be performing duplicate queries to the server for the image assets. Unfortunately, in the initial implementation of our fetchParks API we used the convenience API. We are going to have to replace this with a CKQueryOperation, which will enable us to set the desired keys instead of returning the entire record. Using the CKQueryOperation also gives us the ability to implement pagination using cursors. We will cover the cursors being used; however, it is up to you to get paging working if you wish to implement it, which is highly recommended. Replace the fetchParks function in the API class with Listing 8-8.

Listing 8-8. Updated fetchParks Method
func fetchParks(completionHandler: [Park] -> Void) {
   let parksPredicate = NSPredicate(value: true)
   let query = CKQuery(recordType: "Parks", predicate: parksPredicate)
   let queryOp = CKQueryOperation(query: query)
   queryOp.desiredKeys = ["name", "location", "overview", "isFenced", "hasFreshWater", "allowsOffleash"]
   runOperation(queryOp, completionHandler: completionHandler)
}

We have removed the convenience API call and instead created a CKQueryOperation, passing in our query object. We then tell the query operation which record keys we want returned. Finally, we pass our query operation and our completionHandler to a new function, which we are going to implement next. Now add the code from Listing 8-9 below the fetchPark method in the API class.

Listing 8-9. New API Method for Using CKQueryOperation
func runOperation(queryOp: CKQueryOperation,completionHandler: [Park] -> Void) {
     queryOp.queryCompletionBlock = { cursor, error in
        if self.isRetryableCKError(error) {
           let userInfo: NSDictionary = (error?.userInfo)!
           if let retryAfter = userInfo[CKErrorRetryAfterKey] as? NSNumber {
              let delay = retryAfter.doubleValue * Double(NSEC_PER_SEC)
              let time = dispatch_time(DISPATCH_TIME_NOW, Int64(delay))
              dispatch_after(time, dispatch_get_main_queue()) {
                 self.runOperation(queryOp, completionHandler: completionHandler)
              }
              return
           }
        }
        self.queryFinished(cursor, error: error, completionHandler: completionHandler)
        if cursor != nil {
           self.queryNextCursor(cursor!, completionHandler: completionHandler)
        } else {
           completionHandler(self.parks)
        }
     }
     queryOp.recordFetchedBlock = { record in
        self.fetchedPark(record)
     }
     publicDB.addOperation(queryOp)
}

This is a big change, so let’s go through it carefully. First we create a query completion block for our query operation. This block will take a cursor and an error. If we limit the amount of records a query can return then the cursor will be a non-nil value that can be used to perform the next query (pagination). After our call we pass the error into a new custom function that determines if the error means we should return the query. Create this new function below the last one in the API class (Listing 8-10).

Listing 8-10. Code to Check for Errors and Determine if It Is a Retryable Error
private func isRetryableCKError(error: NSError?) -> Bool {
   var isRetryable = false
   if let error = error {
      let isErrorDomain = error.domain == CKErrorDomain
      let errorCode: Int = error.code
      let isUnavailable = errorCode == CKErrorCode.ServiceUnavailable.rawValue
      let isRateLimited = errorCode == CKErrorCode.RequestRateLimited.rawValue
      let errorCodeIsRetryable = isUnavailable || isRateLimited
          isRetryable = isErrorDomain && errorCodeIsRetryable
   }
   return isRetryable
}

If we should retry the query, we pull the information we need from the user’s info dictionary to calculate the time we must wait before trying to perform the query again. Next we call a new function, passing in our cursor, error, and completionHandler, which handles updating the parks array by passing it to our completion handler. Listing 8-11 is the code for this new function.

Listing 8-11. Code Called When the Query Is Finished
func queryFinished(cursor: CKQueryCursor!, error: NSError!, completionHandler: [Park] -> Void) {
   completionHandler(self.parks)
}

Next we check if there is a valid cursor, and if there is it means we still have more parks that we must fetch from the server. We call a new function to handle this. If there are no more parks to fetch we will not call this function (which will always be our case because by limiting our results we are not taking advantage of the cursor). Listing 8-12 is the code for the queryNextCursor function in the API class.

Listing 8-12. Running a New Query If There Are Still Results to Be Fetched
func queryNextCursor(cursor: CKQueryCursor,completionHandler: [Park] -> Void) {
   let queryOp = CKQueryOperation(cursor: cursor)
   runOperation(queryOp, completionHandler: completionHandler)
}

This function simply creates a new CKQueryOperation using the cursor we passed in, then we call the runOperation again with our new query operation and repeat the process.

After creating our query completion block, we create another block, recordFetchedBlock (we create this block in the runOperation method), which is called whenever there is a new record returned. In this block we are calling another new function, fetchedPark, that will update our parks array. Listing 8-13 provides the new function in the API class.

Listing 8-13. Code Called Whenever a New Park Is Fetched; It Updates Our Parks Array
func fetchedPark(parkRecord: CKRecord) {
    var index = NSNotFound
    var park: Park!
    var isNewPark = true
    for (idx, value) in self.parks.enumerate() {
        if value.recordID == parkRecord.recordID {
           // Here we could check to see if the park pulled has been updated and update our list if needed.
           // This is left for an exercise to the reader.
           index = idx
           park = value
           isNewPark = false
           break
        }
    }
    if index == NSNotFound {
       park = self.convertCKRecordToPark(parkRecord)
       self.parks.append(park)
       index = self.parks.count - 1
    }
}

This function loops through the currently existing parks in the array. If the new record matches one in the parks array, we break out of the loop. Note that we are currently not using this data to update an existing park, but we definitely should. Now if the new park doesn’t already exist in the list we convert the record into a park and append it to the parks array, then we set up the next index. We are currently not using the index. However, if you implement pagination you would use this to pass to a new function. This new function would handle inserting the park into the correct index in the array controller that is used in the Table View. In our case we just always return the entire parks array to the completion handler, so we don’t need it in our example app as it stands.

We add the operation to our public database. If you run the app again it will run exactly as it did before; however, now we are limiting our results to only the record keys we have queried for. This also means that the thumbnail will now always be the default thumbnail. Before we move on to loading and caching the thumbnail, let’s update the convertCKRecordToPark function. In the code that creates a Park instance, remove the parameter that sets the thumbnail, as well as the first few lines in the method that checks for the thumbnail asset (which we will not have any longer).

Finally, to fix the compiler error, update the Park init method to look like Listing 8-14.

Listing 8-14. Updated Park init Method
init(recordID: CKRecordID, name: String, overview: String, location: String, isFenced: Bool, hasFreshWater: Bool, allowsOffleash: Bool) {
   self.recordID = recordID
   self.name = name
   self.overview = overview
   self.location = location
   self.isFenced = isFenced
   self.hasFreshWater = hasFreshWater
   self.allowsOffleash = allowsOffleash
   super.init()
   self.thumbnail = NSImage(named: "DefaultParkIcon")!
}

Here we remove the thumbnail parameter and replace the other thumbnail code with code to set the default thumbnail. When running the code again everything should work as before, only this time you will see the default thumbnails for each park.

Caching and Loading Park Thumbnails

Now we have enough code in place to look at loading and caching the thumbnails. Let’s set up our image cache path with the following function and add this to the API.swift file (Listing 8-15).

Listing 8-15. Code to Determine the Thumbnail Cache Path
func imageCachePath(recordID: CKRecordID) -> String {
   let paths = NSSearchPathForDirectoriesInDomains(.CachesDirectory, .UserDomainMask, true)
   let path = paths[0].stringByAppendingString("/(recordID.recordName)")
   return path
}

Here we are using the park’s recordID record namewhich we pass into this methodas the file name. Let’s now create a new function that will either load the cached thumbnail or query the server for the thumbnail and then cache it locally. Listing 8-16 shows the code that also appears in the API.swift file.

Listing 8-16. Either Load a Cached Thumbnail or Query CloudKit for One
func loadParkThumbnail(parkRecordID: CKRecordID, completion: (photo: NSImage!) -> Void) {
   let backgroundQueue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_BACKGROUND, 0)


dispatch_async(backgroundQueue) { () -> Void in
         let imagePath = self.imageCachePath(parkRecordID)
         if NSFileManager.defaultManager().fileExistsAtPath(imagePath) {
            let image = NSImage(contentsOfFile: imagePath)
            completion(photo: image)
         } else {
            let fetchOp = CKFetchRecordsOperation(recordIDs: [parkRecordID])
            fetchOp.desiredKeys = ["thumbnail"]
            fetchOp.fetchRecordsCompletionBlock = {
               records, error in
               self.processThumbnailAsset(parkRecordID, records: records, error: error, completionHandler: completion)
            }
            self.publicDB.addOperation(fetchOp)
         }
    }
}

We create a background queue to use as we are working with the image. On the background thread, we get the imagePath from the image cache for the current park record ID. With this image path, we use the file manager to check if the file exists. If it does exist we use this file and pass it to the completion handler. If the file does not exist we create a new CKFetchRecordsOperation using the parkRecordID and we fetch the thumbnail field. We create a completion block for the returned record and call a new function that processes the thumbnail asset. Finally, we add the operation to our public database.

Now let’s look at the processThumbnailAsset function, which downloads the asset and saves it into cache for the next time the app loads (Listing 8-17).

Listing 8-17. Download a Thumbnail Asset and Save It to Cache
func processThumbnailAsset(parkRecordID: CKRecordID, records: [NSObject: AnyObject]!, error: NSError!, completionHandler:(thumbnail: NSImage!) -> Void) {
     if error != nil {
        completionHandler(thumbnail: nil)
     }
     let updatedRecord = records[parkRecordID] as! CKRecord
     if let asset = updatedRecord.objectForKey("thumbnail") as? CKAsset {
        let url = asset.fileURL
        let thumbnail = NSImage(contentsOfFile: url.path!)
        do {
           try NSFileManager.defaultManager().copyItemAtPath(url.path!, toPath: imageCachePath(parkRecordID))
        } catch {
          print("There was an issue copying the image")
        }
        completionHandler(thumbnail: thumbnail)
      } else {
        completionHandler(thumbnail: nil)
      }
}

If there is an error we pass nil to the completion handler; otherwise, we use the park record ID to get the CKRecord out of the returned results. Next we check if the record has a thumbnail asset, and if it does we load the thumbnail and then try to copy the file to our imageCachePath. This can fail if the file already exists. Finally, we pass the completion handler our new thumbnail.

Now let’s move our focus over to the ParkListViewController and create a new function called LoadThumbnails. This function will loop though all the parks and call our new loadParkThumbnail API call, passing in the park recordID. We update the target park’s thumbnail on the main thread via the completion handler if there was a photo returned and then reload the table data (Listing 8-18).

Listing 8-18. Load the Thumbnail Images for a Selected Park
func loadThumbnails() {
   for (index, park) in self.parks.enumerate() {
      api.loadParkThumbnail(park.recordID) { (photo) -> Void in
         dispatch_async(dispatch_get_main_queue()) {
           if photo != nil {
              self.parks[index].thumbnail = photo
              self.tableView.reloadDataForRowIndexes(NSIndexSet(index: index), columnIndexes: NSIndexSet(index: 0))
           }
         }
      }
   }
}

Finally, we need to update the api.fetchParks completion block to call our loadThumbnails function (Listing 8-19).

Listing 8-19. Updated fetchParks API Call to Load Thumbnails
api.fetchParks { [unowned self] (parks) -> Void in
   dispatch_async(dispatch_get_main_queue()) {
     self.parks = parks
     self.loadThumbnails()
     self.persist()
   }
}

If you run the app you will still notice a little delay, so also update the loadCache method so as to call the same loadThumbnails inside the dispatch_async after we set the park’s variable (Listing 8-20).

Listing 8-20. Updated loadCache Method to Also Load Thumbnails
func loadCache() {
   let path = cachePath()
   if let data = NSData(contentsOfFile: path) where data.length > 0 {
      let decoder = NSKeyedUnarchiver(forReadingWithData: data)
      let object: AnyObject! = decoder.decodeObject()
      if object != nil {
         dispatch_async(dispatch_get_main_queue(), { [unowned self]() -> Void in
           self.parks = object as! [Park]
           self.loadThumbnails()
         })
      }
   }
}

Now if you rerun the app you will see an instant update.

Caching the Park Images

At this point you should know how to implement caching for the park images. So instead of walking you step by step through it, I will leave you with some pointers on how to accomplish it on your own. I always find that I learn the most when I am forced to implement something myself.

First, you will have to create a cache path, and because record IDs are always unique, you can use the record ID for each of the park images. If you want to take it a step further, you can create a directory using the park record ID as the name and store all the park images in that folder. Remember that we can delete park images, so you are going to want to remove any park images from the cache so they aren’t loaded on the reload.

You are also going to want to update the query so it only returns the thumbnail instead of both the thumbnail and the full-size image. This is going to require you to replace the convenience API in the fetchParkImages function. This will give you an opportunity to make some of the functions we created in the last section more generic so you can reuse them in this section.

Additional Suggested Updates

Wouldn’t it be nice to allow users to post comments on different parks? It would be a nice challenge for you to create a new record type for notes that uses the park as a reference. The query for notes is going to be very similar to the park images.

What about adding additional information to the park, like park hours and the actual GPS coordinates for the park, which could then be used to plot the parks on a map?

Conclusion

This chapter covered a lot of ground and required us to part ways with the convenience API so we could have more control over the data we get from the server. We learned a way to cache objects and images. We learned how to load cached data, making our app more responsive and seem more like it has an instantaneous load, instead of feeling the delay when the app first loads.

This brings us to the end of the book. Feel free to submit pull requests at https://github.com/warplydesigned/DogParksOSX for any new features you have added and want to share with others who have also been working on this app.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset