/ core data

CoreData Syncing with Swift

When developing an app which its data is fetched from remote server and need to be cached inside for faster data displaying, you should really go with an efficient way in both performance and code involved. Here we have several requirements for our latest iOS app:

  • Data should be fetched from a remote server when app is started
  • Data should be stored inside app, and the user interface displays data from internal database only
  • Fetched data should be saved and synced with internal database in background, and notify UI thread when it finished its work
  • The remote server returns data in form of JSON and need to be parsed, checked for duplication and validity before save to internal database
  • Data here will be tracks, fetched from SoundCloud

With the above requirements, we decide to use CoreData as internal data store, Alamofire as network controller and a third-party library SwiftyJSON to parse SoundCloud response and insert to CoreData. Then each time new data arrive, we perform CoreData syncing to eliminate duplicate.

Preparation

First, we use CocoaPods as dependency management. We will also have SwiftyJSON for easier JSON handling. So we add Alamofire and SwiftyJSON in Podfile as dependencies as follow:

source 'https://github.com/CocoaPods/Specs.git'
platform :ios, '8.0'
use_frameworks!

pod 'SwiftyJSON'
pod 'Alamofire'

Run pod install against our working directory to have CocoaPods correctly installed and then, open up our workspace in XCode.

CoreData Modeling

Because our project is created without CoreData support at beginning, we need to manually add CoreData stack to our delegation.

First, we add a CoreData Data Model file to declare our model:

CoreData_CreateModel

Add an Entity named Track, then we add track properties as listed in SoundCloud HTTP API:

CoreData_TrackEntity

As our project is started without CoreData from beginning, we need to add XCode boilerplace code to AppDelegate.swift to ensure our CoreData stack is initialized properly:

import CoreData

@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
  var window: UIWindow?

  func application(application: UIApplication, didFinishLaunchingWithOptions launchOptions: [NSObject: AnyObject]?) -> Bool {
    return true
  }

  func applicationWillTerminate(application: UIApplication) {
    // Called when the application is about to terminate. Save data if appropriate. See also applicationDidEnterBackground:.
    // Saves changes in the application's managed object context before the application terminates.
    self.saveContext()
  }

  override func remoteControlReceivedWithEvent(event: UIEvent) {
    PlayService.get.remoteControlReceivedWithEvent(event)
  }

  // MARK: - Core Data stack

  lazy var applicationDocumentsDirectory: NSURL = {
    // The directory the application uses to store the Core Data store file. This code uses a directory named "com.xxxx.ProjectName" in the application's documents Application Support directory.
    let urls = NSFileManager.defaultManager().URLsForDirectory(.DocumentDirectory, inDomains: .UserDomainMask)
    return urls[urls.count-1] as! NSURL
  }()

  lazy var managedObjectModel: NSManagedObjectModel = {
    // The managed object model for the application. This property is not optional. It is a fatal error for the application not to be able to find and load its model.
    let modelURL = NSBundle.mainBundle().URLForResource("Track", withExtension: "momd")!
    return NSManagedObjectModel(contentsOfURL: modelURL)!
  }()

  lazy var persistentStoreCoordinator: NSPersistentStoreCoordinator? = {
    // The persistent store coordinator for the application. This implementation creates and return a coordinator, having added the store for the application to it. This property is optional since there are legitimate error conditions that could cause the creation of the store to fail.
    // Create the coordinator and store
    var coordinator: NSPersistentStoreCoordinator? = NSPersistentStoreCoordinator(managedObjectModel: self.managedObjectModel)
    let url = self.applicationDocumentsDirectory.URLByAppendingPathComponent("PlayMe.sqlite")
    var error: NSError? = nil
    var failureReason = "There was an error creating or loading the application's saved data."
    if coordinator!.addPersistentStoreWithType(NSSQLiteStoreType, configuration: nil, URL: url, options: nil, error: &error) == nil {
      coordinator = nil // Report any error we got.
      var dict = [String: AnyObject]()
      dict[NSLocalizedDescriptionKey] = "Failed to initialize the application's saved data"
      dict[NSLocalizedFailureReasonErrorKey] = failureReason
      dict[NSUnderlyingErrorKey] = error
      error = NSError(domain: Config.ERROR_DOMAIN_COMMON, code: 9999, userInfo: dict)
      // Replace this with code to handle the error appropriately.
      // abort() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development.
      NSLog("Unresolved error \(error), \(error!.userInfo)")
      abort()
    }

    return coordinator
  }()

  lazy var managedObjectContext: NSManagedObjectContext? = {
    // Returns the managed object context for the application (which is already bound to the persistent store coordinator for the application.) This property is optional since there are legitimate error conditions that could cause the creation of the context to fail.
    let coordinator = self.persistentStoreCoordinator
    if coordinator == nil {
      return nil
    }
    var managedObjectContext = NSManagedObjectContext()
    managedObjectContext.persistentStoreCoordinator = coordinator
    return managedObjectContext
  }()

  // MARK: - Core Data Saving support

  func saveContext () {
    if let moc = self.managedObjectContext {
      var error: NSError? = nil
      if moc.hasChanges && !moc.save(&error) {
        // Replace this implementation with code to handle the error appropriately.
        // abort() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development.
        NSLog("Unresolved error \(error), \(error!.userInfo)")
        abort()
      }
    }
  }
}

As you can see, it contains a lot of code which we do not know about. However, these methods are exactly what XCode creates for us if we create a project with CoreData support from scratch. Replace the modelUrl and persistentStore with your own CoreData model name and app name.

