黒毛和牛モモバラ切り落し100g298円

iPhoneアプリを作ってます。リリースノートとか用ブログです。

Xcode7 で NSFetchedResultsControllerDelegate がクラッシュする場合の対応方法

Xcode7-beta の頃から NSFetchedResultsControllerDelegate で落ちるようになりました。現在の(betaではない)Xcode7 でも同様にクラッシュするようです。私のアプリも一時期結構落ちてましてユーザーさんにはご迷惑をおかけしておりました。

当初 iOS9 固有の問題かと思っていたのですが iOS8 でも発生しているようで、iOS 起因の問題なのか Xcode 起因の問題なのかちゃんと把握しておりませんが、条件さえ揃えば確実にクラッシュしますので結構クリティカルな問題と認識しています。

いくつかのクラッシュする状態があるようですが、NSFetchedResultsChangeUpdate 時に [UITableView reloadRowsAtIndexPaths:withRowAnimation:] を呼び出すと落ちるようになったのが私の方では一番のクラッシュ原因でした。話の流れは Apple の forum や stackoverflow が詳しいのでそちらをご覧ください。

なお私が問題に遭遇した時点では、Apple の forum や stackoverflow にあった解決方法ではクラッシュが解決しなかったので、以下のようなコードで対応しました。

まず FetchedResultsControllerDelegate というクラスを用意しています。Xcode の新規プロジェクトで master/detail 型のプロジェクトを作成すると [configureCell:atIndexPath:] が UITableViewDataSource に追加されますが、私のアプリは master/detail 型として作成したものではありませんし NSFetchedResultsControllerDelegate は同じクラスを使い回していますので別オブジェクトにしてしまった方が便利かと思います。(Swift の場合は適当に変換してください。)

FetchedResultsControllerDelegate.h

#import <CoreData/CoreData.h>

@protocol FetchedResultsControllerDelegateDataSource <UITableViewDataSource>

@required
- (void)configureCell:(UITableViewCell *)cell atIndexPath:(NSIndexPath *)indexPath;

@end

@interface FetchedResultsControllerDelegate : NSObject <NSFetchedResultsControllerDelegate>

@property (weak, nonatomic) UITableView *tableView;

- (id)initWithTableView:(UITableView *)tableView;

@end

FetchedResultsControllerDelegate.m

#import "FetchedResultsControllerDelegate.h"

@implementation FetchedResultsControllerDelegate

- (id)initWithTableView:(UITableView *)tableView
{
    self = [super init];
    if (self) {
        self.tableView = tableView;
    }
    return self;
}

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

- (void)controller:(NSFetchedResultsController *)controller didChangeSection:(id <NSFetchedResultsSectionInfo>)sectionInfo atIndex:(NSUInteger)sectionIndex forChangeType:(NSFetchedResultsChangeType)type
{
    switch(type) {
        case NSFetchedResultsChangeInsert:
            [self.tableView insertSections:[NSIndexSet indexSetWithIndex:sectionIndex] withRowAnimation:UITableViewRowAnimationFade];
            break;
            
        case NSFetchedResultsChangeDelete:
            [self.tableView deleteSections:[NSIndexSet indexSetWithIndex:sectionIndex] withRowAnimation:UITableViewRowAnimationFade];
            break;
        case NSFetchedResultsChangeUpdate:
            break;
        case NSFetchedResultsChangeMove:
            break;
    }
}

- (BOOL)canUpdateWithTableView:(UITableView *)tableView indexPath:(NSIndexPath *)indexPath
{
    NSObject <UITableViewDataSource> *dataSource = tableView.dataSource;
    
    if (dataSource == nil || ![dataSource conformsToProtocol:@protocol(FetchedResultsControllerDelegateDataSource)]) {
        return NO;
        
    } else {
        NSInteger sections = 1;
        if ([dataSource respondsToSelector:@selector(numberOfSectionsInTableView:)]) {
            sections = [dataSource numberOfSectionsInTableView:tableView];
        }
        if (indexPath.section >= sections) {
            return NO;
        }
        NSInteger rows = 0;
        if ([dataSource respondsToSelector:@selector(tableView:numberOfRowsInSection:)]) {
            rows = [dataSource tableView:tableView numberOfRowsInSection:indexPath.section];
        }
        return (indexPath.row < rows);
    }
}

- (void)controller:(NSFetchedResultsController *)controller didChangeObject:(id)anObject atIndexPath:(NSIndexPath *)indexPath forChangeType:(NSFetchedResultsChangeType)type newIndexPath:(NSIndexPath *)newIndexPath
{
    UITableView *tableView = self.tableView;
    
    switch(type) {
        case NSFetchedResultsChangeInsert:
            [tableView insertRowsAtIndexPaths:[NSArray arrayWithObject:newIndexPath] withRowAnimation:UITableViewRowAnimationFade];
            break;
            
        case NSFetchedResultsChangeDelete:
            [tableView deleteRowsAtIndexPaths:[NSArray arrayWithObject:indexPath] withRowAnimation:UITableViewRowAnimationFade];
            break;
            
        case NSFetchedResultsChangeUpdate:
            if ([self canUpdateWithTableView:tableView indexPath:indexPath]) {
                NSObject <FetchedResultsControllerDelegateDataSource> *dataSource = (NSObject <FetchedResultsControllerDelegateDataSource> *)tableView.dataSource;
                UITableViewCell *cell = [tableView cellForRowAtIndexPath:indexPath];
                if (cell != nil) {
                    [dataSource configureCell:cell atIndexPath:indexPath];
                }
            }
            break;
            
        case NSFetchedResultsChangeMove:
            [tableView deleteRowsAtIndexPaths:[NSArray arrayWithObject:indexPath] withRowAnimation:UITableViewRowAnimationFade];
            [tableView insertRowsAtIndexPaths:[NSArray arrayWithObject:newIndexPath] withRowAnimation:UITableViewRowAnimationFade];
            break;
    }
}

- (void)controllerDidChangeContent:(NSFetchedResultsController *)controller
{
    [self.tableView endUpdates];
}

@end

あくまでも参考として利用される場合は、NSFetchedResultsChangeUpdate の分岐で configureCell を呼び出せない場合の判定を忘れないようにしてください。うろ覚えですが Xcode6 の頃は [cellForRowAtIndexPath:] で取得できない位置のセルに対して NSFetchedResultsChangeUpdate が発生することは無かったように思うのですが、Xcode7 では発生するようですので、(未確認ですが)AppleNSFetchedResultsControllerDelegate の実装例でも落ちるような気がします。

上記クラスを使うには、まず UITableViewDataSource で以下のように NSFetchedResultsController の delegate に FetchedResultsControllerDelegate オブジェクトを指定し、

- (void)someMethod {
     FetchedResultsControllerDelegate *fetchedResultsControllerDelegate = [[FetchedResultsControllerDelegate alloc] initWithTableView:self.tableView];
     NSFetchedResultsController *fetchedResultsController = [[NSFetchedResultsController alloc] initWithFetchRequest:request managedObjectContext:managedObjectContext sectionNameKeyPath:nil cacheName:nil];
 }

[configureCell:atIndexPath:] を定義し、[tableView:cellForRowAtIndexPath:] からもそれを呼び出すようにします。

- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath
{
     UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:ReuseIdentifier forIndexPath:indexPath];
     [self configureCell:cell atIndexPath:indexPath];
     return cell;
}

- (void)configureCell:(UITableViewCell *)cell atIndexPath:(NSIndexPath *)indexPath
{
    // セルの初期化処理
}