4

(Names of entities were changed to make it an easier read)

Environment: Xcode 7 beta 4. iOS 9.0 is the Base SDK, and the deployment target is iOS 8.0. Debugging on an iPhone 5s real device running iOS 8.4.

Here's an interesting Core Data bug that I've spent the last couple of days debugging/trying to make sense of.

-

I've set up an entity, RecipeEntity, which has some attributes and a many-to-many relationship with entity IngredientEntity to represent it's ingredients. IngredientsEntity has a corresponding inverse relationship to RecipeEntity, both entities have the following properties set on the relationships:

enter image description here

There is a view controller that has a table view which displays ingredients, and uses an NSFetchedResultsController to get data for the cells, with the NSFetchedResultsControllerDelegate set to the view controller itself.

Here was the original code used for the fetched results controller delegate:

// MARK: - FetchedResultsController Delegate

/*
We're using a nifty solution to fix the issue of multiple Core Data changes occuring at the same time.
See here for an explanation of why and how it works:
http://www.fruitstandsoftware.com/blog/2013/02/19/uitableview-and-nsfetchedresultscontroller-updates-done-right/
*/

private var deletedSectionIndexes = NSMutableIndexSet()
private var insertedSectionIndexes = NSMutableIndexSet()
private var deletedRowIndexPaths = [NSIndexPath]()
private var insertedRowIndexPaths = [NSIndexPath]()
private var updatedRowIndexPaths = [NSIndexPath]()

func controller(controller: NSFetchedResultsController, didChangeObject anObject: NSManagedObject, atIndexPath indexPath: NSIndexPath?, forChangeType type: NSFetchedResultsChangeType, newIndexPath: NSIndexPath?) {
    switch type {
    case NSFetchedResultsChangeType.Insert:
        if let newIndexPath = newIndexPath {
            if insertedSectionIndexes.containsIndex(newIndexPath.section) {
                // If we've already been told that we're adding a section for this inserted row we skip it since it will handled by the section insertion.
                return
            }
            insertedRowIndexPaths.append(newIndexPath)
        }
    case NSFetchedResultsChangeType.Delete:
        if let indexPath = indexPath {
            if deletedSectionIndexes.containsIndex(indexPath.section) {
                // If we've already been told that we're deleting a section for this deleted row we skip it since it will handled by the section deletion.
                return
            }
            deletedRowIndexPaths.append(indexPath)
        }
    case NSFetchedResultsChangeType.Move:
        if let newIndexPath = newIndexPath {
            if !insertedSectionIndexes.containsIndex(newIndexPath.section) {
                insertedRowIndexPaths.append(newIndexPath)
            }
        }
        if let indexPath = indexPath {
            if !deletedSectionIndexes.containsIndex(indexPath.section) {
                deletedRowIndexPaths.append(indexPath)
            }
        }
    case NSFetchedResultsChangeType.Update:
        if let indexPath = indexPath {
            updatedRowIndexPaths.append(indexPath)
        }
    }
}

func controller(controller: NSFetchedResultsController, didChangeSection sectionInfo: NSFetchedResultsSectionInfo, atIndex sectionIndex: Int, forChangeType type: NSFetchedResultsChangeType) {
    switch type {
    case NSFetchedResultsChangeType.Insert:
        insertedSectionIndexes.addIndex(sectionIndex)
    case NSFetchedResultsChangeType.Delete:
        deletedSectionIndexes.addIndex(sectionIndex)
    default:
        break
    }
}

func controllerDidChangeContent(controller: NSFetchedResultsController) {
    tableView.beginUpdates()

    tableView.deleteSections(deletedSectionIndexes, withRowAnimation: UITableViewRowAnimation.Automatic)
    tableView.insertSections(insertedSectionIndexes, withRowAnimation: UITableViewRowAnimation.Automatic)

    tableView.deleteRowsAtIndexPaths(deletedRowIndexPaths, withRowAnimation: UITableViewRowAnimation.Left)
    tableView.insertRowsAtIndexPaths(insertedRowIndexPaths, withRowAnimation: UITableViewRowAnimation.Right)
    tableView.reloadRowsAtIndexPaths(updatedRowIndexPaths, withRowAnimation: UITableViewRowAnimation.Automatic)

    tableView.endUpdates()

    // Clear the collections so they are ready for their next use.
    insertedSectionIndexes = NSMutableIndexSet()
    deletedSectionIndexes = NSMutableIndexSet()
    deletedRowIndexPaths = [NSIndexPath]()
    insertedRowIndexPaths = [NSIndexPath]()
    updatedRowIndexPaths = [NSIndexPath]()
}

