[Cocoa] NSFetchedResultsController

코어데이터와 UITableView와의 연결

지금까지 몇 개의 예제를 통해 코어데이터를 사용해서 일련의 데이터를 디스크에 읽고 쓰며, 이를 관리하는 방법에 대해 살펴보았는데, 이런 작업을 보다 쉽게 만들어주는 컨트롤러가 있었으니, 바로 NSFetchedResultsController이다.

이 클래스는 코어데이터의 컨텍스트를 기반으로 저장소로부터 조건에 맞는 객체를 읽어들여서, UITableView의 데이터소스 메소드에서 쉽게 사용할 수 있는 형태로 제공한다. 또한 읽어온 객체에 대해 추적 기능을 가지고 있어서 managed object에 어떤 변경이 발생할 때 이를 감지하여 적절하게 테이블 뷰에서의 변경을 만들어낼 수 있다.

단, NSFetchedResultsController는 저장소나 컨텍스트를 생성하지는 못한다. 코어데이터 프로그램에서 수동으로 배열을 만들어 불러온 데이터를 관리하는 부분에서의 코드의 양을 줄이는 용도로 사용할 수 있다는 것이 이 클래스의 의의랄까.

NSFetchedResultsController는 이름에서처럼 불러온 데이터들을 제어한다. 따라서 데이터를 불러오는 데 필요한 managed object context, fetch request, predicate, sort descriptor 등은 일반적인 코어데이터 프로그램에서와 같이 우리가 일일이 생성해 주어야 한다.

대신, 이 컨트롤러는 UITableView의 indexPath에 대한 접근 방식에 일치하는 메소드들을 가지고 있으므로, 데이터소스를 생성하는데 매우 편리하게 활용된다.

생성방법

컨트롤러의 생성을 위해서는 initWithFetchRequest:… 메소드를 사용하면 된다. 이 때 필요한 파라미터로는 다음의 것들이 있다.

  • (NSFetchRequest*)fetchRequest
  • (NSManagedObjectContext*)managedObjectContext
  • (NSString *)sectionNameKeyPath
  • (NSString*)cacheName

기본적으로 fetchRequest는 별도로 생성해야 한다. 섹션네임키패스는 섹션이 여러 개인 경우 각 섹션의 이름을 제공하는 객체를 지정한다. 보통 섹션을 하나만 사용하는 경우에는 nil을 넘기면 된다. 또한 캐시 이름은 캐시를 저장할 파일을 생성한다. nil을 사용하면 메모리내에서만 추적이 일어나는데, 이는 약간의 오버헤드를 유발할 수 있다. 대신 서로 다른 엔트리에 대해서 컨트롤러를 반복해서 재사용해야 하거나 할 때는 일일이 캐시를 삭제해 주어야 하므로, 상황에 맞게 사용하면 된다. 또한 캐시는 단순히 이름만 주면 된다.

다음은 컨트롤러를 생성하는 예제이다. 해당 클래스에는 컨텍스트, 패치 리퀘스트, 컨트롤러가 각각 프로퍼티로 설정되어 있다고 가정한다.

<# managedObjectContext를 구함 #>
...

-(NSFetchRequest*)fetchRequest
{
    // 일반적인 코어데이터의 fetch 구문과 동일하다.
    if(!_fetchRequest) {
        _fetchRequest = [[NSFetchRequest alloc] init];
        NSEntityDescription *entityDescription = [NSEntityDescription EntityForName:@"Memo" inManagedObjectContext:self.managedObjectContext];
        [_fetchRequest setEntity:entityDescription];

        NSPredicate *predicate = [NSPredicate predicateWithFormat:@"isAvailable == YES"];
        [_fetchRequest setPredicate:predicate];

        NSSortDescriptor *sortDescriptor = [[NSSortDescriptor alloc] initWithKey:@"lastModifiedDate" ascending:NO];
        [_fetchRequest setSortDescriptors:[NSArray arrayWithObject:sortDescriptor]];
    }
    return _fetchRequest;
}

-(NSFetchedResultsController*)controller {
    if (!_controller) {
        _controller = [[NSFetchedResultsController alloc] initWithFetchRequest:self.fetchRequest
            managedObjectContext:self.managedObjectContext
            sectionNameKeyPath:nil
            cacheName:nil];
    }
    return _controller;
}

performFetch

컨트롤러에 performFetch 메시지를 보내면 코어데이터 저장소로부터 지정된 조건에 맞게 결과를 가져와 그 결과값을 컨트롤러 내부에서 관리하기 시작한다. 만약 델리게이트가 지정되어 있다면, 컨트롤러는 불러온 결과에 대한 변경을 추적하고 변경이 생기면 이를 델리게이트에게 알려준다.

테이블 뷰와의 결합

