I had originally thought I was going to write about my new design with my CloudKit support I'm using in my new app, but I was reminded that I had not written about integrating with Ensembles as I had promised a while back. So I'll fulfill that promise with this post.
Before I get started, I need to give the standard “legal” disclaimer. This is what worked for me, it may not work for others but hopefully this will be of some help. Second, I am by no means a Swift expert so if the code seems weird, I’m still learning. Finally, this was a test app that I put together to “play” around with Ensembles to see what it could do and make sure it would work for my needs. So it may not be the best design. Buyer be ware!
Ok, so what is Ensembles. I will refer you to the website for most of the information which you can find here: http://www.ensembles.io/. Basically it allows you to sync your CoreData database from your app to the iCloud container (in a sane way).
I’ve written about my experiences with using the vanilla support for CoreData syncing provided by Apple. For whatever reason I continue to run into problems. WHY DOESN"T IT JUST WORK!!! I won’t rehash those issues.
So a while back I tried out using Ensembles to do the syncing part of CoreData and I found it to work pretty well. As the site explains there are two versions, a free version and a paid version. The cool thing is the paid version is a drop-in replacement for the free version. Up till now, I’ve only setup the free version.
At one point I was considering buying the paid version, but I got caught up in learning about CloudKit and right now my thinking is my next app will use that instead (at least for cloud storage). But for my existing CoreData app, Ensembles is a strong contender I'm hoping to add in when I get back to working on it.
It’s a good idea to have read through the Ensembles website to understand the technology it is using as I won’t be going into that either.
So let’s get started.
The test app I built is very simple, it consists of a single view controller which is a UITableViewController called MainVC (wrapped in a UINavigationController). It has an “add” button in the navigation bar to create a new Entity. I didn't create a UI to edit Entity objects so clicking "add" just adds a new one to the local database.
There is a toolbar at the bottom of the view which has two items in it. A label that indicates whether syncing is enabled or not and an action button which opens up a UIActionSheet to do the following actions:
There is a toolbar at the bottom of the view which has two items in it. A label that indicates whether syncing is enabled or not and an action button which opens up a UIActionSheet to do the following actions:
- Enable/Disable syncing to the cloud store
- Delete both the local and remote stores
- Delete the local store and resync with the remote store
I figured these actions were enough to simulate the different sync scenarios I wanted to try out.
Architecturally I abstracted all the CoreData and Ensembles “glue” code in a class named CDEStack. so as to keep the rest of the code clean (as much as possible) of all the boilerplate stuff.
To set all this up I used Cocoapods to pull in the Ensembles dependency. My podfile looks like this:platform :ios, '8.0'
pod "Ensembles", "~> 1.0"
Since Ensembles is written in Objective-C you need to have a bridging header. Here are the only two entries in mine that pull in the Ensembles headers:
#import <Ensembles/Ensembles.h>
#import <Ensembles/CDEDefines.h>
The CoreData model is very simple. It consists of one object which I called “Entity” and it has four fields:
creationDate: NSDate
descText: String
name: String
uniqueIdentifier: String
The database name is CDSyncENS (for CoreData Sync with ENSembles, get it?).
I don’t remember if I had to do this or if I got this for free, but when creating the Entity object in the CoreData model editor I had to make sure it’s class was properly namespaced. So in the “Data Model Inspector” for the class attribute it has "CDSyncENS.Entity" instead of, what I think it did in Objective-C, of just being "Entity".
Here is the code for the Entity class:
import Foundation
import CoreData
class Entity: NSManagedObject {
@NSManaged var name: String
@NSManaged var desctext: String
@NSManaged var uniqueIdentifier: String?
@NSManaged var creationDate: NSDate?
override func awakeFromInsert() {
super.awakeFromInsert()
if let uniqueIdentifier = uniqueIdentifier {
}
else {
self.uniqueIdentifier = NSProcessInfo.processInfo().globallyUniqueString
self.creationDate = NSDate()
}
}
}
The key thing to note here is when the object is first inserted into CoreData it gets a uniqueIdentifier and creationDate added to it. The uniqueIdentifier will be used to help Ensembles not create duplicate objects when syncing. The creationDate, seemed like a good thing to do.
Next up is the AppDelegate:
class AppDelegate: UIResponder, UIApplicationDelegate {
var window: UIWindow?
var coreDataStack: CDEStack?
func application(application: UIApplication, didFinishLaunchingWithOptions launchOptions: [NSObject: AnyObject]?) -> Bool {
// Override point for customization after application launch.
coreDataStack = CDEStack(dbName: "CDSyncENS", syncToCloud: true, completion: nil)
let navigationController = self.window!.rootViewController as! UINavigationController
let mainVC = navigationController.topViewController as! MainVC
mainVC.setCoreDataStack(self.coreDataStack!)
return true
}
func applicationWillResignActive(application: UIApplication) {
if let coreDataStack = coreDataStack {
coreDataStack.save(nil)
}
}
func applicationDidEnterBackground(application: UIApplication) {
if let coreDataStack = coreDataStack {
coreDataStack.save(nil)
}
}
func applicationWillTerminate(application: UIApplication) {
if let coreDataStack = coreDataStack {
coreDataStack.save(nil)
}
}
}
This is a pretty vanilla app delegate class. In the "didFinishLaunchingWithOptions" method the CDEStack object is created, assigned to the local variable "coreDataStack" and passed into the main view controller (which is the UITableViewController I mentioned earlier).
The rest of the methods "applicationWillResignActive", "applicationDidEnterBackground", and "applicationWillTerminate" ensure that a save operation is called on the stack should one of those events occur.
So, the obvious next question is, what does CDEStack look like. I’m glad you asked. This one I’ll take a little slower in the walk through.
First we setup some names for some notifications that will be fired by the lifecycle methods. import CoreData
import UIKit
let SYNC_ACTIVITY_DID_BEGIN = "SyncActivityDidBegin"
let SYNC_ACTIVITY_DID_END = "SyncActivityDidEnd"
Next is the class declaration. I don't remember why I annotated it with "@objc" but I think it is required to make it work better with CoreData and Ensembles since they are both Objective-C based frameworks.
@objc class CDEStack : NSObject, CDEPersistentStoreEnsembleDelegate {
private var mainQueueMOC:NSManagedObjectContext?
private var psc:NSPersistentStoreCoordinator?
private var model:NSManagedObjectModel?
private var modelURL:NSURL?
private let modelName:String
private var store:NSPersistentStore?
private var ensemble:CDEPersistentStoreEnsemble?
private var activeMergeCount:Int = 0
This is a list of some private fields that the stack will hold. In here we have the main NSManagedObjectContext (mainQueueMOC), the NSPersistentStoreCoordinator (psc), the NSManagedObjectModel (model), the url for the model (modelURL), the name of the model (modelName), the NSPersistentStore (store), the CDEPersistentStoreEnsemble (ensemble) and a counter for the number of active merges (activeMergeCount). I found this last one in some Ensembles documentation I was reading and it is there to determine when the network activity indicator should be shown or not. These should mostly be familiar with anyone who has done any CoreData work.
Next are some computed properties:
This first one exposes the main context. I was trying to limit access to this by the using components (MainVC in this case) so that they could only read the context but not replace it (which would obviously be a very bad thing). var context:NSManagedObjectContext {
get {
return mainQueueMOC!
}
}
private var storeOptions : [NSObject: AnyObject] {
return [
NSMigratePersistentStoresAutomaticallyOption: true,
NSInferMappingModelAutomaticallyOption: true
]
// TODO: May need to add NSPersistentStoreRemoveUbiquitousMetadataOption
}
This is just an accessor to put creation of the store options array in one place so I could find it later on should I need to change it. Note the TODO item.
private var storeDirectoryURL : NSURL {
let fileManager = NSFileManager.defaultManager()
let directoryURL = fileManager.URLForDirectory(.ApplicationSupportDirectory, inDomain: .UserDomainMask, appropriateForURL: nil, create: true, error: nil)
return directoryURL!
}
This one I remember being awkward to setup. I think I got this from the Ensembles documentation. Basically it abstracts out the creation of the store directory when setting up the store. I remember bouncing about between several different examples but this one seemed to do the trick.
private var storeURL : NSURL {
return storeDirectoryURL.URLByAppendingPathComponent("\(modelName).sqlite")
}
private var ubiquityContainerIdentifier: String {
let fileManager = NSFileManager.defaultManager()
let teamId = "iCloud"
let bundleID = NSBundle.mainBundle().bundleIdentifier
let cloudRoot = "\(teamId).\(bundleID!)"
return cloudRoot
}
Again here we just pull out some of the boiler plate code to make the code cleaner. These just create the store URL and the ubiquity container identifier.
Now onto the init method:
First we capture the database name (which is also the name of the model). We then set the logging level on Ensembles to be verbose. I found this very useful to see what was going on in the console. init(dbName:String, syncToCloud:Bool, completion: ((Void) -> Void)? ) {
modelName = dbName
super.init()
CDESetCurrentLoggingLevel(CDELoggingLevel.Verbose.rawValue)
// Setup Core Data Stack
setupCoreData() {
self.setupEnsemble(completion)
}
}
Finally we call the setupCoreData() method to setup the CoreData stack and we also pass a completion method to setup Ensembles after CoreData is successfully stood up.
The setupCoreData() method is very standard, in fact I think I just copied most of it from the boiler plate code you get when you choose to build a CoreData based app in the Project wizard.
I’m not going to go into much detail here. Essentially we create the store directory, then we create the model, then we create the NSPersistentStoreCoordinator, then we create the NSPersistentStore, and finally we create the NSManagedObjectContext. Note: I did not create a private/public MOC relationship as is becoming a defacto best practice. I’m sure you could, I just didn’t want to complicate my test app. I was really focussing on getting Ensembles integrated. private func setupCoreData(completion: ((Void) -> Void)?) {
println("Setting up CD Stack")
NSFileManager.defaultManager().createDirectoryAtURL(storeDirectoryURL, withIntermediateDirectories:true, attributes:nil, error:nil)
// Create the model
modelURL = NSBundle.mainBundle().URLForResource(modelName, withExtension: "momd")!
let _model = NSManagedObjectModel(contentsOfURL: modelURL!)
assert(_model != nil, "Could not retrieve model at URL \(modelURL!)")
model = _model
// Build the persistent store coordinator
psc = NSPersistentStoreCoordinator(managedObjectModel: model!)
var storeURL = self.storeURL
var options = self.storeOptions
var error: NSError? = nil
self.store = self.psc!.addPersistentStoreWithType(NSSQLiteStoreType, configuration: nil, URL: storeURL, options: options, error: &error)
assert(self.store != nil, "Could not add store URL \(storeURL)")
// Set up the main MOC
mainQueueMOC = NSManagedObjectContext(concurrencyType: .MainQueueConcurrencyType)
mainQueueMOC?.persistentStoreCoordinator = psc
mainQueueMOC?.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
println("Finished setting up CD Stack")
if let completion = completion {
completion()
}
}
Once all this is done the completion handler (which sets up Ensembles) is ran.
Here is the "setupEnsemble" method:
We first check to see if we can sync and bail out if we can’t. More on this later. Next the cloud file system is setup, followed by setting up the Ensemble. We then attach the CDEStack instance as a delegate to the ensemble and start listening for local saves, and downloads from the cloud store. func setupEnsemble(completion:((Void) -> Void)?) {
println("Setting up sync stack")
if (!canSynchronize()) {
println("Cannot set up sync stack, disabled")
return
}
let cloudFileSystem = CDEICloudFileSystem(ubiquityContainerIdentifier: nil)
assert(cloudFileSystem != nil, "Cloud file system could not be created")
ensemble = CDEPersistentStoreEnsemble(ensembleIdentifier: modelName, persistentStoreURL: storeURL, managedObjectModelURL: modelURL, cloudFileSystem: cloudFileSystem)
ensemble!.delegate = self
// Listen for local saves, and trigger merges
NSNotificationCenter.defaultCenter().addObserver(self, selector: "localSaveOccurred:", name: CDEMonitoredManagedObjectContextDidSaveNotification, object: nil)
NSNotificationCenter.defaultCenter().addObserver(self, selector: "cloudDataDidDownload:", name: CDEICloudFileSystemDidDownloadFilesNotification, object: nil)
println("Finished setting up sync stack")
synchronizeWithCompletion(completion)
}
Finally we call the "synchronizeWithCompletion" method with any completion handler that might have been passed in. In this case there is none.
Here is the "synchronizeWithCompletion" method.
private func synchronizeWithCompletion(completion:((Void) -> Void)?) {
if (!canSynchronize()) {
return
}
incrementMergeCount()
if let ensemble = ensemble {
if (!ensemble.leeched) {
println("Leeching the ensemble")
ensemble.leechPersistentStoreWithCompletion(){
(error:NSError?) in
println("Leeching complete")
self.decrementMergeCount()
if (error != nil) {
println("Could not leech to ensemble: \(error)")
if (!ensemble.leeched) {
self.disconnectFromSyncServiceWithCompletion(completion)
return
}
}
println("Leeching successful")
if let completion = completion {
completion()
}
}
}
else {
println("Merging with the ensemble")
// Initiate sync operations
ensemble.mergeWithCompletion() {
error in
println("Merging complete")
self.decrementMergeCount()
if (error != nil) {
println("Could not merge ensemble: \(error)")
}
println("Merging successful")
if let completion = completion {
completion()
}
}
}
}
}
I won’t go into this much as I believe I copied it from the Ensembles sample code. Basically, it ensures that “leeching” has occurred and if the ensemble is “leeched” it accomplishes the merge operation.
Next we implement some of the optional Ensemble delegate methods:
The second method, "globalIdentifiersForManagedObjects", hung me up for a while. By implementing this method we leverage our locally generated unique identifiers we calculated when an Entity was created so that Ensembles can be smart enough to not insert duplicate objects. Again see the documentation in the delegate declaration for this method.
Next we implement the two increment and decrement methods so we can keep track of when to start and stop the network activity indicator in the UI. To be honest, I don't think this is really needed, but it is a nice touch to give the user subtle feedback.
The "disconnectFromSyncServiceWithCompletion" method is used in the app to disable syncing (one of the requirements of the app).
// MARK: Ensemble Delegate Methods
func persistentStoreEnsemble(ensemble: CDEPersistentStoreEnsemble!, didSaveMergeChangesWithNotification notification: NSNotification!) {
if let mainQueueMOC = mainQueueMOC {
// merge the changes in the notification into the main MOC
mainQueueMOC.performBlockAndWait() {
mainQueueMOC.mergeChangesFromContextDidSaveNotification(notification)
}
}
}
func persistentStoreEnsemble(ensemble: CDEPersistentStoreEnsemble!, globalIdentifiersForManagedObjects objects: [AnyObject]!) -> [AnyObject]! {
var returnArray = NSMutableArray()
for (idx, object) in enumerate(objects) {
var value: AnyObject? = object.valueForKeyPath("uniqueIdentifier")
returnArray.addObject(value!)
}
return returnArray as [AnyObject]
}
It's best to look at the Ensemble documentation (in the code) as it is pretty self-explanitory. Suffice it to say the first one ("didSaveMergeChangesWithNotication") gets called when the ensemble saves changes into the local persistent store. Here we just merge those changes into the main NSManagedObjectContext.The second method, "globalIdentifiersForManagedObjects", hung me up for a while. By implementing this method we leverage our locally generated unique identifiers we calculated when an Entity was created so that Ensembles can be smart enough to not insert duplicate objects. Again see the documentation in the delegate declaration for this method.
Next we implement the two increment and decrement methods so we can keep track of when to start and stop the network activity indicator in the UI. To be honest, I don't think this is really needed, but it is a nice touch to give the user subtle feedback.
private func decrementMergeCount() {
activeMergeCount--
if (activeMergeCount == 0) {
NSNotificationCenter.defaultCenter().postNotificationName(SYNC_ACTIVITY_DID_END, object: nil)
UIApplication.sharedApplication().networkActivityIndicatorVisible = false
}
}
private func incrementMergeCount() {
activeMergeCount++;
if (activeMergeCount == 1) {
NSNotificationCenter.defaultCenter().postNotificationName(SYNC_ACTIVITY_DID_BEGIN, object: nil)
UIApplication.sharedApplication().networkActivityIndicatorVisible = true
}
}
Next we setup methods to be called to disable syncing and to clean up when the instance is removed from memory. func disconnectFromSyncServiceWithCompletion(completion:((Void) -> Void)?) {
if let ensemble = ensemble {
ensemble.deleechPersistentStoreWithCompletion() {
error in
if (error != nil) {
NSLog("Could not disconnect from sync service: \(error)")
}
else {
self.reset()
if let completion = completion {
completion()
}
}
}
}
}
func reset() {
if (ensemble != nil) {
ensemble!.delegate = nil
ensemble = nil
}
}
deinit {
println("Deallocating CoreDataStack")
NSNotificationCenter.defaultCenter().removeObserver(self)
}
The "disconnectFromSyncServiceWithCompletion" method is used in the app to disable syncing (one of the requirements of the app).
Next is the implementation of the selector methods that get called when the NSNotifications that were setup in "setupEnsembles" method are fired. These both call into the "synchronizeWithCompletion" method. If you relook at that you can see that (since the ensemble has already been leeched) these both cause the ensemble to merge the changes.
func localSaveOccurred(notification:NSNotification) {
synchronizeWithCompletion(nil)
}
func cloudDataDidDownload(notification:NSNotification) {
synchronizeWithCompletion(nil)
}
What's left in CDEStack are methods to service the UI actions. Hopefully these will be self explanatory:
func fetchEntities() -> [Entity]?{
let fetchRequest = NSFetchRequest(entityName: "Entity")
var error: NSError?
let result = mainQueueMOC?.executeFetchRequest(fetchRequest, error: &error) as! [Entity]?
return result
}
func enableSyncManager(completion:((Void) -> Void)?) {
LocalStoreService.sharedInstance.syncToCloud = true
setupEnsemble() {
if let completion = completion {
completion()
}
}
}
func disableSyncManager(completion:((Void) -> Void)?) {
disconnectFromSyncServiceWithCompletion() {
LocalStoreService.sharedInstance.syncToCloud = false
if let completion = completion {
completion()
}
}
}
private func canSynchronize() -> Bool {
return LocalStoreService.sharedInstance.syncToCloud
}
Here's the breakdown of the methods:
- fetchEntities - Retrieves all the entities from the database
- enableSyncManager - Turns on syncing
- disableSyncManager - Turns off syncing
- canSynchronize - Returns whether synchronizing is turned on or off
One thing to note about these methods is they use a singleton class called LocalStoreService which is just a facade I created over NSUserDefaults. Again I am a big fan of abstracting out boilerplate code, so you are seeing this pattern in play a again.
Finally we have the save method, which I pulled out separately here. I'm not going to walk through this one as it should be self explanatory.
Finally we have the save method, which I pulled out separately here. I'm not going to walk through this one as it should be self explanatory.
func save(completion:((Void) -> Void)?) {
if let mainMOC = self.mainQueueMOC {
// If we have nothing to save just run the completion listener
if (!mainMOC.hasChanges) {
println("No changes to be saved")
if let completion = completion {
completion()
}
}
else {
mainMOC.performBlockAndWait() {
// Save main MOC on the main queue
var error: NSError? = nil
println("Saving mainQueueMOC")
if !mainMOC.save(&error) {
println("Error saving main MOC: \(error?.localizedDescription), \(error?.userInfo)")
}
if let completion = completion {
println("Running completion handler")
completion()
println("Finished completion handler")
}
}
}
}
}
The only thing left to go over is the design of MainVC. Since this post has gotten EXTREMELY LONG I'll stop here. In my next post I'll go over MainVC, give my final impressions, and I think I'll try to include a screen shot or two as well.
No comments:
Post a Comment