NSFetchedResultsController: Fetch in a background thread
Asked Answered
S

4

11

I have a more or less basic UITableViewController with a NSFetchedResultsController. The UITableViewController is pushed onto the navigationController's stack. But the push animation isn't smooth because the fetch of NSFetchedResultsController is performed on the main thread, and therefore blocks the UI.

My question is: How can I perform the fetch of the NSFetchedResultsController in a background thread to keep the animation smooth?

The NSFetchedResultsController and the delegate methods look like this:

- (NSFetchedResultsController *)fetchedResultsController
{
    if (_fetchedResultsController != nil) {
        return _fetchedResultsController;
    }

    NSFetchRequest *fetchRequest = [[NSFetchRequest alloc] init];
    // Edit the entity name as appropriate.
    NSEntityDescription *entity = [NSEntityDescription entityForName:@"GPGrade" inManagedObjectContext:self.managedObjectContext];
    [fetchRequest setEntity:entity];

    // Set the batch size to a suitable number.
    [fetchRequest setFetchBatchSize:20];

    //Set predicate
    NSPredicate *predicate = [NSPredicate predicateWithFormat:@"parent == %@", self.subject];
    [fetchRequest setPredicate:predicate];


    // Edit the sort key as appropriate.
    NSSortDescriptor *sortDescriptor = [[NSSortDescriptor alloc] initWithKey:@"name" ascending:YES];
    NSArray *sortDescriptors = @[sortDescriptor];

    [fetchRequest setSortDescriptors:sortDescriptors];

    // Edit the section name key path and cache name if appropriate.
    // nil for section name key path means "no sections".
    NSFetchedResultsController *aFetchedResultsController = [[NSFetchedResultsController alloc] initWithFetchRequest:fetchRequest managedObjectContext:self.managedObjectContext sectionNameKeyPath:nil cacheName:@"SubjectMaster"];
    aFetchedResultsController.delegate = self;
    self.fetchedResultsController = aFetchedResultsController;

    NSError *error = nil;
    if (![self.fetchedResultsController performFetch:&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();
    }

    return _fetchedResultsController;
}

- (void)controllerWillChangeContent:(NSFetchedResultsController *)controller
{
    [self.tableView beginUpdates];
}

- (void)controller:(NSFetchedResultsController *)controller didChangeSection:(id <NSFetchedResultsSectionInfo>)sectionInfo
           atIndex:(NSUInteger)sectionIndex forChangeType:(NSFetchedResultsChangeType)type
{    
    switch(type) {
        case NSFetchedResultsChangeInsert:
            [self.tableView insertSections:[NSIndexSet indexSetWithIndex:sectionIndex] withRowAnimation:UITableViewRowAnimationFade];
            break;

        case NSFetchedResultsChangeDelete:
            [self.tableView deleteSections:[NSIndexSet indexSetWithIndex:sectionIndex] withRowAnimation:UITableViewRowAnimationFade];
            break;
    }

}

- (void)controller:(NSFetchedResultsController *)controller didChangeObject:(id)anObject
       atIndexPath:(NSIndexPath *)indexPath forChangeType:(NSFetchedResultsChangeType)type
      newIndexPath:(NSIndexPath *)newIndexPath
{    
    UITableView *tableView = self.tableView;

    switch(type) {
        case NSFetchedResultsChangeInsert:
            [tableView insertRowsAtIndexPaths:@[newIndexPath] withRowAnimation:UITableViewRowAnimationTop];
            break;

        case NSFetchedResultsChangeDelete:
            [tableView deleteRowsAtIndexPaths:@[indexPath] withRowAnimation:UITableViewRowAnimationRight];
            break;

        case NSFetchedResultsChangeUpdate:
            //[self configureCell:(GPSubjectOverviewListCell *)[tableView cellForRowAtIndexPath:indexPath] atIndexPath:indexPath];
            break;

        case NSFetchedResultsChangeMove:
            [tableView deleteRowsAtIndexPaths:@[indexPath] withRowAnimation:UITableViewRowAnimationFade];
            [tableView insertRowsAtIndexPaths:@[newIndexPath] withRowAnimation:UITableViewRowAnimationFade];
            break;
    }
}

- (void)controllerDidChangeContent:(NSFetchedResultsController *)controller
{
    [self.tableView endUpdates];
}
Slug answered 10/2, 2013 at 22:14 Comment(1)
Didn't want to put this in the answer - are you sure that the Core Data fetch is causing the problem? Does the problem go away if you don't instantiate the Fetched Results Controller?Whyte
W
1

The general rule with Core Data is one Managed Object Context per thread, and one thread per MOC. With that in mind you need to perform the fetch for the Fetched Results Controller on the main thread, as this is the thread that will be interacting with the FRC's Managed Objects. (See Core Data Programming Guide - Concurrency with Core Data)

If you are having performance issues with the animation you should probably look at ways to ensure that the fetch is performed before or after the view is pushed. Normally you would perform the fetch in the view controller's viewDidLoad:, and the navigation controller wouldn't push the view until the fetch was complete.

Whyte answered 11/2, 2013 at 15:5 Comment(5)
The question was - how I can use NSFetchedResultsController to fetch data in background? Is there any modern pattern with two MOCs (background & main queue) merging or any other trick?Tratner
@Tratner try this duckrowing.com/2010/03/11/using-core-data-on-multiple-threadsRipping
The link in your answer is broken.Tarp
"If you are having performance issues with the animation you should probably look at ways to ensure that the fetch is performed before or after the view is pushed.". No. Just perform it on a background context. This is absolute nonsense.Bloodless
It might seem like “nonsense” if you don’t understand thread confinement or how Core Data works in general. Even though there’s an answer here showing how to fetch from background, it is dangerous to do that for a variety of reasons. Core Data is easy to hack like this and equally easy to introduce hard to debug crash inducing bugs.Whyte
S
19

TL;DR; There is no good reason to use a context on the main queue.

can use NSFetchedResultsController to fetch data in background

Absolutely. NSFetchedResultsController can be used with a private queue context. It is, in fact, quite happy and performant when doing so. There is a bug that prevents NSFetchedResultsController from using it's cache when it's using a private queue, but the cache does not win you as much as it did in iOS 3.0. Set a cacheName of nil and you will be fine.

1. Create a context with NSPrivateQueueConcurrencyType. Preferably not the one you use for IO.

2. Create the fetched results controller with that context, and a cache name of nil.

3. Perform your initial fetch from within a performBlock: block:

 [[[self fetchedResultsController] managedObjectContext] performBlock:^{
    NSError *fetchError = nil;
    if (![self fetchedResultsController] performFetch:&error]){
        /// handle the error. Don't just log it.
    } else {
        // Update the view from the main queue.
        [[NSOperationQueue mainQueue] addOperationWithBlock:^{
            [tableView reloadData];
         }];
    }
 }];

