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_CreateModelom/content/images/2015/08/CoreData_CreateModel.png)
Add an Entity named Track, then we add track properties as listed in SoundCloud HTTP API:
[
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:
[
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.