iOS) UICollectionViewDataSource 대체하여 DiffableDataSource 적용해보자
내용
- UICollectionViewDataSource 를 대체하여 DiffableDataSource 를 적용해보자
- 코드 중심적으로 정리하여 추후에 사용할 때 도움이 되어보자
👉 들어가기 전
관련 글은 다음에 정리해뒀습니다.
iOS) Diffable Data Source 알아보기
iOS) DiffableDataSource 사용해서 collection view 를 업데이트해보자(개발자 문서)
👉 적용해보자
- 사용할 데이터 모델을 살펴보겠습니다.
public struct ReceivedTag: Codable {
let adjective: String
let cardTagID: Int
// ...
enum CodingKeys: String, CodingKey {
case adjective, icon, noun
case cardTagID = "cardTagId"
// ...
}
}
기존의 데이터 모델은 위와 같습니다. 이를 diffableDataSource 에 사용하기 위해서는 Hashable 을 채택한 데이터 모델을 요구합니다.
이는 모델이 갱신될때 비교해야하기 때문입니다.
우선, 하나의 컬렉션뷰 섹션을 사용할 것이기 때문에 SectionIdentifierType 역할을 하기 위해서 다음과 같이 데이터 모델을 생성하였습니다.
private enum Section: Hashable {
case main
}
// ✅ 많은 예제가 다음과 같이 CaseIterable 프로토콜을 채택합니다. 이것도 되는걸까요?
private enum Section: CaseIterable {
// CaseIterable 을 채택하면 자동으로 allCasese 를 얻게되는데 이때, 조건이 associated type 이 없는 경우입니다.
// 즉, CaseIterable 을 채택할 수 있다면 associaated type 없다는 것입니다.
// enum 타입에서 associated type 이 없다면 자동으로 Hashable 을 준수하게됩니다.
case main
}
어떻게 해야 Hashable 을 준수할 수 있을까요? 기존의 데이터 모델이 Hashable 을 채택하도록 Hashable 에 대해서 알아봅시다.
(프로토콜을 채택하기 위해서 implement(구현)하는 것을 conformance(준수한다)라고 표현합니다.)
👉 Hashable 준수하기
Hashable 프로토콜은
기본적으로 Strings, integers, floating-point, boolean values, sets 는 Hashable 을 채택합니다. optionals 이라던지 arrays 와 같은 타입들의 경우는 해당 타입이 hashable 하다면 자동으로 hashable 합니다.
커스텀 타입이 Hashable 을 다음과 같이 채택할 수 있습니다.
- associated value 가 없는 enum 타입은 자동으로 Hashable 를 준수
- hash(into:) 메소드를 구현하여 커스텀 타입이 Hashable 준수하도록 할 수 있습니다.
- Hashable 프로토콜은 Equatable 프로토콜을 상속하므로 해당 프로토콜도 준수해야 합니다.
Hashable 을 준수하기 위해서 구현해야하는 hash(into:) 메소드를 컴파일러가 자동으로 제공하는 경우도 있습니다.
- 구조체의 경우 저장된 프로퍼티들이 모두 Hashable.
- Hashable 한 associated value 를 가진 enum 타입.
(출처: https://developer.apple.com/documentation/swift/hashable)
클래스인 경우에 Hashable 을 준수하도록 해보겠습니다.
public class TagDataModel: Codable, Hashable {
let name: String
let id: Int
public func hash(into hasher: inout Hasher) {
hasher.combine(name)
hasher.combine(id)
}
// hash(into:) 만 구현하면 Equatable 프로토콜을 채택하라는 에러 메시지가 뜸.
// Equatable 을 채택하는 Hashable 을 채택하기 때문에 구현해야 함.
public static func == (lhs: TagDataModel, rhs: TagDataModel) -> Bool {
return lhs.name == rhs.name && lhs.id == rhs.id
}
}
다시 돌아와서 데이터 모델들을 수정해보겠습니다.
저장 프로퍼티들이 Hashable 한 구조체의 경우 Hashable 을 준수하기 위해서 구현해야하는 바가 없기 때문에 다음과 같이 Hashable 프로토콜을 채택하겠습니다.
public struct ReceivedTag: Codable, Hashable {
// ...
}
하지만, 데이터 모델 안에 기본 자료형이 아닌 다른 데이터 모델들이 들어있을 수 있습니다.
TagDataModel 이 Hashable 하지 않기 때문에 ReceivedTag 데이터 모델은 Hashable 을 준수할 수 없습니다.
이때는 다음과 같이 구현해줘야 합니다.
Hashable 프로토콜을 채택하게 된 데이터 모델을 사용하여 다음과 같이 diffableDataSource 변수를 만들 수 있습니다.
private var diffableDataSource: UICollectionViewDiffableDataSource<Section, ReceivedTag>?
- data source 를 대체하여 적용해보겠습니다.
// viewController.swift
private var diffableDataSource: UICollectionViewDiffableDataSource<Section, ReceivedTag>?
// ...
private func setDelegate() {
collectionView.delegate = self
// collectionView.dataSource = self
collectionView.register(TagCVC.self, forCellWithReuseIdentifier: "TagCVC")
diffableDataSource = UICollectionViewDiffableDataSource<Section, ReceivedTag>(collectionView: collectionView) { [weak self] collectionView, indexPath, itemIdentifier in
guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "TagCVC", for: indexPath) as? TagCVC else { return UICollectionViewCell() }
// 아래와 같이 동일한 동작으로 사용할 수 있습니다.
cell.initCell(itemIdentifier.name)
cell.initCell(receivedTags[indexPath.item].name)
return cell
}
collectionView.dataSource = diffableDataSource
}
// 초기 구성 후, 새로운 data source가 필요하다면 새로운 collection view 와 data source 를 만들어야 한다.
초기 데이터를 로드했을 때 컬렉션 뷰를 세팅해보겠습니다.
컬렉션 뷰에 데이터를 보여주기 위해서는 apply… 로 시작하는 4개의 메소드를 사용할 수 있습니다.
- applySnapshotUsingReloadData(_:) : iOS 15 +. 차이점을 계산하거나 변경 사항에 애니메이션을 적용하지 않고, 스냅샷의 상태만 반영하도록 UI 를 재설정하는 메소드입니다.
- **[applySnapshotUsingReloadData(:completion:)](https://developer.apple.com/documentation/uikit/uicollectionviewdiffabledatasource/3804470-applysnapshotusingreloaddata)** : iOS 15 +. applySnapshotUsingReloadData(:) 동일하고, 애니메이션 적용 후 completion handler 실행.
- apply(_:animatingDifferences:) : iOS 15 +. UI 를 업데이트하고, 선택적으로 변경사항에 애니메이션을 적용합니다.
- **[apply(:animatingDifferences:completion:)](https://developer.apple.com/documentation/uikit/uicollectionviewdiffabledatasource/3375795-apply)** : iOS 13 +. apply(:animatingDifferences:) 동일하고, 애니메이션 적용 후 completion handler 실행.
WWDC 2021 > Make blazing fast lists and collection views을 살펴보면 iOS 13 이상 사용 가능한 apply(:animatingDifferences:completion:) 과 iOS 15이상 사용 가능한 apply(:animatingDifferences:) 의 동작 차이에 대해서 설명해줍니다.(2분40초부터)
- iOS 15 이전에는 animatingDifferences 파라미터가 false 인 경우에는 내부적으로 reloadData() 바뀌어서 동작하였습니다.(컬렉션 뷰의 모든 셀을 재생성해야 했기 때문에 퍼포먼스가 좋지 않았다고 합니다.)
- iOS 15 부터는 animatingDifferences 파라미터가 false 인 경우에도 차이만 적용하고 다른 extra work 를 하지 않도록 동작한다고 합니다.
- 그리고 해당 completion 파라미터는 nil 기본값을 가지고 있기 때문에 apply(: animatingDifferences:) 를 사용하게되면, iOS 15 이전에는 apply(:animatingDifferences:completion:) 메소드가 호출될 것이고 iOS 15 부터는 apply(_:animatingDifferences:) 가 호출될 것입니다. 즉, 코드 변화 없이 적용될 수 있습니다.
초기 데이터 로드에서 applySnapshotUsingReloadData(:) 메소드를 사용하여 변경사항 비교 없이 스냅샷의 상태를 반영하고, UI 업데이트를 위해서 apply(:animatingDifferences:) 메소드를 호출할 수 있습니다.
하지만, 저는 초기 데이터 로드 과정없이 서버 통신 후의 데이터 로드 과정을 가져갔기 때문에 다음과 같이 apply(_:animatingDifferences:) 메소드를 사용하여 적용하였습니다.
- snapshot 을 갱신하겠습니다.
private func setCollectionView() {
var snapshot = NSDiffableDataSourceSnapshot<Section, ReceivedTag>()
snapshot.appendSections([.main])
if let receivedTags {
snapshot.appendItems(receivedTags)
}
diffableDataSource?.apply(snapshot, animatingDifferences: true)
}
// 서버 통신 후 데이터를 반영하는데 사용.
RxAlamofire.requestData(.get, url)
.map { $1 }
.bind(with: self, onNext: { owner, data in
owner.receivedTags = data
DispatchQueue.main.async {
// owner.collectionView.reloadData()
owner.setCollectionView()
}
})
.disposed(by: disposedBag)
애니메이션이 생겨 collection view 를 reloadData 하여 갑자기 변하는 것보다 자연스러워졌습니다.
👉 느낀 점
- UICollectionViewDataSource 에서 필수록 구현해야했던 두 메소드를 구현하지않아도 되었고, 애니메이션을 위해서 별도의 코드없이도 자연스럽게 적용할 수 있었습니다. 채택하기 위해 요구하는 메소드를 구현해야하는 과정에서 diffable data source 객체를 만들어서 할당하는 과정이 새로워서 직관적이게 느껴졌던 것 같습니다.
- 또한, UICollectionViewDataSource 를 사용할때와 달리 해싱으로 item 에 접근하기 때문에 셀을 재구성하거나 새롭게 교체하거나 ID 를 활용하는 성능을 향상시킬 수 있는 여지가 주어지는 점도 장점으로 느껴졌습니다.
-
반면, 복잡한 프로세스는 아니지만 identifier 를 사용하기 위해서 데이터 타입은 반드시 Hashable 프로토콜을 채택해야만 하는 점이 있었습니다.
- 아래와 같이 섹션 수가 잘못되어 앱이 종료되는 경우를 방지할 수 있었습니다. 데이터의 변경 상황을 동기화하기 위해서 reloadData() 를 사용하게 됩니다. 이때 시간이 지남에 따라 UI 와 DataSource 역할을 하는 controller 가 own version of the truth 를 가지게 됩니다. 이 centralize 된 truth 가 없기 때문에 서로의 truth 가 맞지 않아 아래의 에러가 나는 것을 방지할 수 있었습니다.
(출처 : WWDC 2019 > Advances in UI Data Source)
-
diffable data source 가 저장하는 section 과 item identifiers 는 변하지 않고 안정적인 identifiers 입니다. 이는 UICollectionViewDataSource 의 안정적이지 않은 indices 와 index path 와 대비됨을 경험하였습니다.
-
identifiers 를 가진 diffable data source 는 컬렉션 뷰 내의 위치(indices, index path)에 대한 지식 없이 section 과 item 을 참조할 수 있었습니다.