NSFetchedResultsControllerとUITableViewでハマる(セクション別)

2010/09/16

NSFetchedResultsControllerを利用して、UITableViewでセクション別にソートしてデータの更新/表示する処理。 相当ハマりましたが、少しずつ理解できたので更新。

参考サイト

NSFetchedResultsController でグルーピング(Section分け)

このサイトでも勉強しましたが、実際やってみると原因追及に相当時間がかかりました。

ハマるポイント

(1) fetchedResultsControllerとUITableViewのデータ整合性 (2) setSortDescriptorsとsectionNameKeyPathによるデータ整合性 (3) didChangeObjectのアニメーション処理によるデータ整合性 (4) managedObjectContextの管理ミス

managedObjectContextの取得

アプリケーションからmanagedObjectContextを取得する必要があります。

- (void)viewDidLoad {
    [super viewDidLoad];
    UserAppDelegate *appDelegate = [[UIApplication sharedApplication] delegate];
    self.managedObjectContext = [appDelegate managedObjectContext];
}

これで、クラス内のmanagedObjectContextのアクセスは

[self managedObjectContext]

で統一できます。

fetchedResultsControllerの実装

@synthesize fetchedResultsController=fetchedResultsController_;
@synthesize managedObjectContext=managedObjectContext_;
@synthesize userListViewController;

- (NSFetchedResultsController *)fetchedResultsController {
    if (fetchedResultsController_) {
        return fetchedResultsController_;
    }

    NSFetchRequest *fetchRequest = [[NSFetchRequest alloc] init];
    NSEntityDescription *entity = [NSEntityDescription entityForName:@"User" 
                                              inManagedObjectContext:[self managedObjectContext]];
    [fetchRequest setEntity:entity];
    [fetchRequest setFetchBatchSize:20];
    
    NSSortDescriptor *sortDescriptor = [[NSSortDescriptor alloc] initWithKey:@"indexName" ascending:YES];
    NSSortDescriptor *sortDescriptor2 = [[NSSortDescriptor alloc] initWithKey:@"name" ascending:YES];
    NSArray *sortDescriptors = [[NSArray alloc] initWithObjects:sortDescriptor, sortDescriptor2, nil];
    [fetchRequest setSortDescriptors:sortDescriptors];
    
    NSFetchedResultsController *aFetchedResultsController = [[NSFetchedResultsController alloc] 
                                                             initWithFetchRequest:fetchRequest
                                                             managedObjectContext:[self managedObjectContext]
                                                             sectionNameKeyPath:@"indexName" 
                                                             cacheName:nil
                                                             ];
    
    aFetchedResultsController.delegate = userListViewController;
    self.fetchedResultsController = aFetchedResultsController;
    
    [aFetchedResultsController release];
    [fetchRequest release];
    [sortDescriptor release];
    [sortDescriptors release];
    
    NSError *error = nil;
    if (![fetchedResultsController_ performFetch:&error]) {
        NSLog(@"Unresolved error %@, %@", error, [error userInfo]);
        abort();
    }
    return fetchedResultsController_;
} 

setSortDescriptorsとsectionNameKeyPathによるデータ整合性

上記の例では、データ取得の際、User EntityのindexNameとnameの順でソートしています。 indexNameは、データ一覧をセクション別にグルーピングする為に、nameの先頭1文字を保存したカラムです。

Userデータ一覧を先頭文字(indexName)でセクション分けして取得するには、sectionNameKeyPathにindexNameを設定します。 これは相当便利です。

ただし、setSortDescriptorsでソートした結果と、sectionNameKeyPathでソートした結果の並び順が必ず同じでなければなりません。

つまり、データ追加、削除、データ変更による並び替えを考慮して設計しなければいけません。 双方のセクション数、データ数、indexPathが狂ったらアウトです。

UITableViewのクラスにdelegate

fetchedResultsControllerを実装したクラスでは、ユーザ一覧のUITableViewを持たない設計にしているので(Userデータ一元管理)、 ユーザ一覧のクラスUserListViewControllerを、delegateとして設定しています。

aFetchedResultsController.delegate = userListViewController;

もし、ユーザ一覧とfetchedResultsControllerの実装が同じクラスであれば、通常通りselfです。 どちらにするかは悩みどころです。 fetchedResultsControllerとUITalbeViewは同じクラス内で実装した方が良かったかな?と思ったりもします。

UITableVIewDelegateメソッドの実装

aFetchedResultsController.delegateを設定しているので、これらメソッドが自動で呼び出されます。

- (NSInteger)numberOfSectionsInTableView:(UITableView *)tableView {
    return [[self.delegate.fetchedResultsController sections] count];
}

- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section {
    return [[[self.delegate.fetchedResultsController sections] objectAtIndex:section] numberOfObjects];
}

- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {
    static NSString *CellIdentifier = @"Cell";
    
    UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:CellIdentifier];
    if (cell == nil) {
        cell = [[[UITableViewCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:CellIdentifier] autorelease];
    }
    User *user = [self.delegate.fetchedResultsController objectAtIndexPath:indexPath];
    cell.textLabel.text = [[user valueForKey:@"name"] description];
    
    return cell;
}

-(NSString *)tableView:(UITableView *)tableView titleForHeaderInSection:(NSInteger)section {
    return [NSString stringWithFormat:@"%@", [[[self.delegate.fetchedResultsController sections] objectAtIndex:section] name], nil];
}

データ取得は常に、self.delegate.fetchedResultsControllerを経由しています。 NSFetchedResultsControllerには、面倒なデータ取得メソッドが用意されているのでこれが重宝される理由かと思います。

didChangeObjectによるfetchedResultsControllerとUITableViewの整合性

UITalbeVIewにアニメーションをつけないのであれば、以下のコーディングで問題ないと思います。

- (void)controller:(NSFetchedResultsController *)controller didChangeObject:(id)anObject
       atIndexPath:(NSIndexPath *)indexPath forChangeType:(NSFetchedResultsChangeType)type
      newIndexPath:(NSIndexPath *)newIndexPath {
    UITableView *tableView = userTableView;
    [tableView reloadData];
}

が、UITalbeVIewアニメーションをつける場合、用意された雛形をそのまま利用するとアプリが落ちる事があります。

- (void)controller:(NSFetchedResultsController *)controller didChangeObject:(id)anObject
       atIndexPath:(NSIndexPath *)indexPath forChangeType:(NSFetchedResultsChangeType)type
      newIndexPath:(NSIndexPath *)newIndexPath {
    UITableView *tableView = userTableView;
    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 reloadData];
            //[tableView deleteRowsAtIndexPaths:[NSArray arrayWithObject:indexPath] withRowAnimation:UITableViewRowAnimationFade];
            //[tableView insertRowsAtIndexPaths:[NSArray arrayWithObject:newIndexPath]withRowAnimation:UITableViewRowAnimationFade];
            break;
    }
}

自分が陥ったのは、ソートやセクションの増減が発生した場合で、コメントアウトした部分が問題でした。 例えば、managedObjectContext上でAセクションが削除されても、 UITalbeViewのレンダリング途中でデータ不整合が起こりアプリが終了します。

NSManagedObjectContextの管理ミス

NSManagedObjectContextを利用していると、自分の設計やコーディングによりアプリが落ちる事が多々ありました(^^;)

NSManagedObjectContextのデータを変更するだけ(saveしない)で、fetchedResultsControllerが呼ばれてしまったり、色々な画面を行き来しているうちにメモリが解放されてたりと頭を悩ませます。

こればかりは経験値もありますが、シンプルなベストプラクティスな設計を身につけたいものです。