The problem was when setting the IngredientEntitys that RecipeEntity had a relationship with, the following failure would occur:

2015-08-12 14:43:34.777 MyApp[9707:2866157] *** Assertion failure in -[MyApp.MyAppTableView _endCellAnimationsWithContext:], /SourceCache/UIKit/UIKit-3347.44.2/UITableView.m:1222
2015-08-12 14:43:34.782 MyApp[9707:2866157] CoreData: error: Serious application error. An exception was caught from the delegate of NSFetchedResultsController during a call to -controllerDidChangeContent:. attempt to delete and reload the same index path ( {length = 2, path = 0 - 0}) with userInfo (null)

I eventually tracked this down to controller:didChangeObject:atIndexPath:forChangeType:newIndexPath: being called with change type NSFetchedResultsChangeType.Move, but the same NSIndexPath for both indexPath and newIndexPath.

Here's roughly the code that adds the entities as a relationship:

// setOfIngredients is an NSSet of IngredientEntity's retreived from Core Data.
recipeEntity.ingredients = setOfIngredients

Changing this switch case statement (in the controller:didChangeObject:atIndexPath:forChangeType:newIndexPath: delegate method) to the following fixes the issue:

    case NSFetchedResultsChangeType.Move:
        // If the indexPath and the newIndexPath are the same, then they shouldn't really be deleted and inserted, the row should just be reloaded,
        // otherwise it causes a crash.
        if indexPath != newIndexPath {
            if let newIndexPath = newIndexPath {
                if !insertedSectionIndexes.containsIndex(newIndexPath.section) {
                    insertedRowIndexPaths.append(newIndexPath)
                }
            }
            if let indexPath = indexPath {
                if !deletedSectionIndexes.containsIndex(indexPath.section) {
                    deletedRowIndexPaths.append(indexPath)
                }
            }
        } else if let indexPath = indexPath {
            updatedRowIndexPaths.append(indexPath)
        }

-

Main question:

So why would setting the values for a relationship set on a Core Data entity object cause controller:didChangeObject:atIndexPath:forChangeType:newIndexPath: to be called with change type .Move but the same old and new index paths?

Is this a bug in Xcode 7 beta 4/Swift 2.0 or have I done something incorrectly?

I would be really interested to understand what's going on here, I've spent a few days trying to debug this now, so I appreciate your help!

Let me know if there is any more info I can give that would be helpful, thanks.

Mundi
  • 79,884
  • 17
  • 117
  • 140
Jon Cox
  • 10,622
  • 22
  • 78
  • 123
  • Could this be a duplicate of [iOS 9 - “attempt to delete and reload the same index path”](http://stackoverflow.com/questions/31383760/ios-9-attempt-to-delete-and-reload-the-same-index-path)? – Martin R Aug 12 '15 at 14:58
  • It looks like a similar bug is happening, although I'm not sure if it's the same thing causing it. Whilst iOS 9.0 is the Base SDK, the deployment target is iOS 8.0 and the app is running on a real device with iOS 8.4. – Jon Cox Aug 12 '15 at 15:17
  • Thanks for linking to it though, somewhere in there another clue may be buried :) – Jon Cox Aug 12 '15 at 15:17

1 Answers1

0

I wonder if this is because .Move changes are reported when the changed attribute on the object is one of the sort descriptors used in the fetch request.

I see both the indexPath and newIndexPath being the same quite often as well. Per the docs, this is considered to also be an implicit update.

From the NSFetchedResultsController.h

The Move object is reported when the changed attribute on the 
object is one of the sort descriptors used in the fetch request. 
An update of the object is assumed in this case, but no separate 
update message is sent to the delegate.
Troy
  • 5,319
  • 1
  • 35
  • 41