I have a UITableView
that uses an NSFetchedResultsController
as data source.
The core data store is updated in multiple background threads running in parallel (each thread using it's own NSManagedObjectContext
).
The main thread observes the NSManagedObjectContextDidSaveNotification
notification and updates it's context with mergeChangesFromContextDidSaveNotification:
.
Sometimes it happens that the NSFetchedResultsController
sends an
NSFetchedResultsChangeUpdate
event with an indexPath that does not exist
anymore at that point.
For example: The result set of the fetched results controller contains 1 section with 4 objects. The first object is deleted in one thread. The last object is updated in a different thread. Then sometimes the following happens:
But the fetched results controller contains only 3 objects now, and if call
MyManagedObject *obj = [controller objectAtIndexPath:indexPath]
to update the table view cell according to the NSFetchedResultsChangeUpdate
event, this crashes with a NSRangeException
exception.
Thank you for any help or ideas!
This is actually quite common because of the bug in Apple's boiler plate code for NSFetchedResultsControllerDelegate, which you get when you create a new master/detail project with Core Data enabled:
- (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:[NSArray arrayWithObject:newIndexPath]
withRowAnimation:UITableViewRowAnimationFade];
break;
case NSFetchedResultsChangeDelete:
[tableView deleteRowsAtIndexPaths:[NSArray arrayWithObject:indexPath]
withRowAnimation:UITableViewRowAnimationFade];
break;
case NSFetchedResultsChangeUpdate:
[self configureCell:[tableView cellForRowAtIndexPath:indexPath]
atIndexPath:indexPath];
break;
case NSFetchedResultsChangeMove:
[tableView deleteRowsAtIndexPaths:[NSArray arrayWithObject:indexPath]
withRowAnimation:UITableViewRowAnimationFade];
[tableView insertRowsAtIndexPaths:[NSArray arrayWithObject:newIndexPath]
withRowAnimation:UITableViewRowAnimationFade];
break;
}
}
anObject
Why query the fetched results controller and risk using an incorrect index path when the object is already given to you? Martin R recommends this solution as well.
Simply change the helper method configureCell:atIndexPath:
from taking an index path to take in the actual object that was modified:
- (void)configureCell:(UITableViewCell *)cell withObject:(NSManagedObject *)object {
cell.textLabel.text = [[object valueForKey:@"timeStamp"] description];
}
In cell for row, use:
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {
UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:@"Cell" forIndexPath:indexPath];
[self configureCell:cell withObject:[self.fetchedResultsController objectAtIndexPath:indexPath]];
return cell;
}
Finally, in the update use:
case NSFetchedResultsChangeUpdate:
[self configureCell:[tableView cellForRowAtIndexPath:indexPath]
withObject:anObject];
break;
newIndexPath
As of iOS 7.1, both indexPath
and newIndexPath
are passed in when a NSFetchedResultsChangeUpdate happens.
Simply keep the default implementation's usage of indexPath when calling cellForRowAtIndexPath, but change the second index path that is sent in to newIndexPath:
case NSFetchedResultsChangeUpdate:
[self configureCell:[tableView cellForRowAtIndexPath:indexPath]
atIndexPath:newIndexPath];
break;
Ole Begemann's solution is to reload the index paths. Replace the call to configure cell with a call to reload rows:
case NSFetchedResultsChangeUpdate:
[tableView reloadRowsAtIndexPaths:@[indexPath]
withRowAnimation:UITableViewRowAnimationAutomatic];
break;
There are two disadvantages with this method:
nil
if the cell is not visible or indexPath
is out of range." Therefore it would be more correct to check with indexPathsForVisibleRows before calling reload rows.event
object.Populate the table with several records (e.g. in viewDidLoad
run this code)
NSManagedObjectContext *context = [self.fetchedResultsController managedObjectContext];
NSEntityDescription *entity = [[self.fetchedResultsController fetchRequest] entity];
for (NSInteger i = 0; i < 5; i++) {
NSManagedObject *newManagedObject = [NSEntityDescription insertNewObjectForEntityForName:[entity name] inManagedObjectContext:context];
[newManagedObject setValue:[NSDate date] forKey:@"timeStamp"];
[newManagedObject setValue:[@(i) stringValue] forKey:@"title"];
}
[context save:nil];
Change configure cell to show the title attribute:
- (void)configureCell:(UITableViewCell *)cell atIndexPath:(NSIndexPath *)indexPath {
NSManagedObject *object = [self.fetchedResultsController objectAtIndexPath:indexPath];
cell.textLabel.text = [NSString stringWithFormat:@"%@ - %@", [object valueForKey:@"timeStamp"], [object valueForKey:@"title"]];
}
In addition to adding a record when the new button is tapped, update the last item (Note: this can be done before or after the item is created, but make sure to do it before save is called!):
// update the last item
NSArray *objects = [self.fetchedResultsController fetchedObjects];
NSManagedObject *lastObject = [objects lastObject];
[lastObject setValue:@"updated" forKey:@"title"];
Run the app. You should see five items.
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