Search code examples
iosmultithreadingcore-dataconcurrencyuiprogressview

Multi-threaded progress bar concurrency issue outside the main view controller


I've found so many solutions for progress bar update within the same thread and view controller, however they seemed to be not similar cases as mine.

In my application, the main view controller calls loadIntoCoreData()(implemented in class MyLoadingService) which asynchronously loads data into core data by another thread. This function has to continuously update the loading percentage (which is written in NSUserDefaults.standardUserDefaults()) to the main thread so that it could be shown on the progress bar in main view controller. I had ever used a while loop in MainViewController to continuously fetch the current percentage value, like below:

class MainViewController {

    override func viewDidLoad() {
        MyLoadingService.loadIntoCoreData() { result in 

            NSUserDefaults.standardUserDefaults().setBool(false, forKey: "isLoading")
            // do something to update the view

        }
        self.performSelectorInBackground("updateLoadingProgress", withObject: nil)
    } 

    func updatingLoadingProgress() {
        let prefs = NSUserDefaults.standardUserDefaults()
        prefs.setBool(true, forKey: "isLoading")

        // here I use a while loop to listen to the progress value
        while(prefs.boolForKey("isLoading")) {

            // update progress bar on main thread
            self.performSelectorOnMainThread("showLoadingProcess", withObject: nil, waitUntilDone: true)
        }
        prefs.setValue(Float(0), forKey: "loadingProcess")
    }    

    func showLoadingProcess() {
        let prefs = NSUserDefaults.standardUserDefaults()
        if let percentage = prefs.valueForKey("loadingProcess") {
            self.progressView.setProgress(percentage.floatValue, animated: true)
        }
    }
}

And in the class of function loadIntoCoreData:

class MyLoadingService {

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

    func loadIntoCoreData(source: [MyModel]) {
        var counter = 0
        for s in source {
            //load into core data using the class context

            NSOperationQueue.mainQueue.addOperationWithBlock({
                // updating the value of "loadingProcess" in NSUserDefaults.standardUserDefaults() 
                // and synchronize it on main queue
            })
            counter++
        }
    }
}

The above code can successfully run the progress bar, however it often encounter BAD_ACCESS or some other exceptions(like "Cannot update object that was never inserted") due to the conflicts on core data context (thought it seems that managedObjectContext isn't touched by the main thread). Therefore, instead of using a while loop listening on the main thread, I consider using NSOperationQueue.performSelectorOnMainThread to acknowledge the main thread after each entry. Therefore I put my view controller as an argument sender into loadCoreData and call performSelectorOnMainThread("updateProgressBar", withObject: sender, waitUntilDone: true) but failed with error "unrecognized selector sent to class 'XXXXXXXX'". So I would like to ask if is it possible to update an UI object between threads? Or, how to modify my previous solution so that the core data context conflicts could be solved? Any solutions are appreciated.

class MyLoadingService {
    func loadIntoCoreData(sender: MainViewController, source: [MyModel]) {
        var counter = 0
        for s in source {
            //load into core data using the class context

            NSOperationQueue.mainQueue.addOperationWithBlock({
                // updating the value of "loadingProcess" in NSUserDefaults.standardUserDefaults() 
                // and synchronize it on main queue
            })
            NSOperationQueue.performSelectorOnMainThread("updateProgressBar", withObject: sender, waitUntilDone: true)
            counter++
        }
    }
    func updateProgressBar(sender: MainViewController) {
        sender.progressView.setProgress(percentage, animated: true)
    }
}

class MainViewController {
    override func viewDidLoad() {
        MyLoadingService.loadIntoCoreData(self) { result in 

            // do something to update the view
        }
    }
} 

Solution

  • First, you are abusing NSUserDefaults in horrible ways. The documentation describes it as this...

    The NSUserDefaults class provides a programmatic interface for interacting with the defaults system. The defaults system allows an application to customize its behavior to match a user’s preferences. For example, you can allow users to determine what units of measurement your application displays or how often documents are automatically saved. Applications record such preferences by assigning values to a set of parameters in a user’s defaults database. The parameters are referred to as defaults since they’re commonly used to determine an application’s default state at startup or the way it acts by default.

    You are using it to store a global variable.

    Furthermore, you are completely abusing the user's CPU in your loop where you continuously are checking the value in the user defaults, and clipping off a selector to the main thread. "Abuse of the CPU" doesn't even come close to describing what this code is doing.

    You should use NSProgress for reporting progress. There is a WWDC 2015 presentation dedicated exclusively to using NSProgress.


    On to your core data usage.

    Unfortunately, since you intentionally redacted all of the core data code, it's impossible to say what is going wrong.

    However, based on what I see, you are probably trying to use that managed object context from your app delegate (which is probably still created with the deprecated confinement policy) from a background thread, which is a cardinal sin of the highest order as far as core data is concerned.

    If you want to import data as a long running operation, use a private context, and execute the operations in the background. Use NSProgress to communicate progress to anyone wanting to listen.


    EDIT

    Thanks for the advice on my core data context usage. I digged into all the contexts in my code and re-organized the contexts inside, the conflict problem does not happen anymore. As for NSProgress , it's a pity that the WWDC presentation focus on the feature on iOS 9 (while my app must compact on iOS 8 devices). However, even though I use NSProgress, I should still tell the main thread how many data the core data (on another thread) already has, right? How does the thread on NSProgress know the loading progress on my core data thread? – whitney13625

    You can still use NSProgress for iOS8, then only real difference is that you can't explicitly add children, but the implicit way still works, and that video explains it as well.

    You really should watch the whole video and forget about the iOS9 part, except to know that you must add children implicitly instead of explicitly.

    Also, this pre-iOS9 blog post should clear up any questions you have about it.