9

Full-screen table view iPad-only app. I have enabled swipe to delete on my rows. The row animation always finishes after the delete (commitEditingStyle completes), but occasionally the entire table view freezes. Not the whole UI, mind you, so it's not a blocked main thread. I am able to tap a column header or tap the back button on the navigation controller, but the table itself locks up and cannot be swiped. I can unfreeze it pretty simply by tapping one of my column header buttons.

enter image description here

I'm just completely at a loss for what might be causing the freeze. I am using an NSFetchedResultsController and here is my delegate code for that. It's pretty boiler plate (Update: not as boiler plate now. Using a batching approach):

// MARK: NSFetchedResultsController delegate methods

lazy var deletedSectionIndexes : NSMutableIndexSet = {
    return NSMutableIndexSet()
}()

lazy var insertedSectionIndexes : NSMutableIndexSet = {
    return NSMutableIndexSet()
}()

lazy var deletedRowIndexPaths : [NSIndexPath] = {
    return [NSIndexPath]()
}()

lazy var insertedRowIndexPaths : [NSIndexPath] = {
    return [NSIndexPath]()
}()

lazy var updatedRowIndexPaths : [NSIndexPath] = {
    return [NSIndexPath]()
}()


func controllerWillChangeContent(controller: NSFetchedResultsController) {

}

func controller(controller: NSFetchedResultsController, didChangeObject anObject: AnyObject, atIndexPath indexPath: NSIndexPath?, forChangeType type: NSFetchedResultsChangeType, newIndexPath: NSIndexPath?) {
    switch(type) {

    case .Delete:
        if let indexPath = indexPath {
            self.deletedRowIndexPaths.appendDistinct(indexPath)
        }
    case .Update:
        if let indexPath = indexPath {
            self.updatedRowIndexPaths.appendDistinct(indexPath)
        }
    case .Insert:
        if let newIndexPath = newIndexPath {
            self.insertedRowIndexPaths.appendDistinct(newIndexPath)
        }
    case .Move:
        if let indexPath = indexPath, newIndexPath = newIndexPath {
            self.insertedRowIndexPaths.appendDistinct(newIndexPath)
            self.deletedRowIndexPaths.appendDistinct(indexPath)
        }
    }
}

func controller(controller: NSFetchedResultsController, didChangeSection sectionInfo: NSFetchedResultsSectionInfo, atIndex sectionIndex: Int, forChangeType type: NSFetchedResultsChangeType) {
    switch(type) {

    case .Delete:
        self.deletedSectionIndexes.addIndex(sectionIndex)
    case .Insert:
        self.insertedSectionIndexes.addIndex(sectionIndex)
    default:
        break
    }
}

func controllerDidChangeContent(controller: NSFetchedResultsController) {
    self.tableView.beginUpdates()
    self.tableView.insertSections(self.insertedSectionIndexes, withRowAnimation: .None)
    self.tableView.deleteSections(self.deletedSectionIndexes, withRowAnimation: .None)

    self.tableView.insertRowsAtIndexPaths(self.insertedRowIndexPaths, withRowAnimation: .None)
    self.tableView.deleteRowsAtIndexPaths(self.deletedRowIndexPaths, withRowAnimation: .None)
    self.tableView.reloadRowsAtIndexPaths(self.updatedRowIndexPaths, withRowAnimation: .None)
    self.tableView.endUpdates()

    self.insertedSectionIndexes.removeAllIndexes()
    self.deletedSectionIndexes.removeAllIndexes()
    self.deletedRowIndexPaths.removeAll()
    self.insertedRowIndexPaths.removeAll()
    self.updatedRowIndexPaths.removeAll()        
}

The delete gets called in the didChangeObject delegate method, however, technically it's not a real delete. I am simply setting a property to -1 and then saving that element through the NSMangagedObjectContext--at which point the NSFRC seems to do the right thing which is remove it from the list of fetched objects which were fetched using this predicate:

NSPredicate(format: "account = %@ and quantity != -1", account)

where account is a valid account managed object. The row disappears without an issue 90% or more of the time. It's just on occasion that after the animation completes the table freezes in the manor I've described. It never freezes with the delete button still showing, so I know it's after commitEditingStyle gets called. The delete button does not have a custom implementation. It is the default UITableView implementation of swipe to delete. Here is my commitEditingStyle method:

func tableView(tableView: UITableView, commitEditingStyle editingStyle: UITableViewCellEditingStyle, forRowAtIndexPath indexPath: NSIndexPath) {
    if editingStyle == .Delete {
        if let frameboardItem = self.fetchedResultsController.objectAtIndexPath(indexPath) as? IRMFrameBoardItemMO {
            if frameboardItem.isNew {
                // If it's never been pushed to the server, just delete locally. This will trigger a table reload
                // via NSFetchedResultsController
                DataManager.mainContext.deleteObject(frameboardItem)
            } else {
                // Otherwise mark it with a negative quantity which tells the server to delete it and tells the
                // app to hide it.
                frameboardItem.quantity = -1
            }

            do {
                try DataManager.mainContext.save()
            } catch let error as NSError {
                dLog("Something went wrong: \(error.localizedDescription)")
            }

        }

    }

}

You can see a video here of what I'm talking about. It's over two minutes so you may not want to watch the whole thing, but I'll put it here for reference.