4. All of your delegate callbacks will now happen from the context's queue. If you are using them to update views, do so by dispatching to the main queue like you see above.

5. ...

6. Profit!

You can read more about this here.

Siward answered 29/7, 2014 at 6:50 Comment(24)
Do you mean: [[fetchedResultsController managedObjectContext] performBlock^{...}];?Confess
should performBlockAndWait: OR performBlock: be used as much as possible, assuming MOC is created under "NSPrivateQueueConcurrencyType" or "NSMainQueueConcurrencyType"?Finnell
The queue access methods are required to be used for anything involving the context other that the context's accessor methods. This means just about everything, yes, including accessing managed objects and their properties.Siward
I'm having problems implementing numberOfRowsInSection. The sectionInfo returns the same number of objects after I inserted an object to the context. Is there any special threading tricks should be implemented in numberOfRowsInSection while accessing a FRC sectionInfo?Buy
Oh just higured out that im reloading the table while beginUpdates were not closed with endUpdatesBuy
Are you sure this is thread-safe? At some point, if you're implementing a table view data source, you'll have to access properties/methods of the NSFetchedResultsController from the main thread. For example, [fetchedResultsController objectAtIndexPath:], fetchedResultsController.sections.count, etc. But if your NSFetchedResultsController was created on a background thread, and is listening to changes to its managed object context, it can be updating itself independently of what's happening in the main thread.Cracking
@StevenWei, what you are describing is using the value from the CoreData call site on the main thread. Accessing that call site does not have to happen on the main thread - exactly as shown in the answer.Siward
@Siward In your linked post you call objectAtIndexPath: inside willDisplayCell:forRowAtIndexPath:, which occurs in the main thread. How do you know that objectAtIndexPath: will return consistently in between invocations of willDisplayCell:forRowAtIndexPath:? It seems to me the fetched results controller can be changing from underneath you in unpredictable ways. Consider: you return fetchedResultsController.sections.count to the table view. It then asks you for the cell at the first index path. But secretly in the background thread everything was deleted.Cracking
@StevenWei, "How do you know that objectAtIndexPath: will return consistently in between invocations of willDisplayCell:forRowAtIndexPath:". There is no requirement for objectAtIndexPath: to return the same object for different invocations of willDisplayCell....Siward
@Siward It's not just objectAtIndexPath:, it's every NSFetchedResultsController API you end up accessing from the main thread. sections, section.numberOfObjects, etc. All of these can be changing underneath you if the managed object context is changing in the background.Cracking
@Siward In the main thread, UITableView is going to call your numberOfSectionsInTableView, numberOfRowsInSection, cellForRowAtIndexPath in succession. Think about what might happen if, in between each of these calls, the contents of NSFetchedResultsController is simultaneously changing in a background thread. How can this possibly be thread-safe?Cracking
@StevenWei performBlock: and performBlockAndWait: operate on a serial queue, with the caveat that performBlockAndWait: is reentrant. As described in the answer (and article), access to anything owned by the context should happen through these methods - which ensures thread safety.Siward
@Siward performBlock:/performBlockAndWait: doesn't solve this problem. Your UITableView is calling numberOfSectionsInTableView, numberOfRows, and cellForRowAtIndexPath in succession in the main thread. In between each of these calls, other code could be executing in the background thread that changes the underlying data and invalidates what you returned. You cannot guarantee that the NSFetchedResultsController has not changed between the time you tell the UITableView "I have 3 sections" and the time it asks you for the number of rows in the first section.Cracking
@StevenWei Yes, it does. Your implementation of, for example, numberOfSectionsInTableView would need to access the sectionInfo of the NSFetchedResultsController instance and return a value. It would do so using a synchronous call from the main queue to the context's queue, returning the result to the main queue. Regardless, if changes are made to the context that affect the NSFetchedResultsController the delegate will be informed - synchronously. It is the responsibility delegate to update the state of the tableview accordingly.Siward
@Siward Let's say you return 10 sections in numberOfSectionsInTableView. Next, the UITableView calls numberOfRowsInSection. In between those two calls there's the opportunity for the NSFetchedResultsController to have changed, say by removing one (or more) sections. The main thread is still executing the UITableView's logic to populate the 10 sections you said it had. It asks you for the number of rows in each section, some of which may no longer exist. See the problem? The NSFetchedResultsController delegate is irrelevant because no other code has executed in the main thread.Cracking
@StevenWei In your scenario, when the sections change the delegate would be invoked - blocking any more changes to the context until control is released. It is the responsibility of the delegate to update the state of the table view in that delegate callback. This will happen "between those two calls". Very simple.Siward
@Siward But see, that can't actually happen, because the delegate fires on a background thread. You'll have to lob a block onto the main queue to be executed on the main thread. But that won't execute until later on in the run loop. If the UITableViewis in the middle of setting up (because you called reloadData, for example), it is not going to get interrupted between the calls to numberOfSectionsInTableView and numberOfRowsInSection. But your data source, since it exists on another thread, can change.Cracking
@Siward Just to be clear, the fetched results controller delegate is irrelevant. If the UITableView is executing its setup code in response to reloadData and calling numberOfSectionsInTableView and numberOfRowsInSection against your data source, there's no way you can have any other code execute in between. You can verify this yourself by breakpointing through it.Cracking
@Siward Following up on this, I've verified in a test project that this approach is definitely not thread-safe: if reloadData is called on the main thread and the private context is simultaneously modified in a background thread, this creates a race condition where the UITableView and NSFetchedResultsController can get out of sync, which manifests as the UITableView requesting a section or index path that no longer exists. (Basically, make the private context continuously insert/remove objects and then call reloadData in the main thread. It will break.)Cracking
This doesn't work for me. I still see all frc data reads happening on the main thread (logging object faults). Anyone able to see otherwise with core data logging?Erickericka
This is really difficult to implement, all tableview delegate methods access managed object's property in some way, which may cause weird behavior in UI.Trismus
Please edit your answer or delete it. While it is possible to do what you recommend it is super unstable and it will cause so many people to lose nights trying to fix race conditions and core data multithreading violations.Depalma
As @Cracking pointed out, this is not thread-safe. It's very appealing, but it's simply wrongTrave
Using diffable data sources and the snapshot-based NSFetchedResultsControllerDelegate callback is a thread safe approach. What do you think? @CrackingCharlyncharm
W
1

