Monday, July 13, 2015

Swifty Ensembles (Part 2)



In my last post I laid out the infrastructure for how I setup Ensembles to work with a local CoreData store in a test app I had created.  What I left out (it was late and I ran out of time) was how it was consumed.  So in part two I'll walk through that.

Also recall, my test app had one view which was a UITable view that showed all the entries that had been stored.  See the previous post for a more detailed description.

The name of my view controller is MainVC:

 import UIKit  
 import CoreData 

 @objc class MainVC: UITableViewController, NSFetchedResultsControllerDelegate { 
   @IBOutlet weak var storeSyncStatus:UIBarButtonItem? 
   @IBOutlet weak var nextIDButton:UIBarButtonItem? 
   @IBOutlet weak var actionSheetButton:UIBarButtonItem? 
   private var _coreDataStack:CDEStack? 
   var fetchedResultsController:NSFetchedResultsController? 
 
Starting out, I declare the controller to be a NSFetchedResultsControllerDelegate.  I also declare references to some of the UI elements as defined here:
  • storeSyncStatus - This is a label to indicate whether syncing is on or off
  • nextIDButton - This will be used as an indicator of the name of the next Entity object that will be created.  I just give each successive Entity object a name that is one more than the last one.
  • actionSheetButton - The button to bring up the action list
Note also that I declare the class with "@objc" I'm not sure this is required but I think it may be as I remember running into weird errors with NSManagedObjects if I didn't do that.  If someone knows whether this is required or not please let me know.

Also note there is the "_coreDataStack" attribute which is a reference to the CDEStack that was stood up in the ApplicationDelegate class.  

I am using a pattern of injecting the CDEStack instance into this class because I read an article online about the pitfalls of using singletons and in it it recommended you should use injection instead.  I'm not sure I am convinced on the author's argument for this case, so I'll have to think about this some more.   Right now I tend to land in the singleton camp, but I could be swayed. Also note this is a private variable as I don't want it messed with by outsiders :-)

Next is the "fetchedResultsController" which is the instance of NSFetchedResultsController for the table view.

Also note I declared both "_coreDataStack" and "fetchedResultsController" as optionals as the AppDelegate will inject them after the class is instantiated. 

Next up is a very vanilla viewDidLoad method:

   override func viewDidLoad() {  
     super.viewDidLoad() 
     tableView.rowHeight = 44 // Fixes bug where we get a warning in the output 
     refreshUI() 
   } 

The setting for the tableView.rowHeight just fixes a warning I was seeing in the log at runtime.  I think I should be setting estimatedRowHeight here instead.  More investigation is needed on this issue. At the end of this method I call my "refreshUI" method which updates the UI.

Next is a simple implementation of "viewWillDisappear":

   override func viewWillDisappear(animated: Bool) {  
     super.viewWillDisappear(animated) 
     NSNotificationCenter.defaultCenter().removeObserver(self) 
   } 
 
This just removes the view controller from observing notifications from the NSNotificationCenter when it disappears.

Next is the public setter method for the CoreData stack (CDEStack):

   func setCoreDataStack(coreDataStack:CDEStack) {  
     _coreDataStack = coreDataStack  
     setupFetchedResultsController()  
     refreshUI()  
   }

Here I set the private variable, call a method to setup the NSFetchedResultsController for the tableView and call our now familiar "refreshUI" method.

Next is a life cycle method that gets called when the store is changed.  I just print out a message to the console so I can see what happened:

   func handleStoreChanged(notification:NSNotification) {  
     println("Handling Store changed: \(notification)")  
   }  

Next is the method to setup the NSFetchedResultsController, "setupFetchedResultsController":

   func setupFetchedResultsController() {  
     if (_coreDataStack == nil) {  
       return  
     }  
     let fetchRequest = NSFetchRequest(entityName: "Entity")  
     let sortDescriptor = NSSortDescriptor(key: "name", ascending: true)  
     fetchRequest.sortDescriptors = [sortDescriptor]  
     fetchedResultsController = NSFetchedResultsController(fetchRequest: fetchRequest, managedObjectContext: _coreDataStack!.context, sectionNameKeyPath: nil, cacheName: nil)  
     fetchedResultsController!.delegate = self  
   
     var error:NSError? = nil  
     let success = fetchedResultsController!.performFetch(&error)  
     if (!success) {  
       println("Error: \(error?.localizedDescription)")  
     }  
     else {  
       self.tableView.reloadData()  
     }  
   }  