https://vimeo.com/153406113

Would love to hear any suggestions.

Update

I updated the NSFRC delegate methods to use a batching approach to ensure the updates get applied all at once. This has not fixed the issue. The table still freezes periodically.

Matt Long
  • 24,438
  • 4
  • 73
  • 99
  • Are you sure the data source is being updated properly? Is there ever a situation where `frameboardItem` is `nil` in the following statement: `if let frameboardItem = self.fetchedResultsController.objectAtIndexPath(indexPath) as? IRMFrameBoardItemMO`? And if it is `nil`, is the table being updated without the data source being updated? – Alexander Jan 31 '16 at 00:33
  • 1) Is the tableView in a child view controller? 2) If, for test purposes, you change one of the buttons (that does respond to touches) so that it triggers the TV to scroll to top (or bottom), does it actually scroll? That might give a clue as to whether the TV is just ignoring touches, or is freezing when it tries to scroll. – pbasdf Jan 31 '16 at 23:09
  • @pbasdf Not completely sure I understand your suggestion, but when the table view freezes I am able to tap the status bar and the table view scrolls to the top as expected. However, it is still frozen when trying to scroll by swiping. – Matt Long Feb 01 '16 at 17:54
  • @Matt Thanks - so the problem is because the TV doesn't receive touches, rather than because it can't scroll. Do your TV or cells have any gesture recognisers that could be swallowing a touch? – pbasdf Feb 01 '16 at 18:13
  • @pbasdf Nope. No gesture recognizers on the cell. – Matt Long Feb 01 '16 at 18:18
  • @MattLong Do you add an observer for NSManagedObjectContextDidSaveNotification? If you do, can you show us codes of the selector with above observer and check how do you merge changes once you have multiple NSManagedContexts – Allen Feb 02 '16 at 11:59

4 Answers4

2

I also have guess about this issue. My idea is that controllerDidChangeContent can be called twice or more times and faster than table refreshes and this cause of multiple calls of tableView.beginUpdates() that can hangup table.

So to fix this I suggest wrap update in dispatch_async block, or just simple boolean flag

func controllerDidChangeContent(controller: NSFetchedResultsController) {
    dispatch_async(dispatch_get_main_queue(), { () -> Void in
         self.tableView.beginUpdates()
         // ..... rest of update code 
         self.updatedRowIndexPaths.removeAll()
    })
}
sage444
  • 5,661
  • 4
  • 33
  • 60
  • So what is this doing exactly then? The updates are being queued then? – Matt Long Feb 01 '16 at 18:30
  • @MattLong yes, updates are being executed consequently in queue – sage444 Feb 01 '16 at 19:28
  • Unfortunately, it's not quite a silver bullet, however it's pretty close. I've reproduced the freezing even using this technique, but the frequency is very minimal--almost never. I'm accepting your answer as correct. Thanks for taking a stab in the dark. I appreciate it. – Matt Long Feb 03 '16 at 20:41
0

Use blocks.

It is unclear to me of which thread the MOC is accessed from, though since you are using fetchedResultsController, it likely is the main one.

As such, you may want to execute

  • deleteObject
  • save

in a performBlockAndWait wait. That may help guarantee data integrity. Something along the lines:

DataManager.mainContext.performBlockAndWait { () -> Void in
    DataManager.mainContext.deleteObject(frameboardItem)
    if DataManager.mainContext.hasChanges {
        do {
            try DataManager.mainContext.save()
        } catch let error as NSError {
            dLog("Something went wrong: \(error.localizedDescription)")
        }
    }
}
SwiftArchitect
  • 47,376
  • 28
  • 140
  • 179
0

I don't think the TableView freezes due to memory issues or unbalanced begin*/endEditing calls (an exception would be thrown or a signal be sent).

I think it could be doing stuff on a thread other than the main thread. In such a case even blocks won't help. (Set a breakpoint and check what thread stops..., also: test on a real device)

My idea to fix that is, try something different like adding the data to add or remove to a temporary array and update the TableView within one method run (call that method to explicit run on the main thread after your fetch results controller ended its delegate calls).

Anticro
  • 685
  • 4
  • 12
0

Did you try to implement NSFetchedResultsControllerDelegate's delegate in more common way, I mean begin table updating when fetchedResultController asks to, make updates and then end updating?

func controllerWillChangeContent(controller: NSFetchedResultsController) {
    self.tableView.beginUpdates()
}

func controller(controller: NSFetchedResultsController, didChangeObject anObject: AnyObject, atIndexPath indexPath: NSIndexPath?, forChangeType type: NSFetchedResultsChangeType, newIndexPath: NSIndexPath?) {
    /* update table here */
}

func controller(controller: NSFetchedResultsController, didChangeSection sectionInfo: NSFetchedResultsSectionInfo, atIndex sectionIndex: Int, forChangeType type: NSFetchedResultsChangeType) {
    /* update table here */
}

func controllerDidChangeContent(controller: NSFetchedResultsController) {    
    self.tableView.endUpdates()
}

UPADATE:
Is it possible, that when you 'mark object as deleted' it triggers some more complicated chain of object's changing which in turn will cause didChangeObject function to be called for multiple times? Have you traced how many times didChangeObject function is called during single 'deletion marking'?

Mykola Denysyuk
  • 1,935
  • 1
  • 15
  • 15