본문 바로가기

학습 노트/iOS (2021)

193. Fetched Result Controller

Fetched Result Controller


FetchedResultController는 영구 저장소에서 가져온 내용을 관리하고 표시한다.
주로 TableView와 연동할 때 사용한다.

Delegate 구현을 사용해 데이터를 모니터링하고 업데이트하며,
Caching을 사용해 읽기 성능을 향상할 수 있다.
이 둘의 사용 여부에 따라 3가지 모드를 사용할 수 있다.

  Delegate Caching
No Tracking Mode
Memory-Only Tracking Mode ⭕️
Full Persistent Tracking Mode ⭕️ ⭕️
  • No Tracking Mode
    가져온 데이터를 관리하는 최소한의 역할만 수행한다.
  • Memory-Only Tracking Mode
    가져온 데이터를 관리하며, Monitoring 기능을 활성화한다.
    데이터가 업데이트되면 Delegate에 이를 알려 주는 방식으로 동작한다.
  • Full Persistent Tracking Mode
    데이터를 모니터링하며 영구저장소로부터 가져온 데이터를 Cache에 저장한다.

 

기본

FetchedResultControllerTableViewController.swift > fetchRequest

FetchedResultController를 사용하기 위해서는 FetchRequest가 필요하다.
또한, 해당 FetchRequest는 반드시 하나 이상의 SortDescriptor가 존재해야 한다.

   lazy var fetchRequest: NSFetchRequest<EmployeeEntity> = {
      let request = NSFetchRequest<EmployeeEntity>(entityName: "Employee")

      request.predicate = NSPredicate(format: "department != NIL")

      let sortByName = NSSortDescriptor(key: "name", ascending: true)
      request.sortDescriptors = [sortByName]

      request.fetchBatchSize = 30

      return request
   }()

FetchRequest는 지연 저장 속성으로 선언돼있다.
EmployeeEntity를 이름순으로 정렬해서 가져오게 된다.

FetchedResultControllerTableViewController.swift > resultController

위에서 생성한 fetchRequest를 사용해 FetchResultController를 생성한다.
마찬가지로 지연 속성을 가진다.

lazy var resultController: NSFetchedResultsController<EmployeeEntity> = { [weak self] in
		let controller = NSFetchedResultsController(fetchRequest: fetchRequest, managedObjectContext: DataManager.shared.mainContext, sectionNameKeyPath: nil, cacheName: nil)
		controller.delegate = self
		return controller
	}()

 

NSFetchedResultsController(fetchRequest: managedObjectContext: sectionNameKeyPath: cacheName:)

 

Apple Developer Documentation

 

developer.apple.com

생성자는 총 네 개의 파라미터를 가진다.

  • param1 : fetchRequest
    사용할 fetchRequest를 전달한다.
  • param2 : managedObjectContext
    첫 번째 파라미터로 가져온 데이터는 해당 파라미터로 전달되는 Context에 등록된다.
    등록된 객체는 FetchedResultController의 모니터링 대상이 된다.
    모니터링 대상이 됐다는 의미는 데이터가 수정, 등록, 삭제되면 특정 메서드를 호출한다는 것을 의미한다.
  • param3 : sectionNameKeyPath
    해당 파라미터에 keyPath를 전달하면 동일한 값이 전달된 데이터를 Section으로 나누어 저장한다.
  • param4 : cacheName
    캐시의 이름을 지정한다.

현재 단계에선 첫 번째와 두 번째 파라미터만 전달하고 나머지는 nil을 전달한다.

FetchedResultControllerTableViewController.swift > FetchedResultsControllerTableViewController

새 extension을 생성한다.

extension FetchedResultsControllerTableViewController: NSFetchedResultsControllerDelegate {
	
}

해당 extension 내에 새로운 메서드를 선언한다.

extension FetchedResultsControllerTableViewController: NSFetchedResultsControllerDelegate {
	func controllerDidChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
		tableView.reloadData()
	}
}

controllerDidChangeContent(_:)

 

 

Apple Developer Documentation

 

developer.apple.com

해당 메서드는 fetchRequest를 통해 데이터를 가져오거나 업데이트가 되게 되면 호출된다.
가장 단순한 구현으로 tableView를 새로고침 할 수 있도록 reloadData 메서드를 호출한다.