The general rule with Core Data is one Managed Object Context per thread, and one thread per MOC. With that in mind you need to perform the fetch for the Fetched Results Controller on the main thread, as this is the thread that will be interacting with the FRC's Managed Objects. (See Core Data Programming Guide - Concurrency with Core Data)

If you are having performance issues with the animation you should probably look at ways to ensure that the fetch is performed before or after the view is pushed. Normally you would perform the fetch in the view controller's viewDidLoad:, and the navigation controller wouldn't push the view until the fetch was complete.

Whyte answered 11/2, 2013 at 15:5 Comment(5)
The question was - how I can use NSFetchedResultsController to fetch data in background? Is there any modern pattern with two MOCs (background & main queue) merging or any other trick?Tratner
@Tratner try this duckrowing.com/2010/03/11/using-core-data-on-multiple-threadsRipping
The link in your answer is broken.Tarp
"If you are having performance issues with the animation you should probably look at ways to ensure that the fetch is performed before or after the view is pushed.". No. Just perform it on a background context. This is absolute nonsense.Bloodless
It might seem like “nonsense” if you don’t understand thread confinement or how Core Data works in general. Even though there’s an answer here showing how to fetch from background, it is dangerous to do that for a variety of reasons. Core Data is easy to hack like this and equally easy to introduce hard to debug crash inducing bugs.Whyte
R
0

