Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Background thread with Core Data and NSFetchedResultsController

I've done some research and have found some decent info on objective-C code, but almost nothing for Swift. I figure this is a pretty common pattern so hopefully we can hammer out how to do it correctly. I've made some pretty significant progress and feel like I'm pretty close, but I'm just out of my depth in Swift.

Goal: Make an app that uses a background thread to parse data and do long fetch requests, and have a main thread that uses an NSFetchedResults Controller.

Code from one of my functions to spin off a new Thread

let tQueue = NSOperationQueue()
let testThread1 = testThread()

tQueue.addOperation(testThread1)
testThread1.threadPriority = 0
testThread1.completionBlock = {() -> () in
    println("Thread Completed")
}

Class I made for making a thread

class testThread: NSOperation{
    var delegate = UIApplication.sharedApplication().delegate as AppDelegate
    var threadContext:NSManagedObjectContext?

    init(){
        super.init()
        NSNotificationCenter.defaultCenter().addObserver(self, selector: "contextDidSave:", name: NSManagedObjectContextDidSaveNotification, object: nil)
    }

    override func main(){
        self.threadContext = NSManagedObjectContext()
        threadContext!.persistentStoreCoordinator = delegate.persistentStoreCoordinator
        ...
        //Code that actually does a fetch, or JSON parsing
        ...
        threadContext!.save(nil)
        NSNotificationCenter.defaultCenter().removeObserver(self)
    }

    func contextDidSave(notification: NSNotification){
        let sender = notification.object as NSManagedObjectContext
        if sender !== self.threadContext{
            self.threadContext!.mergeChangesFromContextDidSaveNotification(notification)
        }
    }
}

I won't include all the code for the NSFetchedResultsController, but I have one linked to the main context. When my threading is commented out the app runs fine, it will block the UI and parse/fetch the data that needs to be inserted into core data, and when it is all complete the UI will unlock.

When I add the threading, as soon as I do anything in the UI that could trigger a save to the main context (in this case, the tappedOnSection table function performs a save), the app crashes and the only thing that appears in the console is. "lldb". The line that is highlighted that triggered the error was

    managedObjectContext?.save(nil)

The error next to it is "EXC_BAD_ACCESS(code 1, address=...

If I simply wait for the background thread to complete, upon completion, I get an error also this time tracing to the "didChangeObject" method of the NSFetchedResultsController. It says "unexpectedly found nil while unwrapping an optional value, and flags the following case:

func controller(controller: NSFetchedResultsController, didChangeObject anObject: AnyObject, atIndexPath indexPath: NSIndexPath?, forChangeType type: NSFetchedResultsChangeType, newIndexPath: NSIndexPath?) {
        switch(type){
        ... other cases
        case NSFetchedResultsChangeType.Update:
            self.configureCell(self.tableView.cellForRowAtIndexPath(indexPath!)!, atIndexPath: indexPath!)
       ...other cases
        }
    }

I'm assuming I'm having some problem with concurrency that I'm not handling properly. I thought that the NSNotification that watched for changes handles this, but I must be missing something else.

override func viewDidLoad() {
    super.viewDidLoad()
    NSNotificationCenter.defaultCenter().addObserver(self, selector: "contextDidSave:", name: NSManagedObjectContextDidSaveNotification, object: nil)

    ...
    //Code here calls the function that starts the thread shown previously to do a background fetch

}

func contextDidSave(notification: NSNotification){
    let sender = notification.object as NSManagedObjectContext
    if sender !== self.managedObjectContext!{
        println("Save Detected Outside Thread Main")
        self.managedObjectContext!.mergeChangesFromContextDidSaveNotification(notification)
    }

}

UPDATE:

With some help from you guys I've been able to localize the error. It seems that the didChangeObject method from the NSFetchedResultsController is the problem. If the data changes, or new rows are inserted, the didChange Object method fires off respective methods to perform these animations, its here where I get the nil errors. Obviouslly, the whole point is that when the background data is fetched, that it will just smoothly animate in, but instead of doing this, it blows up. If I comment this function out, than I don't get any errors, but also loose the smooth animation I hope for. The attached didChangeObject method is below. It is mostly straight from the swift documentation on NSFetchedResultController:

func controller(controller: NSFetchedResultsController, didChangeObject anObject: AnyObject, atIndexPath indexPath: NSIndexPath?, forChangeType type: NSFetchedResultsChangeType, newIndexPath: NSIndexPath?) {
        switch(type){
        case NSFetchedResultsChangeType.Insert:
            self.tableView.insertRowsAtIndexPaths([indexPath!], withRowAnimation: UITableViewRowAnimation.Fade)
        case NSFetchedResultsChangeType.Delete:
            self.tableView.deleteRowsAtIndexPaths([indexPath!], withRowAnimation: UITableViewRowAnimation.Fade)
        case NSFetchedResultsChangeType.Update:
            if self.tableView.cellForRowAtIndexPath(indexPath!) != nil{
                self.configureCell(self.tableView.cellForRowAtIndexPath(indexPath!)!, atIndexPath: indexPath!)
            }

        case NSFetchedResultsChangeType.Move:
            self.tableView.deleteRowsAtIndexPaths([indexPath!], withRowAnimation: UITableViewRowAnimation.Fade)
            self.tableView.insertRowsAtIndexPaths([indexPath!], withRowAnimation: UITableViewRowAnimation.Fade)
        }
    }
like image 970
Unome Avatar asked Dec 20 '22 09:12

Unome


2 Answers

In the end I ended up researching the many different ways to do threading. The most useful turned out to be the Multiple contexes, with the Notification center as described here I also implemented the multiple contexts solution, but degraded back to the other in the end. My issue turned out to be that I was sharing a delegate between multiple NSFetchedResultsControllers without checking if the incoming controller was the same as the one that the table was currently using. This produced out of bounds errors whenever the data auto-reloaded.

My background thread solution was simple.

  1. Create a main Context
  2. Create a background Context.
  3. Call the background context using the performBlock

    context.performBlock {
       //background code here
    
  4. Listen for changes with the main context.

    NSNotificationCenter.defaultCenter().addObserver(self, selector: "contextDidSave:", name: NSManagedObjectContextDidSaveNotification, object: nil)
    
    func contextDidSave(notification: NSNotification) {
        let sender = notification.object as NSManagedObjectContext
        if sender != managedObjectContext {
            managedObjectContext!.mergeChangesFromContextDidSaveNotification(notification)
        }
    }
    
  5. Merge the changes into the mainContext.

My initial setup was actually quite close to correct, what I didn't know was that when you setup your backgroundContext you can give it a concurrency type

let childContext = NSManagedObjectContext(concurrencyType: .PrivateQueueConcurrencyType)

then you can call the context in a background thread using context.performBlock (as shown above) whenever you want to do background thread stuff.

Also, the NSFetchedResultsController used the mainContext as their context so that they wouldn't get blocked during parsing.

UPDATE

There are multiple ways to do background threads, the above solution is just one. Quellish describes another popular approach in his article here. It is very informative and I recommend it, it describes the nested context approach to the queue confinement.

like image 64
Unome Avatar answered Dec 24 '22 01:12

Unome


You should check whether self.tableView.cellForRowAtIndexPath is nil before calling self.configureCell... - it can be nil if the relevant row is no longer visible.

The FRC delegate methods ought to pick up the requisite changes to the tableView, but given you may have many updates coming in from the background, you could just put a reloadData in the controllerDidChangeContent: method.

like image 20
pbasdf Avatar answered Dec 24 '22 02:12

pbasdf