This first checks to see if the _coreDataStack has been set and if not bails out.  If it is set, I create a simple NSFetchedResultsController that retrieves all the Entity's from the local store and sorts them by name.  I set the delegate of the fetched results controller to the MainVC UITableViewController. Finally I perform the fetch and if successful I reload the tableView.

Next up is the "refreshUI" method:

   func refreshUI() {  
     storeSyncStatus?.title = LocalStoreService.sharedInstance.syncToCloud ? "Cloud On" : "Cloud Off"  
     nextIDButton?.title = "Next ID: \(LocalStoreService.sharedInstance.lastCount!+1)"  
   
     var entitiesCount = 0  
     if let _frc = fetchedResultsController {  
       let sectionInfo = _frc.sections![0] as! NSFetchedResultsSectionInfo  
       entitiesCount = sectionInfo.numberOfObjects  
     }  
   
     title = "Entities (\(entitiesCount))"  
   }  
   

This method just updates the different ui components.  Note, I keep a count of the total number of entities stored.  The nextID is used as the name for the next Entity created. Yes, this is probably badly named.

When the user presses the "+" navigation bar button the "addEntity" method is called:

   @IBAction func addEntity() {  
     if let coreDataStack = _coreDataStack {  
       let entityEntity = NSEntityDescription.entityForName("Entity", inManagedObjectContext: coreDataStack.context)  
       let entity = Entity(entity:entityEntity!, insertIntoManagedObjectContext: coreDataStack.context)  
       let newCount = LocalStoreService.sharedInstance.lastCount!+1  
       entity.name = "\(newCount)"  
       let deviceName = UIDevice.currentDevice().name  
       entity.desctext = "\(deviceName): \(NSDate())"  
       var error: NSError? = nil  
       coreDataStack.save() {  
         LocalStoreService.sharedInstance.lastCount = newCount  
       }  
     }  
     else {  
       println("Unable to add entity, there is no coreDataStack")  
     }  
   }  
   

Essentially here I just create the Entity and update it's various fields.  Then the coreDataStack.save() method is called where all the work is done.  If the save is successful then we update the lastCount variable in the local store, so we'll know what to name the next Entity we create.

Next up is the showActionSheet method which shows (when the action button on the toolbar is pressed) the other actions that can be accomplished:

   @IBAction func showActionSheet(sender: AnyObject) {  
       
     let optionMenu = UIAlertController(title: nil, message: "Choose Option", preferredStyle: .ActionSheet)  
       
     let cloudActionTitle = LocalStoreService.sharedInstance.syncToCloud ? "Disable Cloud" : "Enable Cloud"  
     let cloudAction = UIAlertAction(title: cloudActionTitle, style: .Default, handler: {  
       (alert: UIAlertAction!) -> Void in  
       println("toggling cloud access")  
       
       if let coreDataStack = self._coreDataStack {  
         // Whatever the sync flag was before invert it so we call the right method.  
         let syncToCloud = !LocalStoreService.sharedInstance.syncToCloud  
         if (syncToCloud) {  
           println("enabling sync manager")  
           coreDataStack.enableSyncManager() {  
             self.tableView.reloadData()  
             self.refreshUI()  
           }  
         }  
         else {  
           println("disabling sync manager")  
           coreDataStack.disableSyncManager() {  
             self.tableView.reloadData()  
             self.refreshUI()  
           }  
         }  
       }  
     })  
       
     let clearAction = UIAlertAction(title: "Delete Local & Remote", style: .Default, handler: {  
       (alert: UIAlertAction!) -> Void in  
       self.deleteAllLocalEntities()  
     })  
       
     //  
     let resyncAction = UIAlertAction(title: "Delete Local & Resync", style: .Default, handler: {  
       (alert: UIAlertAction!) -> Void in  
       println("Resyncing entities")  
       if let coreDataStack = self._coreDataStack {  
         let syncToCloud = LocalStoreService.sharedInstance.syncToCloud  
         if (syncToCloud) {  
           coreDataStack.disableSyncManager() {  
             self.deleteAllLocalEntities()  
             coreDataStack.enableSyncManager() {  
               self.tableView.reloadData()  
               self.refreshUI()  
             }  
           }  
         }  
         else {  
           self.deleteAllLocalEntities()  
           coreDataStack.enableSyncManager() {  
             self.tableView.reloadData()  
             self.refreshUI()  
           }  
         }  
       }  
     })  
       
     let cancelAction = UIAlertAction(title: "Cancel", style: .Cancel, handler: {  
       (alert: UIAlertAction!) -> Void in  
       println("Cancelled")  
     })  
       
       
     // 4  
     optionMenu.addAction(cloudAction)  
     optionMenu.addAction(clearAction)  
     optionMenu.addAction(resyncAction)  
     optionMenu.addAction(cancelAction)  
       
     // Required for iPad presentation  
     optionMenu.popoverPresentationController?.barButtonItem = actionSheetButton  
       
     // 5  
     self.presentViewController(optionMenu, animated: true, completion: nil)  
   }  
   