FetchedResultControllerTableViewController.swift > viewDidLoad

   override func viewDidLoad() {
      super.viewDidLoad()
	   do {
		   try resultController.performFetch()
	   } catch {
		   print(error.localizedDescription)
	   }
   }
   
   deinit {
	   resultController.delegate = nil
   }

이후 Scene에 진입하면 데이터를 가져오도록 구현한다.
생성한 fetchRequest를 통해 데이터를 가져와 인스턴스 내부에 저장한다.
이후 참조 사이클 방지를 위해 소멸자에서 delegate를 다시 nil로 초기화할 수 있도록 한다.

FetchedResultControllerTableViewController.swift > fetchedResultsControllerTableViewController

가져온 데이터를 TableView에 표시하도록 수정한다.

extension FetchedResultsControllerTableViewController {
   override func numberOfSections(in tableView: UITableView) -> Int {
	   return resultController.sections?.count ?? 0
   }
   
   override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
      return 0
   }
   
   
   override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
      let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath)
      
      
      return cell
   }
}

내부 데이터에 직접 접근하는 것은 불가능하기 때문에
fetchedResultController가 제공하는 메서드와 속성을 사용해야 한다.
따라서 표시할 section의 개수는 resultController에서 제공하는 sections 속성의 count를 사용해 계산한다.

extension FetchedResultsControllerTableViewController {
   override func numberOfSections(in tableView: UITableView) -> Int {
	   return resultController.sections?.count ?? 0
   }
   
   override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
	   guard let sections = resultController.sections else { return 0 }
	   let sectionInfo = sections[section]
	   return sectionInfo.numberOfObjects
   }
   
   
   override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
      let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath)
      
      
      return cell
   }
}

section을 구성하는 row도 resultController의 속성을 통해 구해야 한다.

extension FetchedResultsControllerTableViewController {
   override func numberOfSections(in tableView: UITableView) -> Int {
	   return resultController.sections?.count ?? 0
   }
   
   override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
	   guard let sections = resultController.sections else { return 0 }
	   let sectionInfo = sections[section]
	   return sectionInfo.numberOfObjects
   }
   
   
   override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
      let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath)
      
	   let target = resultController.object(at: indexPath)
	   cell.textLabel?.text = target.name
	   cell.detailTextLabel?.text = target.department?.name
      return cell
   }
}

Cell을 구성하기 위해 특정 데이터를 가져와야 한다.

object(at:)

 

Apple Developer Documentation

 

developer.apple.com

해당 메서드에 indexPath를 전달해 특정 데이터에 접근한다.

 

Monitoring

FetchedResultControllerTableViewController.swift > fetchedResultsControllerTableViewController

지금과 같이 데이터가 추가되고 삭제될 때 TableView 전체를 새로고침 하는 것은 비효율적이다.
따라서 batch 방식 업데이트를 적용해 본다.

extension FetchedResultsControllerTableViewController: NSFetchedResultsControllerDelegate {
	func controllerDidChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
		tableView.reloadData()
	}
	
	func controllerWillChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
		tableView.beginUpdates()
	}
}

 

controllerWillChangeContent(_:)

 

Apple Developer Documentation

 

developer.apple.com

controllerWillChangeContent 메서드를 추가한다.
해당 메서드는 값을 업데이트 하기 직전에 호출된다.

extension FetchedResultsControllerTableViewController: NSFetchedResultsControllerDelegate {
	func controllerDidChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
		tableView.reloadData()
	}
	
	func controllerWillChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
		tableView.beginUpdates()
	}
	
	func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>, didChange anObject: Any, at indexPath: IndexPath?, for type: NSFetchedResultsChangeType, newIndexPath: IndexPath?) {
		switch type{
		case .insert:
			if let insertIndexPath = newIndexPath {
				tableView.insertRows(at: [insertIndexPath], with: .automatic)
			}
		case .delete:
			if let deleteIndexPath = indexPath {
				tableView.deleteRows(at: [deleteIndexPath], with: .fade)
			}
		case .update:
			if let updateIndexPath = indexPath {
				tableView.reloadRows(at: [updateIndexPath], with: .fade)
			}
		case .move:
			if let originalIndexPath = indexPath, let targetIndexPath = newIndexPath {
				tableView.moveRow(at: originalIndexPath, to: targetIndexPath)
			}
		}
	}
}

controller(_:didChange:at:for:newIndexPath:)

 