Generate Model Class From CoreData

Select your CoreData model, then go to Editor -> Create NSManagedObject Subclass… This will help us to create corresponding model class for our CoreData automatically:

CoreData_CreateObject

Then, go to our newly created class, and add the symbol @objc(<your-class-name>) so the framework can understand your model is linked to this class:

@objc(Track) class Track: NSManagedObject {
  @NSManaged var alreadyPlayed: NSNumber
  ...
}

Network Part

Back to our network code, we will create a function within a SoundCloudService class to do remote job, then use SwiftyJSON to handle result from Alamofire, note that we pass the returned result back to the caller, so we no need to insert login handling for our model at here:

import Foundation
import Alamofire
import SwiftyJSON

class SoundCloudService {
  static func fetchData(fromSavedUrl url: String, callback: ((JSON?, NSError?) -> Void)?) {
    Alamofire.request(.GET, url).responseJSON { (_, _, _json, _error) -> Void in
      if let error = _error {
        callback?(nil, error)
      } else {
        var json = JSON(_json!)
        callback?(json, nil)
      }
    }
  }
}

Pay attention to our callback variable:

callback: ((JSON?, NSError?) -> Void)?)

It means, we need an optional callback, which accept two parameters, first is an optional JSON, and second is an optional NSError, this function should return nothing, or Void. So if our request is successful, then we pass the callback the result in first parameter, and let the second be nil. If it fails, then we do otherwise.

Sync with CoreData Using id From SoundCloud

Why we need to sync, in this simple situation? Imagine that:

  • We get first 50 results from SoundCloud API, it returns to us 50 results, which all of them have different ids. We save all of them to CoreData.
  • Later, we get next 50 results when we need. But now, then 50 returned results are not necessary identical with our saved data. So if we continue to insert all of them, then we have duplicate objects.
  • So, we need to loop through all saved data, and compare their ids, with every single object that we received from our response. If any id exists, then we skip that object, or we can perform an update, instead of insertion.

Now the implementation, we will create a DataProvider class to call our network code:

func fetchNextTracks() {
  func callback(_json: JSON?, _error: NSError?) -> Void {
    if let error = _error {
      // handle error here
    } else if let json = _json {
      if let nextHref = json["next_href"].string {
        // save the url of "next page"
      }
      if let trackArray = json["collection"].array {
        storeFetched(trackArray)
      }
    }
  }
  if let nextHref = Prefs.sharedInstance.savedNextSoundCloudHref {
    SoundCloudService.fetchData(fromSavedUrl: nextHref, callback: callback)
  }
}

First, we declare a callback, which will call function storeFetched if it receive valid data, then we call the service we created earlier with the callback.

func storeFetched(tracks: [JSON]) { ... }

Now the storeFetched function, first, we obtain an instance of NSManagedObjectContext:

let managedObjectContext = (UIApplication.sharedApplication().delegate as! AppDelegate).managedObjectContext

Prepare a request to fetch saved data with id from service:

let fetchRequest = NSFetchRequest(entityName:"Track")

Create another entityDescription, so we can use it to add new data:

let entityDescription = NSEntityDescription.entityForName("Track", inManagedObjectContext: managedObjectContext!)

Now, we loop through all objects in our JSON array:

for json in tracks {
    let soundCloudId = json["id"].intValue
    let predicate = NSPredicate(format: "%K == %i", "trackId", soundCloudId)
    fetchRequest.predicate = predicate
    // query for object with specified trackId, note that while SoundCloud returns field "id", we save it in "trackId" name
    var error: NSError?
    let fetchedResults = managedObjectContext!.executeFetchRequest(fetchRequest, error: &error) as? [Track]
    // if there are any result, we just skip
    if let results = fetchedResults {
        if (results.count > 0) {
            continue
        }
    }
    // else, we create the track like this
    let track = Track(entity: entityDescription!, insertIntoManagedObjectContext: self.managedObjectContext)
    track.title = json["title"].stringValue
    track.artworkUrl = json["artwork_url"].stringValue
    track.bpm = json["bpm"].intValue
    track.downloadable = json["downloadable"].intValue
    track.downloadUrl = json["download_url"].stringValue
    track.duration = json["duration"].intValue
    track.genre = json["genre"].stringValue
    track.trackId = json["id"].intValue
    track.label = json["label"].stringValue
    track.labelId = json["label_id"].intValue
    track.sharing = json["sharing"].stringValue
    track.streamable = json["streamable"].intValue
    track.streamUrl = json["stream_url"].stringValue
    track.tagList = json["tag_list"].stringValue
    track.title = json["title"].stringValue
    track.trackDescription = json["description"].stringValue
    track.permalinkUrl = json["permalink_url"].stringValue
    track.userId = json["user_id"].intValue
    track.userName = json["user"]["username"].stringValue
    track.waveformUrl = json["waveform_url"].stringValue 
    track.alreadyPlayed = NSNumber(int: 0)
}
// after the loop, now we save the context
var error: NSError?
managedObjectContext?.save(&error)
if let err = error {
    // error handling here, you can show alert message or anything else
}

Conclusions

So, now you have the idea of how we integrate CoreData into an existing project by adding some boilerplate code. We also use some popular libraries to handle network and JSON part, which iOS native code is too complicated. Finally, we use CoreData to ensure our cached data is up-to-date and do not have duplicate entries.