The actions are as follows:
  • cloudAction - Turns on or off cloud access, depending on the state of the "syncToCloud" flag in the NSUserDefaults store.
  • clearAction - Deletes all the entries in the local store.
  • resyncAction - Deletes all the entries stored in the local store and re-syncs the data from the Cloud
  • cancelAction - Cancels the action sheet menu

Next up is a method to delete all the local entities (this is used in several places by the "showActionSheet" method's actions shown above):

   private func deleteAllLocalEntities() {  
     println("Deleting all local entities")  
     // TODO: Loop through all entities and delete them all  
     if let coreDataStack = self._coreDataStack {  
       let fetchRequest = NSFetchRequest(entityName: "Entity")  
       var error: NSError?  
       let entities = coreDataStack.context.executeFetchRequest(fetchRequest, error: &error) as! [Entity]?  
       if let entities = entities {  
         for (idx, entity) in enumerate(entities) {  
           coreDataStack.context.deleteObject(entity)  
         }  
         coreDataStack.save() {  
           self.tableView.reloadData()  
           self.refreshUI()  
         }  
       }  
     }  
   }  
   

This method loads the entities from the local store then walks through them, deleting each one in the context.  I recognize it isn't correct to do this on the main thread and loading all the entities into memory is also not a good practice, but this was a test app.  There are better, more safe ways, of dealing with memory and threading issues that should be used in a production quality app.

Once the context is saved the UI is then refreshed.

This next method is a method to return a "generic-ized" device name.  I was testing with two devices so to use this you would want to replace the two String checks with your own device names.

   func genericDeviceName() -> String {  
     let deviceName = UIDevice.currentDevice().name  
       
     if (deviceName == "{test device one name here}") {  
       return "Device 3"  
     }  
     else if (deviceName == "{test device two name here}") {  
       return "Device 2"  
     }  
     return deviceName  
   }  
   

Next up we have table view source and delegate methods

   // MARK: - Table view data source  
   override func numberOfSectionsInTableView(tableView: UITableView) -> Int {  
     if let _frc = fetchedResultsController {  
       return _frc.sections!.count  
     }  
     return 1;  
   }  
   

   override func tableView(tableView: UITableView, numberOfRowsInSection section: Int) -> Int {  
     if let _frc = fetchedResultsController {  
       let sectionInfo = _frc.sections![section] as! NSFetchedResultsSectionInfo  
       return sectionInfo.numberOfObjects  
     }  
     return 0  
   }  
   

In both of these methods we just lean on the fetchedResultsController to answer the "count" questions.

   override func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {  
     let cell = tableView.dequeueReusableCellWithIdentifier("MainTVCell", forIndexPath: indexPath) as! MainTVCell  
     if let _frc = fetchedResultsController {  
       let entity = _frc.objectAtIndexPath(indexPath) as! Entity  
       cell.configureCell(entity)  
     }  
     return cell  
   }  
   

For the "cellForRowAtIndexPath" method I have been following a pattern of adding a "configureCell" method on my subclass of UITableViewCell.  This method isolates the "setting" code on the cell's UI attributes to the cell subclass itself.  This is a carry-over of a pattern I used with Objective-C but with Swift I think I will change this pattern to use the "didSet" method pattern instead so as to hide even more internals of the cell subclass.

   // Override to support editing the table view.  
   override func tableView(tableView: UITableView, commitEditingStyle editingStyle: UITableViewCellEditingStyle, forRowAtIndexPath indexPath: NSIndexPath) {  
     if (editingStyle == .Delete) {  
       if let frc = fetchedResultsController, coreDataStack = _coreDataStack {  
         let entity = frc.objectAtIndexPath(indexPath) as! Entity  
         coreDataStack.context.deleteObject(entity)  
   
         coreDataStack.save(nil)  
       }  
     }  
   }  
   

The "commitEditingStyle" implementation adds "swipe to delete" capability to the tableView.

   override func tableView(tableView: UITableView, didSelectRowAtIndexPath indexPath: NSIndexPath) {  
     if let frc = fetchedResultsController, coreDataStack = _coreDataStack {  
       let entity = frc.objectAtIndexPath(indexPath) as! Entity  
       // TODO: Here is where we do a model migration  
       // entity.dateUpdated = NSDate()  
       entity.desctext = "\(genericDeviceName()): \(NSDate())"  
       coreDataStack.save(nil)  
     }  
   }  
   

The "didSelectRowAtIndexPath" implementation just updates the date in the selected Entity's desctext attribute.  This was intended as a "poor-man's" edit function to prove that editing existing records worked as well.

   // MARK: NSFetchedResultsControllerDelegate methods  
   func controllerDidChangeContent(controller: NSFetchedResultsController) {  
     tableView.reloadData()  
     refreshUI()  
   }  
   
 }  