Apple Developer Documentation

 

developer.apple.com

  • param1
    fetchedResultController를 전달한다.
  • param2 : didChange
    업데이트된 데이터를 전달한다.
  • param3 : at
    변경된 indexPath를 전달한다.
  • param4 : for
    업데이트 타입을 전달한다. CRUD를 구분할 수 있다.
  • param5 : newIndexPath
    추가됐거나 이동된 경우 indexPath를 전달한다.

해당 메서드 자체가 이미 업데이트된 다음 호출되는 메서드이기 때문에 다시 업데이트할 필요는 없다.
업데이트 타입에 따라 TableView를 알맞은 방식으로 업데이트하도록 구현한다.
이 경우 TableView 전체를 새로고침 하지 않고, 대상만 업데이트되기 때문에 조금 더 효율적이다.

extension FetchedResultsControllerTableViewController: NSFetchedResultsControllerDelegate {
	func controllerDidChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
		tableView.endUpdates()
	}
	
	func controllerWillChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
		tableView.beginUpdates()
	}
	
	func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>, didChange anObject: Any, at indexPath: IndexPath?, for type: NSFetchedResultsChangeType, newIndexPath: IndexPath?) {
		switch type{
		case .insert:
			if let insertIndexPath = newIndexPath {
				tableView.insertRows(at: [insertIndexPath], with: .automatic)
			}
		case .delete:
			if let deleteIndexPath = indexPath {
				tableView.deleteRows(at: [deleteIndexPath], with: .fade)
			}
		case .update:
			if let updateIndexPath = indexPath {
				tableView.reloadRows(at: [updateIndexPath], with: .fade)
			}
		case .move:
			if let originalIndexPath = indexPath, let targetIndexPath = newIndexPath {
				tableView.moveRow(at: originalIndexPath, to: targetIndexPath)
			}
		}
	}
}

이후 정상적으로 변경된 방식대로 업데이트할 수 있도록,
controllerDidChangeContent 메서드에서 reloadData 대신 endUpdates를 호출해 준다.

FetchedResultControllerTableViewController.swift > resultController

출력하는 데이터를 보기 편하도록 Section으로 분리하도록 한다.

	lazy var resultController: NSFetchedResultsController<EmployeeEntity> = { [weak self] in
		let controller = NSFetchedResultsController(fetchRequest: fetchRequest, managedObjectContext: DataManager.shared.mainContext, sectionNameKeyPath: #keyPath(EmployeeEntity.department.name), cacheName: nil)
		controller.delegate = self
		return controller
	}()

nil을 전달했던 세 번째 파라미터에 section 구분에 사용할 keypath를 전달한다.
department 어트리뷰트의 name을 사용해 구분한다.

FetchedResultControllerTableViewController.swift > controller()

이제 생성된 section에 delegate가 대응할 수 있도록 수정한다.

extension FetchedResultsControllerTableViewController: NSFetchedResultsControllerDelegate {
	func controllerDidChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
		tableView.endUpdates()
	}
	
	func controllerWillChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
		tableView.beginUpdates()
	}
	
	func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>, didChange anObject: Any, at indexPath: IndexPath?, for type: NSFetchedResultsChangeType, newIndexPath: IndexPath?) {
		switch type{
		case .insert:
			if let insertIndexPath = newIndexPath {
				tableView.insertRows(at: [insertIndexPath], with: .automatic)
			}
		case .delete:
			if let deleteIndexPath = indexPath {
				tableView.deleteRows(at: [deleteIndexPath], with: .fade)
			}
		case .update:
			if let updateIndexPath = indexPath {
				tableView.reloadRows(at: [updateIndexPath], with: .fade)
			}
		case .move:
			if let originalIndexPath = indexPath, let targetIndexPath = newIndexPath {
				tableView.moveRow(at: originalIndexPath, to: targetIndexPath)
			}
		}
	}
	
	func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>, didChange sectionInfo: NSFetchedResultsSectionInfo, atSectionIndex sectionIndex: Int, for type: NSFetchedResultsChangeType) {
		switch type {
		case .insert:
			tableView.insertSections(IndexSet(integer: sectionIndex), with: .automatic)
		case .delete:
			tableView.deleteSections(IndexSet(integer: sectionIndex), with: .automatic)
		default:
			break
		}
	}
}