You can go through this very nice post on Core Data with Multi-Threaded behavior.

Hope it helps..!!

Ripping answered 15/11, 2013 at 14:35 Comment(0)
C
0

Worked on this problem today. A well-working solution is to wrap the NSFetchedResultsController(FRC) in an operation (FRCOperation). The goal of this operation after completion is to retain FRC and proxy him.

// After completing the operation, it will proxy calls
// from "NSFetchedResultsControllerDelegate.controller(_:didChangeContentWith:)"
protocol FetchOperationDelegate {
    func didChangeContent(snapshot: NSDiffableDataSourceSnapshotReference)
}

// Exemple Operation
protocol FetchOperation: Operation, NSFetchedResultsControllerDelegate {
    weak var delegate: FetchOperationDelegate { get set }
    
    // To get objects from NSFetchedResultsController. Calls always on the main thread
    func object(at indexPath: IndexPath) -> NSManagedObject?
}

Logic:

  1. Opening the controller
  2. Create first the FetchOperation. Don't forget set FetchOperation.delegate = controller. Because after execution you should get the first data in FetchOperationDelegate.
  3. Save this FetchOperation in a property of the controller(viewModel)
  4. Execute FetchOperation. NSFetchedResultsController.performFetch in the background. Don't forgot to use background context.
  5. For example after 5 sec FetchOperation complete
  6. Update your controller by taking data from the operation
  7. From this moment. You should receive updates from FRC through FetchOperationDelegate(NSFetchedResultsControllerDelegate). And you can also access to objects from FRC in the main thread.

If you need to change predicate or sort. You just have to create a new FetchOperation. While new operation is in progress, you still continue to work with the last FetchOperation version.

Cnidus answered 11/1, 2022 at 19:32 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.