학습내용
CoreData fetch request에 대한 결과를 관리하고 사용자에게 데이터를 보여준다.
테이블 뷰가 여러가지 방법으로 사용되는동안 fetchResultController는 list view를 제공함으로써 도와준다. UITableView
는 dataSource가 행에 필요한 셀들을 제공하는 것을 예상한다.
이를 fetch request
를 사용하는 fetchedResultController를 사용하여 구성한다.
이 객체는 어떤타입의 엔티티를 fetch할 것인지와 결과를 어떻게 sort할 것인지를 명시한다. 또한 엔티티의 특정 인스턴스를 언제 포함할지와 같은 기준도 추가할 수 있다.
fetchedResultController는 효율적으로 fetch된 결과를 분석하고 결과집합의 섹션들을 계산한다. 또한 인덱스에 해당하는 정보들도 계산한다.
추가적인 기능은 다음과 같다.
따라서 이 컨트롤러에는 델리게이트를 가지고있는지, 캐시파일 이름이 설정되어있는지에 따라 결정되는 세 가지 모드를 가지고 있다.
delegate
가 nil
로 설정됨. 컨트롤러는 단순히 fetch가 실행되었을 때 데이터를 제공하는 역할만 한다.delegate
는 nil
이 아니고 cache
파일 이름은 nil
이다. 컨트롤러는 결과 집합안의 객체들을 감시하고 변화에 반응하여 section을 업데이트하고 정보를 정렬한다.cache
파일 이름이 nil
이 아니다. 이 컨트롤러는 결과를 감시하고 섹션을 업데이트하고 관련된 변경사항에 반응하여 정보를 정렬한다. 그리고 그 결과를 캐시한다.변경을 추적하기 위해서 변경을 추적하는 델리게이트 메서드 중 하나가 구현되어야 한다.
일반적으로 테이블 뷰 컨트롤러의 인스턴스 변수로서 NSFetchedResultsController
인스턴스를 생성한다. 이를 초기화할 때 다음 네 가지 파라미터를 제공해야 한다.
인스턴스를 생성한 이후에 performFetch()
메서드를 호출해 실제로 패치를 실행한다.
let context = <#Managed object context#>
let fetchRequest = NSFetchRequest<AAAEmployeeMO>(entityName: "Employee")
// Configure the request's entity, and optionally its predicate
fetchRequest.sortDescriptors = [NSSortDescriptor(key: "<#Sort key#>", ascending: true)]
let controller = NSFetchedResultsController(fetchRequest: fetchRequest, managedObjectContext: context, sectionNameKeyPath: nil, cacheName: nil)
do {
try controller.performFetch()
} catch {
fatalError("Failed to fetch entities: \(error)")
}
만약 캐시를 사용 중이라면 fetch request, predicate, sort descriptor를 변경하기 전에 deleteCache(withName:)
을 꼭 실행해야 한다.
만약 cacheName을 nil로 하는 게 아니라면 같은 fetched results controller를 재사용하면 안 된다.
만약 fetchedResultsContoller에 대해 delegate를 설정했다면 그 컨트롤러는 관리 객체 컨텍스트로부터 오는 알림을 수신할 것이다.
컨텍스트 안의 어떠한 변화(결과 집합이나 섹션 정보에 영향을 미치는)가 처리될 것이고 그 결과는 그에 따라 업데이트 될 것이다.
이 resultController는 결과 객체의 위치가 변경되거나 섹션이 수정되었을 때 델리게이트에게 알린다.
일반적으로 이 델리게이트 메서드를 이용해서 테이블 뷰를 업데이트 할 수 있다.
resultController는 캐시를 사용해서 반복적인 작업을 피할 수 있다. 캐시는 앱을 시작할 때부터 유지된다.
NSFetchedResultsController
의 인스턴스를 초기화했다면 cacheName
을 명시할 수도 있다.(만약 명시하지 않았다면 그 결과컨트롤러는 캐시데이터가 없다.) 결과컨트롤러를 만들 때 주어진 이름에 해당하는 존재하는 캐시를 찾는다.
섹션과 정렬된 정보가 변할때마다 캐시는 업데이트 된다.
만약 여러개의 fetchedResultsController를 가지고 있다면 다른 캐시이름을 줘야 한다.
일반적으로 NSFetchedResultsController
는 결과 객체의 위치나 섹션이 변경되었을 때 대리자에게 호출함으로써 모델 레이어의 변화에 반응되도록 설계되었다.
만약 사용자가 테이블을 재정렬하는 것을 허용하도록 하려면 델리게이트 메서드를 고려해야 한다.
컨트롤러의 관리 객체 컨텍스트가 processPendingChanges
메세지를 수신할 때까지 반영되지 않는다. 그러므로 만약 결과객체의 위치가 변경되도록 관리객체 어트리뷰트의 값을 변경했다면 그것의 인덱스는 현재 이벤트 사이클이 끝날때까지 변경되지 않을 것이다.
예를 들어 다음의 코드는 "same"을 찍는다.
let frc = <#A fetched results controller#>
let managedObject = <#A managed object in frc's fetchedObjects array#>
let beforeIndexPath = frc.indexPath(forObject: managedObject)
managedObject.setValue("Fred", forKey: "name")
let afterIndexPath = frc.indexPath(forObject: managedObject)
if beforeIndexPath?.compare(afterIndexPath!) == .orderedSame {
print("same")
}
관리객체의 속성을 변경시켰는데 indexPath
가 동일하다. 따라서 setValue
뒤에는 frc.processPendingChanges
가 필요하다. 이는 사용자에게 임시적으로 수정된 내용을 보여주고 싶을 때 사용하는 것으로 보인다. 영구적으로 저장하려면 save
를 사용한다.
결과를 수정하기위해 fetch request
를 변경할 수 있다. 만약 원한다면 다음의 순서를 따른다.
performFetch
를 수행한다. 관리 객체 컨텍스트가 개별 객체가 무효하다는 것을 알릴 때 결과컨트롤러는 이를 삭제된 객체로 취급하고 적절한 델리게이트 호출을 보낸다.
관리 객체 컨텍스트 안의 모든 객체들이 동시에 무효해지는 건 가능하다. (예를 들어 reset()
호출의 결과나 만약 저장소가 persistent store coordinator에 의해 제거되었을 때) 이러한 현상이 일어났을 때 NSFetchedResultsController
는 모든 객체를 무효화하지는 않는다. 그리고 객체 삭제에 대한 알림을 보내지도 않는다. 대신에 performFetch()
메서드를 호출해서 컨트롤러의 상태를 reset
시키고난 후에 reloadData
로 리로드 한다.
기존 코어데이터를 이용해 테이블 뷰의 데이터를 삭제할 때는 다음 세 가지 보일러 플레이트 코드가 존재한다.
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
CoreDataStack.shared.delete(itemArray[indexPath.row])
itemArray.remove(at: indexPath.row)
saveItems()
tableView.deselectRow(at: indexPath, animated: true)
}
이는 fetchedResultsController
로 깔끔하게 제거할 수 있다.
왜냐하면 fetchedResultsController
는 CoreData
의 특정 저장소에 해당하는 context
를 모니터링하고 있기 때문이다. 정확히는 NSFetchedResultsControllerDelegate
가 이 일을 수행한다. 따라서 fetchedResultsController
를 사용하는 뷰컨에서 이 델리게이트 메서드를 채택하면 모니터링할 수 있게 된다.
이 의미는 코어데이터가 변경되었을 때 자동으로 호출되는 메서드가 존재한다는 의미와 같다.
delete
메서드를 호출해서 해당 객체가 지워지고 난 후에 save
만 성공적으로 되면 UI
처리는 이 델리게이트에서 처리한다는 것이다.
func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>,
didChange anObject: Any,
at indexPath: IndexPath?,
for type: NSFetchedResultsChangeType,
newIndexPath: IndexPath?) {
switch type {
case .insert:
ahjaeTableView.insertRows(at: [newIndexPath!], with: .automatic)
case .delete:
ahjaeTableView.deleteRows(at: [indexPath!], with: .automatic)
default:
print("Unexpected NSFetchedResultsChangeType")
}
}
NSFetchedResultsController
를 적용하기 전에는 코어데이터를 지우는 메서드를 호출하고 나서 테이블 뷰의 행을 지우는 메서드를 호출했다.
하지만 코어데이터를 지우는 메서드를 호출했지만 모종의 이유로 만약 지워지지 않은 상태라면 데이터는 남아있지만 테이블 뷰에서는 지워질 것이다.
이를 해결하기 위해 Bool
타입의 성공여부를 반환받아서 성공했을 때 테이블 뷰를 업데이트할 수 있을 것이다. (또는 Result타입을 사용할 수도, do catch를 사용할 수도 있겠다.)
하지만 하나의 didSelect
델리게이트 메서드에서 여러가지 일을 하는 건 왠지 모르게 찝찝하다.(데이터도 지우고, )
SRP를 준수하기 위해 코어데이터와 관련있는 UI에 관련된 객체를 따로 설정해두는 것이 더 효율적으로 보였다.
따라서 NSFetchedResultsControllerDelegate
를 이용해서 CRUD동기화를 이쪽에서 적용시켜 역할을 분리했다.
또 NSFetchedResultsController
를 사용하면 좋은 이유 중에는 캐시와 관련된 내용이 있지만 아직 다루지않았다. (추후 사용해 볼 예정)
패치된 결과를 관리하는 컨트롤러를 관리하는 객체.
처음에는 fetchedResultController
를 뷰컨트롤러에서 직접 갖고 있었다. 하지만 여러개의 뷰컨에서 이를 사용할 때 performFetch
를 실행하는 코드와 이에 실패했을 때 처리하는 코드가 뷰컨에 존재해야 했기 때문에 보일러 플레이트 코드를 줄이고자 CoreDataFetchedResults
라는 객체를 만들어서 내부에 fetchedResultController
를 정의했다.
CoreData에 필요한 객체들(PersistentConainer, NSManagedObjectContext)를 갖고있고 CRUD를 하는 객체
초기에는 CoreDataStack
을 싱글톤이 아닌 일반적인 클래스로 만들었는데 보통 하나의 NSPersistentContainer
를 사용하고 엔티티는 CRUD
할 때 파라미터로 넣어주면 된다고 생각했기 때문에 싱글톤으로 구성해서 앱 전역에서 접근할 수 있도록 만들었다.
// ViewController.swift
class ViewController: UIViewController {
let categorySort = NSSortDescriptor(key: "createdAt", ascending: true)
// FechedResults객체 생성과 동시에 `delegate`와 `managedContext` 설정
lazy var fetchedAhjaeResults = CoreDataFetchedResults(
ofType: Joke.self,
entityName: "Joke",
sortDescriptors: [categorySort],
managedContext: CoreDataStack.shared.managedContext,
delegate: self
)
}
extension ViewController: UITableViewDataSource {
// fetchedResultController에는 코어데이터에서 엔티티에 해당하는 row를 가져온다.
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return fetchedAhjaeResults.fetchedResultsController.sections?[section].numberOfObjects ?? 0
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath)
let joke = fetchedAhjaeResults.fetchedResultsController.object(at: indexPath)
cell.textLabel?.text = joke.body
return cell
}
}
기존의 방법에는 코어데이터에서 값을 가져오면 뷰컨에서 배열변수를 하나 가져야 했다. 하지만 fetchedResultController
내에 전부 존재하기 때문에 변수를 사용하지 않고도 구성할 수 있게 되었다.
이전에 NSFetchedResultsController
의 대리자를 ViewController로 설정했다. 따라서 이 델리게이트 프로토콜을 채택하고 코어데이터가 변경되는 시점을 세분화해서 관리할 수 있다.
예를 들어 코어데이터가 변경될 때마다 호출되는 다음의 메서드가 존재한다.
func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>,
didChange anObject: Any,
at indexPath: IndexPath?,
for type: NSFetchedResultsChangeType,
newIndexPath: IndexPath?) {
switch type {
case .insert:
ahjaeTableView.insertRows(at: [newIndexPath!], with: .automatic)
case .delete:
ahjaeTableView.deleteRows(at: [indexPath!], with: .automatic)
default:
print("Unexpected NSFetchedResultsChangeType")
}
}
이를 통해 코어데이터가 변경될 때 UI를 변경시키는 역할이 분리가 되었다.
FetchedResultsController
를 생성할 때 sectionNameKeyPath
라는 것이 존재한다.
fetchedResultsController = NSFetchedResultsController(
fetchRequest: fetchRequest,
managedObjectContext: managedContext,
sectionNameKeyPath: nil,
cacheName: nil
)
직독직해를 해보면 섹션이름 + keyPath.
섹션을 어떤 KeyPath로 나눌 지를 결정하는 프로퍼티로 보인다.
공식문서의 설명에는 이 값이 nil이라면 하나의 section을 갖는다고 적혀있다. 바로 확인해보자.
날짜를 기준으로 섹션을 나눠야 한다면 keyPath가 날짜를 의미하는 "createdAt"이 되어야 할 것 같다. 그래서 sectionNameKeyPath = "createdAt"
으로 변경해주었다.
이후 실행하고 메모를 추가했더니 앱이 크래시가 났다.
TableView의 데이터소스에서는 섹션의 수를 명시적으로 반환하고 있지 않기 떄문에 하나의 섹션만 존재한다.
그런데 createdAt
값으로 인해 고유한 섹션이 하나 더 생기게 되어 resultController
에는 두 개의 섹션이 생겨 테이블 뷰의 섹션개수가 불일치되어 생기는 것이다.
따라서 데이터소스의 section 수를 다음과 같이 설정해주었다.
func numberOfSections(in tableView: UITableView) -> Int {
return fetchedAhjaeResults.fetchedResultsController.sections?.count ?? 0
}
하지만 또 아래 사진의 크래시가 났다.
현재 createdAt
는 고유한 값이고 이를 기반으로 resultController
가 생성되고 있기에 각각의 row가 section인 상태이다.
이 상황에서 resultController
에 row가 추가되었기 때문에 section이 증가가 되었지만 화면에 section을 추가하는 코드가 존재하지 않기 때문에 크래시가 나는 것이었다.
하지만 신기한 사실은 lldb로 찍어봤을 때 save
에서 실패했을 때 throw를 던지는 게 아닌 크래시를 낸다는 것이었다. 이에 대한 에러 핸들링을 할 수는 없었다.
그래서 다음과 같은 결론을 내렸다.
context.save
메서드는 일반적인 경우에는 에러를 던지지만 fetchedResultController
와 연결되어있는 경우에 변경된 데이터와 테이블 뷰 UI를 동기화하는 델리게이트 메서드가 존재하지 않는다면 에러를 던지는 게 아닌 크래시를 낸다.즉 FetchedResultsController
가 감지하는 데이터 변경사항들에 대해 UI를 업데이트 할 수 있는 델리게이트 메서드를 구현해주어야 한다.
그래서 다음의 코드를 추가해줬다.
func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>, didChange sectionInfo: NSFetchedResultsSectionInfo, atSectionIndex sectionIndex: Int, for type: NSFetchedResultsChangeType) {
let indexSet = IndexSet(integer: sectionIndex)
switch type {
case .insert:
ahjaeTableView.insertSections(indexSet, with: .automatic)
case .delete:
ahjaeTableView.deleteSections(indexSet, with: .automatic)
default:
break
}
}
이런... 또 크래시가 났다.
stackOverflow에 따르면 애플 공식문서에서 테이블뷰의 변경사항은 beginUpdates
와 endUpdates
사이에 넣으라고 말한다.
beginUpdates
는 테이블 뷰의 행과 섹션을 삽입, 삭제 또는 선택하는 일련의 메서드 호출을 시작하는 메서드이고 endUpdates
는 그 반대이다.
이 두 메서드를 통해 테이블 뷰와 데이터소스를 동기화할 수 있게 만들 수 있다.
따라서 다음의 메서드를 추가했다.
func controllerWillChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
self.ahjaeTableView.beginUpdates()
}
func controllerDidChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
ahjaeTableView.endUpdates()
}
이렇게 문제를 해결했다!