Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

NSRangeException exception in NSFetchedResultsChangeUpdate event of NSFetchedResultsController

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:

  • controllerWillChangeContent: is called.
  • controller:didChangeObject:atIndexPath:forChangeType:newIndexPath: is called with type = NSFetchedResultsChangeDelete, indexPath.row = 0.
  • controller:didChangeObject:atIndexPath:forChangeType:newIndexPath: is called with type = NSFetchedResultsChangeUpdate, indexPath.row = 3.

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!

like image 418
Martin R Avatar asked Jul 11 '12 12:07

Martin R


1 Answers

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;
    }
}

Solution #1: Use 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;

Solution #2: Use 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;

Solution #3: Reload rows at index path

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:

  1. By calling reload rows, it will call cellForRow, which in turn calls dequeueReusableCellWithIdentifier, which will reuse an existing cell, possibly getting rid of important state (e.g. if the cell is in the middle of being dragged a la Mailbox style).
  2. It will incorrectly try and reload a cell that isn't visible. In Apple's original code, cellForRowAtIndexPath: will return "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.

Reproducing the bug

  1. Create a new master/detail project with core data in Xcode 6.4.
  2. Add a title attribute to the core data event object.
  3. 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];
    
  4. 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"]];
    }
    
  5. 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"];
    
  6. Run the app. You should see five items.

  7. Tap the new button. You will see that a new item is added to the top, and that the last item does not have the text "updated," even though it should have had it. If you force the cell to reload (e.g. by scrolling the cell off the screen), it will have the text "updated."
  8. Now implement one of the three solutions outlined above and in addition to an item being added, the last item's text will change to "updated."
like image 52
Senseful Avatar answered Oct 07 '22 12:10

Senseful