컨트롤러는 sections, sectionIndexTitles의 프로퍼티와 objectAtIndexPath:, sectionForSectionIndexTitle:, sectionIndexTitleForSectionName: 등의 메소드를 가지고 있고, 이를 사용하여 테이블 뷰에 데이터소스로 연결될 수 있다.

다음은 테이블뷰의 데이터소스를 구현한 예이다. 테이블 뷰 컨트롤러에서 구현해야 하는 내용은 대부분 동일하거나 유사한 구문으로 페치 컨트롤러에서 제공하고 있다.

-(NSInteger)numberOfSectionsInTableView:(UITableView*)tableView
{
    return [self.controller.sections count];
}
-(NSArray*)sectionIndexTitlesForTableView:(UITableView*)tableView
{
    return self.controller.sectionIndexTitles;
}
-(UITableView*)sectionForSectionIndexTitle:(NSString*)title atIndex:(NSInteger)index
{
    return [self.controller sectionForSectionIndexTitle:title atIndex:index];
}
-(UITableView*)tableView titleForHeaderInSection:(NSInteger)section
{
    id<NSFetchedResultSectionInfo> sectionInfo = [[self.controller sections] objectAtIndex:section];
    return [sectionInfo name];
}
-(NSInteger)tableView:(UITableView*)tableView numberOfRowsInSection:(NSInteger)section
{
    id<NSFetchedResultsSectionInfo> sectionInfo = [[self.controller sections] objectAtIndex:section];
    return [sectionInfo numberOfObjects];
}
-(UITableViewCell)tableView:(UITableView*)tableView cellForRowAtIndexPath:(NSIndexPath*)indexPath
{
    UITableViewCell *cell = [tableView dequeReusableCellWithIdentifier:@"cellIdentifier"];
    NSManagedObject *managedObject= [self.controller objectAtIndexPath:indexPath];
    <# cell에 표시될 내용을 memo로 부터 지정 #>
        cell.textLabel.text = [managedObject valueForKey:@"title"];
    ...
        return cell;
}

각각의 섹션은 NSFetchedResultsSectionInfo 프로토콜을 따르는데, 이 프로토콜에는 indexTitle, name, numberOfObjects, objects이 정의되어 있고, 이를 통해 섹션과 관련한 정보를 테이블 뷰에 제공할 수 있다.

데이터의 변경

데이터가 추가/삭제/변경될 때 컨트롤러의 델리게이트가 있다면 (그리고 그 델리게이트가 최소 1개의 델리게이트 메소드를 구현했다면) 컨트롤러는 로드된 데이터를 추적하게 된다. 그리고 변화가 발생할 때 델리게이트에게 적절한 메시지를 보내게 된다.

  • -controllerWillChangeContent:
  • -controller: didChangeObject: atIndexPath: forChangeType: newIndexPath:
  • -controller: didChangeSection: atIndex: forChangeType:
  • -controllerDidChangeContent:

이 때 가장 많이 사용하는 것은 두 번째의 것으로 각 객체의 추가/삭제에 대한 내용으로 이는 다음과 같이 구현된다. 특히 이 메소드는 여러 개의 데이터가 한 번에 변경되는 경우에, 여러 차례 반복해서 호출되므로 이에 신경을 써야 한다.

- (void)controller:(NSFetchedResultsController *)controller
didChangeObject:(id)anObject
atIndexPath:(NSIndexPath *)indexPath
forChangeType:(NSFetchedResultsChangeType)type
newIndexPath:(NSIndexPath *)newIndexPath
{        
    if (!self.suspendAutomaticTrackingOfChangesInManagedObjectContext)
    {
        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:
                [self.tableView 
      reloadRowsAtIndexPaths:[NSArray arrayWithObject:indexPath] 
            withRowAnimation:UITableViewRowAnimationFade];
                break;

            case NSFetchedResultsChangeMove:
                [self.tableView 
        deleteRowsAtIndexPaths:[NSArray arrayWithObject:indexPath] 
              withRowAnimation:UITableViewRowAnimationFade];
                [self.tableView 
     insertRowsAtIndexPaths:[NSArray arrayWithObject:newIndexPath] 
           withRowAnimation:UITableViewRowAnimationFade];
                break;
        }
    }
}

또한 섹션 자체의 변경에 대해서는 다음과 같이 구현할 수 있다. 테이블 뷰 컨트롤러의 변경처리방식과 거의 1대 1로 대응된다고 보면 된다.

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

            case NSFetchedResultsChangeDelete:
                [self.tableView 
                  deleteSections:[NSIndexSet indexSetWithIndex:sectionIndex] 
                withRowAnimation:UITableViewRowAnimationFade];
                break;
        }
    }
}

또한, 컨텐츠의 변경 시작과 끝을 알리는 두 메소드는 tableView의 beginUpdates, endUpdates를 각각 호출하여 변경 사항이 UI에 한 번에 적용되는 것으로 보이도록 할 수 있다.