Finally anytime the NSFetchedResultsController fires a "didChangeContent" event the controller catches this, reloads the table and refreshes the UI.

That's it.  So in summary, what I did was create a simple app that uses CoreData as it's local store and then leans on the excellent Ensembles framework to sync the objects into the cloud and thus across multiple devices.

What I didn't cover was various scenarios dealing with network connectivity or errors nor multi-threading.  I think it wouldn't be too hard to add these capabilities as you stand up a full production ready app.

My final conclusion is this feels like a workable solution to the CoreData-Cloud sync problem.  For my current development work, I intend to still continue exploring CloudKit.  However, after writing this post I'm wondering if I should rethink that position.

I am just learning Ensembles, so if you see areas where I have totally blown it, or things I could have done better, please don't hesitate to let me know.

Finally, as a side note, I want to give a shout out to a free video series I have been watching recently.

Even though I have been a professional programmer for over 15 years, using various languages and IDEs, I have struggled at times learning best practices for XCode.  I come from an Eclipse/Java background and let's just say XCode and I ,haven't always got along very well.  But recently I found this Stanford online course CS193P - developing iOS 8 Apps with Swift, via iTunesU.

Having worked with iOS app development for two and half years now, I already knew most of the material and concepts (even if they are in Swift).  But what I have found very useful was the demos in the videos.  The professor (Paul Hegarty) does a great job of explaining what he is doing and giving great little tidbits of information on how he uses Xcode.

I particularly found his workflow for AutoLayout to be very useful.  I think this course would be a great start for a new iOS developer and for us "seasoned" programmers I have found it quiet useful.  Thanks Stanford and professor Hegarty for producing this and making it available to all.

3 comments:

  1. I used to prepend @objc to the class declaration too to avoid NSManagedObjectObjects issues, as I used to add it on top of my NSManagedObjects subclasses, but you can avoid it:
    1. Clic on you DataModel.xcdatamodel
    2. Select a entity
    3. In the utilities panel (the right one) select the third inspector (Data model inspector)
    4. Check that you have your entity name in the name field and YourApp.YourEntityName in the class field. Chances are, you'll need to change the current content of class field, but after typing app name followed by a dot followed by the entity name, I didn't need @objc anymore
    5. Repeat for every entity.
    Have a nice day!

    ReplyDelete
  2. Hi, first congrats for the tutorial. I have bought the Ensembles 2 basic package but I am having problem implementing since I think the book that comes with the package is not easy for starters like me so I was wondering if you could share the code for this simple app you created for this tutorial so that I could take a look and maybe it would help me getting started with Ensembles.

    ReplyDelete
  3. Marco, I have published the code at: https://github.com/talonstrikesoftware/CDSyncENS Please be aware, at the time I was working on it, I was learning Swift, Ensembles, how to best sync CoreData databases, and probably a 100 other things. I have since moved on to a different solution that doesn't use CoreData at all (Realm + CloudKit). The project, as posted, doesn't compile and was written for Swift 2.0, and a much older version of Ensembles (current around Jan of 2015) but maybe there are some nuggets of information that will help. I'll try to clean it up over the next few days and get it going again, but maybe what I posted will help in some small way. Good Luck.

    ReplyDelete