controller(_:didChange:atSectionIndex:for:)

 

Apple Developer Documentation

 

developer.apple.com

해당 메서드는 section이 업데이트될 때마다 호출되고,
파라미터로 전달되는 type을 통해 어떤 작업인지 판단하게 된다.
단, type 파라미터는 insert와 delete만 전달한다.

FetchedResultControllerTableViewController.swift > FetchedResultsControllerTableViewController

sortDescripter를 section에 대응할 수 있도록 수정한다.

class FetchedResultsControllerTableViewController: UITableViewController {
   
   lazy var fetchRequest: NSFetchRequest<EmployeeEntity> = {
      let request = NSFetchRequest<EmployeeEntity>(entityName: "Employee")

      request.predicate = NSPredicate(format: "department != NIL")

      let sortByName = NSSortDescriptor(key: "name", ascending: true)
	   let sortByDeptName = NSSortDescriptor(key: "department.name", ascending: false)
      request.sortDescriptors = [sortByDeptName, sortByName]

      request.fetchBatchSize = 30

      return request
   }()
	
	lazy var resultController: NSFetchedResultsController<EmployeeEntity> = { [weak self] in
		let controller = NSFetchedResultsController(fetchRequest: fetchRequest, managedObjectContext: DataManager.shared.mainContext, sectionNameKeyPath: #keyPath(EmployeeEntity.department.name), cacheName: nil)
		controller.delegate = self
		return controller
	}()
   
   @IBAction func showMenu(_ sender: Any) {
      showMenu()
   }
   

   
   
   func changeSortOrder() {
//      let sortByDeptName = NSSortDescriptor(key: "department.name", ascending: false)
//      let sortBySalary = NSSortDescriptor(key: "salary", ascending: true)
//      resultController.fetchRequest.sortDescriptors = [sortByDeptName, sortBySalary]
   }
   
   override func viewDidLoad() {
      super.viewDidLoad()
	   do {
		   try resultController.performFetch()
	   } catch {
		   print(error.localizedDescription)
	   }
   }
   
   deinit {
	   resultController.delegate = nil
   }
}

sortByDeptName이라는 이름의 NSSortDescriptor 인스턴스를 생성하고,
request의 sortDescriptors 속성에 지정한다.
이때 배열에 전달되는 순서가 적용 순서가 되므로 주의해야 한다.

FetchedResultControllerTableViewController.swift > fetchedResultsControllerTableViewController

Section을 구별하기 쉽도록 TableView의 Header를 추가한다.

extension FetchedResultsControllerTableViewController {
   override func numberOfSections(in tableView: UITableView) -> Int {
	   return resultController.sections?.count ?? 0
   }
   
   override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
	   guard let sections = resultController.sections else { return 0 }
	   let sectionInfo = sections[section]
	   return sectionInfo.numberOfObjects
   }
   
   
   override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
      let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath)
      
	   let target = resultController.object(at: indexPath)
	   cell.textLabel?.text = target.name
	   cell.detailTextLabel?.text = target.department?.name
      return cell
   }
	
	override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
		return resultController.sections?[section].name
	}
}

마찬가지로 데이터에 직접 접근할 수 없으며,
resultController가 제공하는 sections 속성에서 section name을 사용할 수 있도록 구현한다.

FetchedResultControllerTableViewController.swift > fetchedResultsControllerTableViewController

section 간의 이동을 편하게 할 수 있도록 section index title을 추가한다.

extension FetchedResultsControllerTableViewController {
   override func numberOfSections(in tableView: UITableView) -> Int {
	   return resultController.sections?.count ?? 0
   }
   
   override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
	   guard let sections = resultController.sections else { return 0 }
	   let sectionInfo = sections[section]
	   return sectionInfo.numberOfObjects
   }
   
   
   override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
      let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath)
      
	   let target = resultController.object(at: indexPath)
	   cell.textLabel?.text = target.name
	   cell.detailTextLabel?.text = target.department?.name
      return cell
   }
	
	override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
		return resultController.sections?[section].name
	}
	
	override func sectionIndexTitles(for tableView: UITableView) -> [String]? {
		return resultController.sectionIndexTitles
	}
}

resultController의 sectionIndexTitles 속성을 반환하도록 구현한다.

 

Caching

FetchedResultControllerTableViewController.swift > fetchedResultsControllerTableViewController > resultController

