Thursday, January 21, 2010

Chapter 4 and the Tale of the NSFetchedResultsController

Okay, some people have been experiencing sporadic problems with the Chapter 4 application as described here. The solution I'd like to use would require being able to determine the number of pending, uncommitted section inserts and deletes that a table view has. Although I can get to this information, I can only do so by accessing a private instance variable of UITableView. Obviously, I don't want to give you all a solution that's going to get your application's rejected during the review process.

So, I went back to the drawing board. I don't like this solution as much since it requires us to duplicate work that the table view is already doing by keeping a shadow count of inserts and deletes, but it seems to work well and doesn't add too much complexity. I now have a pretty thorough test case for inserting and deleting rows from a table that uses an NSFetchedResultsController and this solution passes it, so fingers crossed.

The Solution


The first step is to add a @private NSUInteger instance variables to the controller class that manages the table and fetched results controller. This will keep a running count of the number of sections inserted and deleted during a batch of table updates.

In context of the Chapter 4 application, that means adding the following bold line of code to HeroListViewController.h:

#import <UIKit/UIKit.h>

#define kSelectedTabDefaultsKey @"Selected Tab"
enum {
kByName = 0,
kBySecretIdentity,
}
;
@class HeroEditController;
@interface HeroListViewController : UIViewController <UITableViewDelegate, UITableViewDataSource, UITabBarDelegate, UIAlertViewDelegate, NSFetchedResultsControllerDelegate>{

UITableView *tableView;
UITabBar *tabBar;
HeroEditController *detailController;

@private
NSFetchedResultsController *_fetchedResultsController;
NSUInteger sectionInsertCount;
}

@property (nonatomic, retain) IBOutlet UITableView *tableView;
@property (nonatomic, retain) IBOutlet UITabBar *tabBar;
@property (nonatomic, retain) IBOutlet HeroEditController *detailController;
@property (nonatomic, readonly) NSFetchedResultsController *fetchedResultsController;
- (void)addHero;
- (IBAction)toggleEdit;
@end



Now, we have to switch over to the implementation file, HeroListViewController.m and add a line of code to reset the insert count when we get notified by the fetched results controller that changes are coming. To do that, we add one line of code to the method controllerWillChangeContent:, like so:

- (void)controllerWillChangeContent:(NSFetchedResultsController *)controller {
sectionInsertCount = 0;
[self.tableView beginUpdates];
}

Next, we have to increment this variable whenever we insert a section, and decrement it whenever we delete a section in controller:didChangeSection:atIndex:forChangeType:. We do that by adding the bold code below:

- (void)controller:(NSFetchedResultsController *)controller didChangeSection:(id <NSFetchedResultsSectionInfo>)sectionInfo atIndex:(NSUInteger)sectionIndex forChangeType:(NSFetchedResultsChangeType)type {
switch(type) {

case NSFetchedResultsChangeInsert:
if (!((sectionIndex == 0) && ([self.tableView numberOfSections] == 1))) {
[self.tableView insertSections:[NSIndexSet indexSetWithIndex:sectionIndex] withRowAnimation:UITableViewRowAnimationFade];
sectionInsertCount++;
}


break;
case NSFetchedResultsChangeDelete:
if (!((sectionIndex == 0) && ([self.tableView numberOfSections] == 1) )) {
[self.tableView deleteSections:[NSIndexSet indexSetWithIndex:sectionIndex] withRowAnimation:UITableViewRowAnimationFade];
sectionInsertCount--;
}


break;
case NSFetchedResultsChangeMove:
break;
case NSFetchedResultsChangeUpdate:
break;
default:
break;
}

}

Finally, any time we do our consistency check in controller:didChangeObject:atIndexPath:forChangeType:newIndexPath:, we have to take the pending inserts and deletes into account. Since we do the check more than once and insert new sections when the check fails, we also increment the variable if we do insert new rows. We do all that by adding the bold code in below to that method:

