(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:
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 IngredientEntity
s 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.