데이터 로드 시마다 Section을 매번 분할하고, 정렬을 매번 시도하는 것은 매우 비효율적이다.
내용을 기억해 뒀다가 데이터 로드를 조금 더 효율적으로 수행할 수 있도록 Cache를 사용하도록 수정해 본다.

	lazy var resultController: NSFetchedResultsController<EmployeeEntity> = { [weak self] in
		let controller = NSFetchedResultsController(fetchRequest: fetchRequest, managedObjectContext: DataManager.shared.mainContext, sectionNameKeyPath: #keyPath(EmployeeEntity.department.name), cacheName: "CacheByDeptName")
		controller.delegate = self
		return controller
	}()

비어있던 마지막 파라미터에 문자열을 전달한다.
해당 문자열을 Cache의 이름으로 사용되며, 해당 Cache를 생성해야 하는지, 불러와서 사용해야 하는지는
Controller가 알아서 판단한다.
이렇게 생성된 Cache는 별도의 파일로써 존재하며 앱을 종료해도 이후에 재사용이 가능하다.

FetchedResultControllerTableViewController.swift > viewDidLoad()

생성된 Cache를 사용할 수 있도록 viewDidLoad 메서드를 수정한다.

   override func viewDidLoad() {
      super.viewDidLoad()
	   do {
		   try resultController.performFetch()
	   } catch {
		   print(error.localizedDescription)
	   }
	   
	   NSFetchedResultsController<EmployeeEntity>.deleteCache(withName: "CacheByDeptName")
   }

Cache 사용은 deleteCache 메서드를 호출해 사용한다.
위에서 네 번째 파라미터로 전달했던 이름을 전달하면 해당 Cache를 불러와 사용 후 삭제한다.
nil을 전달하면 모든 Cache를 불러와 사용후 삭제한다.

FetchedResultControllerTableViewController.swift > changeSortOrder()

   func changeSortOrder() {
      let sortByDeptName = NSSortDescriptor(key: "department.name", ascending: false)
      let sortBySalary = NSSortDescriptor(key: "salary", ascending: true)
      resultController.fetchRequest.sortDescriptors = [sortByDeptName, sortBySalary]
   }

FetchedResultController와 연결된 FetchRequest의 정렬을 변경하는 경우
위와 같이 단순히 정렬 순서를 변경하는 일반적인 방법이 아닌 특별한 패턴을 사용해야 한다.

   func changeSortOrder() {
      let sortByDeptName = NSSortDescriptor(key: "department.name", ascending: false)
      let sortBySalary = NSSortDescriptor(key: "salary", ascending: true)
      resultController.fetchRequest.sortDescriptors = [sortByDeptName, sortBySalary]
	   
	   do {
		   try resultController.performFetch()
		   tableView.reloadData()
	   } catch {
		   print(error.localizedDescription)
	   }
   }

위와 같이 resultController에서 performFetch 메서드를 호출해
변경된 정렬 규칙이 적용될 수 있도록 해야 한다.

지금과 같이 규칙을 전부 반전시킨 경우 모든 Cell이 이동해야 하기 때문에 delegate 메서드가 호출되지 않는다.
따라서 수동으로 tableView를 새로고침 할 필요가 있다.

지금 상태로도 잘 작동하지만 종종 기존에 존재하던 Cache의 내용과 충돌하는 경우가 있다.

   func changeSortOrder() {
	   NSFetchedResultsController<EmployeeEntity>.deleteCache(withName: resultController.cacheName)
	   
      let sortByDeptName = NSSortDescriptor(key: "department.name", ascending: false)
      let sortBySalary = NSSortDescriptor(key: "salary", ascending: true)
      resultController.fetchRequest.sortDescriptors = [sortByDeptName, sortBySalary]
	   
	   do {
		   try resultController.performFetch()
		   tableView.reloadData()
	   } catch {
		   print(error.localizedDescription)
	   }
   }

따라서 정렬 기준을 변경하기 전에 Cahce를 삭제하는 과정을 한번 거치는 것이 좋다.

'학습 노트 > iOS (2021)' 카테고리의 다른 글

195. Faulting & Uniquing  (0) 2022.05.24
194. Transformable  (0) 2022.05.20
192. Expression  (0) 2022.03.30
191. Predicate Syntax  (0) 2022.03.24
190. Predicate  (0) 2022.03.24