- (void)controller:(NSFetchedResultsController *)controller didChangeObject:(id)anObject atIndexPath:(NSIndexPath *)indexPath forChangeType:(NSFetchedResultsChangeType)type newIndexPath:(NSIndexPath *)newIndexPath {
switch(type) {
case NSFetchedResultsChangeInsert:
[self.tableView insertRowsAtIndexPaths:[NSArray arrayWithObject:newIndexPath] withRowAnimation:UITableViewRowAnimationFade];
break;
case NSFetchedResultsChangeDelete:
[self.tableView deleteRowsAtIndexPaths:[NSArray arrayWithObject:indexPath] withRowAnimation:UITableViewRowAnimationFade];
break;
case NSFetchedResultsChangeUpdate: {
NSString *sectionKeyPath = [controller sectionNameKeyPath];
if (sectionKeyPath == nil)
break;
NSManagedObject *changedObject = [controller objectAtIndexPath:indexPath];
NSArray *keyParts = [sectionKeyPath componentsSeparatedByString:@"."];
id currentKeyValue = [changedObject valueForKeyPath:sectionKeyPath];
for (int i = 0; i < [keyParts count] - 1; i++) {
NSString *onePart = [keyParts objectAtIndex:i];
changedObject = [changedObject valueForKey:onePart];
}

sectionKeyPath = [keyParts lastObject];
NSDictionary *committedValues = [changedObject committedValuesForKeys:nil];

if ([[committedValues valueForKeyPath:sectionKeyPath] isEqual:currentKeyValue])
break;

NSUInteger tableSectionCount = [self.tableView numberOfSections];
NSUInteger frcSectionCount = [[controller sections] count];
if (tableSectionCount + sectionInsertCount != frcSectionCount) {
// Need to insert a section
NSArray *sections = controller.sections;
NSInteger newSectionLocation = -1;
for (id oneSection in sections) {
NSString *sectionName = [oneSection name];
if ([currentKeyValue isEqual:sectionName]) {
newSectionLocation = [sections indexOfObject:oneSection];
break;
}

}

if (newSectionLocation == -1)
return; // uh oh

if (!((newSectionLocation == 0) && (tableSectionCount == 1) && ([self.tableView numberOfRowsInSection:0] == 0))) {
[self.tableView insertSections:[NSIndexSet indexSetWithIndex:newSectionLocation] withRowAnimation:UITableViewRowAnimationFade];
sectionInsertCount++;
}


NSUInteger indices[2] = {newSectionLocation, 0};
newIndexPath = [[[NSIndexPath alloc] initWithIndexes:indices length:2] autorelease];
}

}

case NSFetchedResultsChangeMove:
if (newIndexPath != nil) {

NSUInteger tableSectionCount = [self.tableView numberOfSections];
NSUInteger frcSectionCount = [[controller sections] count];
if (frcSectionCount != tableSectionCount + sectionInsertCount) {
[self.tableView insertSections:[NSIndexSet indexSetWithIndex:[newIndexPath section]] withRowAnimation:UITableViewRowAnimationNone];
sectionInsertCount++;
}



[self.tableView deleteRowsAtIndexPaths:[NSArray arrayWithObject:indexPath] withRowAnimation:UITableViewRowAnimationFade];
[self.tableView insertRowsAtIndexPaths: [NSArray arrayWithObject:newIndexPath]
withRowAnimation: UITableViewRowAnimationRight
]
;

}

else {
[self.tableView reloadSections:[NSIndexSet indexSetWithIndex:[indexPath section]] withRowAnimation:UITableViewRowAnimationFade];
}

break;
default:
break;
}

}


I'll push this new code into the project archive as soon as possible and get it posted to apress.com and iphonedevbook.com, but here is the updated version of the Chapter 4 Xcode project in the meantime.

Don't worry if you don't understand everything that's going on in this code. This is nasty code designed to be completely generic so you don't have to worry about it at all. Hopefully this will be the end of our troubles with NSFetchedResultsController.



8 comments:

Simo Salminen said...

UITableview asynchronous nature with row insertions is also causing troubles with app that does scrolling.

Basically every time an message comes from network, item is placed on the bottom of table view, then scrollToRowAtIndexPath is called. However, all kind of hacks are needed to get the scrolling right in all situations (timers, state variable changes in table data delegate methods, you name it).

And I have yet still found a perfect solution...

Emil Choski said...

Thank you.

This is a big help.

Jaanus said...

uh... this code does not work for me. Crashes the same way as the original NSFRC example. I'll have to resort to not using the delegate for now, NSFRC seems too wobbly, at least the delegate part, other parts seem better.

pbohrer said...

Thanks, this got things working for me. I did notice that you could update the non-sort key values and those changes were not getting propagated back in the original tableview. I ended up changing didChangeObject method to have:

if ([[committedValues valueForKeyPath:sectionKeyPath] isEqual:currentKeyValue])
{
// KeyPath did not change but we should still update the cell's other values
UITableViewCell *cell = [self.tableView cellForRowAtIndexPath:indexPath];
[self updateTableViewCell:cell forIndexPath:indexPath];

break;
}

I then also made the cellForRowAtIndexPath call this same routine:

- (void)updateTableViewCell:(UITableViewCell *)cell forIndexPath:(NSIndexPath *)indexPath
{
NSManagedObject *oneHero = [self.fetchedResultsController objectAtIndexPath:indexPath];
NSInteger tab = [tabBar.items indexOfObject:tabBar.selectedItem];
switch (tab) {
case kByName:
cell.textLabel.text = [oneHero valueForKey:@"name"];
cell.detailTextLabel.text = [oneHero valueForKey:@"secretIdentity"];
break;
case kBySecretIdentity:
cell.detailTextLabel.text = [oneHero valueForKey:@"name"];
cell.textLabel.text = [oneHero valueForKey:@"secretIdentity"];
default:
break;
}
}
- (UITableViewCell *)tableView:(UITableView *)theTableView cellForRowAtIndexPath:(NSIndexPath *)indexPath
{
static NSString *HeroTableViewCell = @"HeroTableViewCell";

UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:HeroTableViewCell];
if (cell == nil) {
cell = [[[UITableViewCell alloc] initWithStyle:UITableViewCellStyleSubtitle reuseIdentifier:HeroTableViewCell] autorelease];
}

[self updateTableViewCell:cell forIndexPath:indexPath];

return cell;
}

To reproduce you need to set the hero name and secret identity to something and go back to the top list and then go change whichever value is not the sort key and when you go back to the top tableview that value was not being shown.

Thanks again for getting the fix out.

Edsko said...

Hi Jeff,

This NSFetchedResultsController seems a lot more hassle than it's worth! It seems that it's still not the end of the difficulties. Here's how to reproduce yet another crash :(

Starting with the version of '04 SuperDB' that you attached to this post, make the modifications you describe in your post "SuperDB Core Data App with Sections": add a custom class for the hero entity, add two virtual accessor methods, etc. It's only a handful of modications.

Then run the application with a fresh database. Add a superhero "Aaa", save. Add a superhero "Bbb", save. Go back to "Aaa", change the name to "Ccc", and click save. Crash:

2010-02-22 14:44:24.936 SuperDB[67007:207] *** Assertion failure in -[UITableView _endCellAnimationsWithContext:], /SourceCache/UIKit/UIKit-984.38/UITableView.m:774
2010-02-22 14:44:24.938 SuperDB[67007:207] Serious application error. Exception was caught during Core Data change processing: Invalid update: invalid number of rows in section 0. The number of rows contained in an existing section after the update (1) must be equal to the number of rows contained in that section before the update (1), plus or minus the number of rows inserted or deleted from that section (0 inserted, 1 deleted). with userInfo (null)
2010-02-22 14:44:24.939 SuperDB[67007:207] *** Terminating app due to uncaught exception 'NSInternalInconsistencyException', reason: 'Invalid update: invalid number of rows in section 0. The number of rows contained in an existing section after the update (1) must be equal to the number of rows contained in that section before the update (1), plus or minus the number of rows inserted or deleted from that section (0 inserted, 1 deleted).'

Edwin said...

scrub m65 kamagra attorney lawyer body scrub field jacket lovegra marijuana attorney injury lawyer

SEO Services Consultants said...

Nice information, many thanks to the author. It is incomprehensible to me now, but in general, the usefulness and significance is overwhelming. Thanks again and good luck! Web Design Company

h4ns said...

What youre saying is completely true. I know that everybody must say the same thing, but I just think that you put it in a way that everyone can understand. I also love the images you put in here. They fit so well with what youre trying to say. Im sure youll reach so many people with what youve got to say.

Arsenal vs Huddersfield Town live streaming
Arsenal vs Huddersfield Town live streaming
Wolverhampton Wanderers vs Stoke City Live Streaming
Wolverhampton Wanderers vs Stoke City Live Streaming
Notts County vs Manchester City Live Streaming
Notts County vs Manchester City Live Streaming
Bologna vs AS Roma Live Streaming
Bologna vs AS Roma Live Streaming
Juventus vs Udinese Live Streaming
Juventus vs Udinese Live Streaming
Napoli vs Sampdoria Live Streaming
Napoli vs Sampdoria Live Streaming
Fulham vs Tottenham Hotspur Live Streaming
Fulham vs Tottenham Hotspur Live Streaming
AS Monaco vs Marseille Live Streaming
AS Monaco vs Marseille Live Streaming
Alajuelense vs Perez Zeledon Live Streaming
Alajuelense vs Perez Zeledon Live Streaming
Technology News | News Today | Live Streaming TV Channels