I have 2 managed object contexts: (1) created as NSMainQueueConcurrencyType
that is used by the UI/main thread and (2) created as NSPrivateQueueConcurrencyType
that is used by the networking. Both of these contexts go to the persistent store (i.e., I'm not using parent/child contexts).
For the view controller, I'm using a UITableViewController
with a NSFetchedResultsController
that uses the 1st UI managed object context.
I am merging changes from the 2nd managed object context into the 1st context by observing the NSManagedObjectContextDidSaveNotification
.
The app works fine until it processes a network response that causes a new object to be inserted and an existing object to be deleted in the 2nd context. When the 2nd context is saved, the NSManagedObjectContextDidSaveNotification
fires and the changes are merged into the 1st context. The delegate methods of NSFetchedResultsController
are invoked and a new row is added to the table, but the row representing the deleted object is *not removed.
If I attempt other actions on the table view, like reloading the table or updating other objects, I get this assert in the console log:
*** Assertion failure in -[UITableView _endCellAnimationsWithContext:],
/SourceCache/UIKit/UIKit-2380.17/UITableView.m:1070
CoreData: error: Serious application error. An exception was caught
from the delegate of NSFetchedResultsController during a call to -
controllerDidChangeContent:. Invalid update: invalid number of rows in
section 0. The number of rows contained in an existing section after the
update (6) must be equal to the number of rows contained in that section
before the update (7), plus or minus the number of rows inserted or deleted
from that section (6 inserted, 6 deleted) and plus or minus the number of rows
moved into or out of that section (0 moved in, 0 moved out). with
userInfo (null)
Typically, you get this error if you've forgotten to update your model objects when using UITableView
's batch update methods, but in this case, NSFetchedResultsController
is doing all work. My delegate methods are boilerplate:
- (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
{
NSLog(@" didChangeObject type=%d indexPath=%@ newIndexPath=%@", type, indexPath, newIndexPath);
UITableView *tableView = self.tableView;
switch(type) {
case NSFetchedResultsChangeInsert:
[tableView insertRowsAtIndexPaths:@[newIndexPath] withRowAnimation:UITableViewRowAnimationFade];
break;
case NSFetchedResultsChangeDelete:
[tableView deleteRowsAtIndexPaths:@[indexPath] withRowAnimation:UITableViewRowAnimationFade];
break;
case NSFetchedResultsChangeUpdate:
[tableView reloadRowsAtIndexPaths:@[indexPath] withRowAnimation:UITableViewRowAnimationNone];
break;
case NSFetchedResultsChangeMove:
[tableView deleteRowsAtIndexPaths:@[indexPath] withRowAnimation:UITableViewRowAnimationFade];
[tableView insertRowsAtIndexPaths:@[newIndexPath] withRowAnimation:UITableViewRowAnimationFade];
break;
}
}
- (void)controllerDidChangeContent:(NSFetchedResultsController *)controller
{
[self.tableView endUpdates];
}
My UITableViewDataSource tableView:cellForRowAtIndexPath
method is:
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath
{
static NSString *CellIdentifier = @"Cell";
UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:CellIdentifier];
if (cell == nil) {
cell = [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleSubtitle reuseIdentifier:CellIdentifier];
cell.accessoryType = UITableViewCellAccessoryDisclosureIndicator;
}
[self configureCell:cell atIndexPath:indexPath];
return cell;
}
And configureCell:
is:
- (void)configureCell:(UITableViewCell *)cell atIndexPath:(NSIndexPath *)indexPath
{
Event *event = (Event *)[self.fetchedResultsController objectAtIndexPath:indexPath];
cell.textLabel.text = [[event valueForKey:@"timeStamp"] description];
NSFetchRequest *fetchRequest = [[NSFetchRequest alloc] initWithEntityName:@"Owner"];
NSArray *objects = [self.managedObjectContext executeFetchRequest:fetchRequest error:nil];
cell.detailTextLabel.text = [objects.lastObject name]; // simplified
}
NSFetchedResultsController
gets terribly confused if you use an NSFetchRequest
when preparing your cell in response to tableView:cellForRowAtIndexPath:
. If you don't execute a NSFetchRequest
, all is well. However, if you do, it triggers NSFetchedResultsController
to perform further change notifications, which does bad things to UITableView
.
The workaround for this is to set includesPendingChanges = NO
on your NSFetchRequest.
I have opened a radar issue about this -- problem id 14048101
-- with a detailed example and sample app. This bug reproduces on iOS 5.1, 6.0, and 6.1.
In my sample app, I added logging to Xcode's CoreData template to log enter/leave of NSFetchedResultsController
delegate methods. When I insert + delete objects on the network context, the logging shows:
01: => (before) mergeChangesFromContextDidSaveNotification 02: => (enter) controllerWillChangeContent count=4 03: <= (leave) controllerWillChangeContent count=4 04: didChangeObject type=1 indexPath=(null) newIndexPath= 2 indexes [0, 0] 05: => (enter) controllerDidChangeContent count=5
At this point, all is good. controllerDidChangeContent:
has been called to process the 1 insert, which calls [tableView endUpdates]
, which calls tableView:cellForRowAtIndexPath:
, which calls configureCell:atIndexPath:
.
06: => (enter) configure cell at row 0
At this point, configureCell:atIndexPath:
creates an NSFetchRequest
and calls [self.managedObjectContext executeFetchRequest:error:]
-- here begins the badness. Executing this fetch request triggers the processing of the remaining changes in the context (1 delete and 3 updates) before processing of the insert has finished (we entered controllerDidChangeContent:
on line #05 and don't leave until line #16).
07: => (enter) controllerWillChangeContent count=5 08: <= (leave) controllerWillChangeContent count=5 09: didChangeObject type=2 indexPath= 2 indexes [0, 4] newIndexPath=(null) 10: didChangeObject type=4 indexPath= 2 indexes [0, 2] newIndexPath=(null) 11: didChangeObject type=4 indexPath= 2 indexes [0, 1] newIndexPath=(null) 12: didChangeObject type=4 indexPath= 2 indexes [0, 3] newIndexPath=(null) 13: => (enter) controllerDidChangeContent count=4
At this point, the framework is making a re-entrant call tocontrollerDidChangeContent:
.
14: <= (leave) controllerDidChangeContent count=4 15: <= (leave) configure cell at row 0 16: <= (leave) controllerDidChangeContent count=4 17: <= (after) mergeChangesFromContextDidSaveNotification
At this point, you can see in the UI that: (1) a new cell has been added, (2) 3 cells were updated, and (3) the deleted cell is still visible, which is wrong.
After further action in the UI, I typically get an Assertion failure
or message sent to an invalid object exception.
My sample app is available at https://github.com/peymano/CoreDataFetchedResultsController
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With