우리는 보통 UICollectionView
(or UITableView
)를 작성할 때, DataSource
를 구현하기 위해서 UICollectionViewDataSource
를 채택하였고 직접 아이템이나 섹션의 개수를 정해주는 방식을 사용한다.
이 경우에는 해당 프로토콜을 채택하고 있는 VC가 아이템이나 섹션의 변경을 담당해주게 되는데, VC가 시간이 흐름에 따라 자신만의 버전인 truth를 가지게 되고 만약 UI의 truth와 서로 맞지 않으면 위와 같은 오류가 발생하게 된다. centraize된 truth가 없어서 발생하는 오류로서 보통은 reloadData
를 통해 해결 가능하다.
다만 reloadData
를 사용하면 애니메이션 없이 UI가 업데이트되다보니 사용자의 경험(UX)을 크게 저하시킬 수 있다. 뿐만 아니라 아이템이나 섹션의 개수 등도 일일히 지정해줘야 하는 불편함도 있어 최근에는 RxDataSource
를 활용하기도 하였다.
Why Diffable DataSource?
reloadData
를 사용한 동기화도 UX 저하 외에는 별다른 문제가 없고 (무려 WWDC에서 안정성 확인!), RxDataSource
를 사용하면 위와 같은 UX 저하도 해결되고 Rx
와도 혼용이 용이한데 굳이 DiffableDataSource
를 사용하려고 했을까.
서드파티 라이브러리는 매우 편리함을 제공해주지만 로직이 감춰져있어 자세한 동작방식을 이해하기 쉽지 않고, 이러한 어려움은 특히 디버깅 때 도드라진다. RxDataSource
또한 사용하면서 이런 아쉬움을 많이 느꼈고 동작 방식이나 사용 방법 등이 유사하다면 애플에서 제공하는 라이브러리의 것들을 사용하는 편이 나을 듯 싶어 학습해보고자 하였다.
DiffableDataSource
에서는 현재 UI의 truth를 의미하는 Snapshot
이라는 개념이 도입되었다. 이전에는 섹션과 아이템에 대하여 업데이트를 하기위해 IndexPath
가 필요했으나, 이제는 각각의 섹션과 아이템이 자신만의 유니크한 identifiers를 갖게 되고 이를 통해 업데이트를 진행하게 된다.
이처럼 Snapshot
을 활용하여 현재 UI의 truth를 업데이트할 수 있는데 이때 사용되는 메서드가 apply(_:animatingDifferences:completion:)
다.
해당 메서드를 통해 Snapshot
의 데이터 상태에 맞도록 UI를 업데이트하고 이러한 변경사항에 애니메이션을 선택적으로 적용하거나 completion handler를 실행할 수 있다. apply
를 하면 새로운 Snapshot
으로 적용된다.
먼저 UICollectionView(or UITableView)
와 연결할 수 있는 DiffableDataSource
인스턴스를 만들어주어야 한다.
class UICollectionViewDiffableDataSource<SectionIdentifierType, ItemIdentifierType> : [NSObject]
where SectionIdentifierType : [Hashable], ItemIdentifierType : [Hashable]
기존의 DataSource와 달리 DiffableDataSource
는 제네릭 객체로서 해당 제네릭 타입에 본인이 원하는 적절한 타입을 넣어서 생성해주면 된다. 다만 두 타입 모두 Hashable을 준수해주어야 하니, 이 점을 명심해야 한다. (커스텀 타입이 아닌 경우, Swift의 대부분 타입들은 적용 가능하다.)
마침 소켓 연결을 연습하면서 채팅 UI를 만들던 중이라 각각의 타입들은 채팅 형태를 염두해둔 커스텀 타입들을 활용했다.
[SectionIdentifierType]
enum DiffableSection: CaseIterable {
case main
}
참고로 enum 타입은 모든 케이스나 associatedValue
가 Hashable하면 본인도 Hashable해진다.
[ItemIdentifierType]
struct ChatDTO: Hashable {
let chatType: ChatType
let message: String
let date: Date
}
본인의 경우에는 커스텀하게 만든 타입이라 상관없지만, 만약 String이나 Int 같은 타입을 사용할 경우에 값이 동일하면 identifiers가 유일하지 못 하게 되어 크래시가 발생하므로 주의해야 한다.
이제 상기의 타입들을 활용하여 DiffableDataSource
를 생성해주면 된다. 다만 객체 생성 시점에서 해당 UICollectionView(or UITableView)
의 인스턴스를 인자로 넘겨주고, 클로저 형태로 셀 생성 구문을 작성해주어야 한다.
var dataSource: UICollectionViewDiffableDataSource<DiffableSection, ChatDTO>?
self.dataSource = .init(collectionView: collectionView) { (collectionView, indexPath, item) -> UICollectionViewCell? in
}
...중략...
self.collectionView.dataSource = dataSource
이렇게 생성한 DataSource는 UICollectionView(or UITableView)
의 dataSource
에 할당해주면 된다. 추가적으로 DataSource는 앞으로 다른 곳에서도 호출이 될 예정이므로 간편하게 전역 변수로 다뤄주거나 따로 관리 객체를 두면 좋다.
해당 작업은 우리가 UICollectionView(or UITableView)
를 다루며 수도 없이 한 셀 생성 및 데이터 주입 구현이다. 여기는 평소에 하던 대로 dequeueReusableCell
을 호출해주면 된다.
self.dataSource = .init(collectionView: collectionView) { (collectionView, indexPath, item) -> UICollectionViewCell? in
guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: identifier, for: indexPath) as? MessageCell else { return .init() }
let formatter: MessageDateFormatter = .init()
let dateString: String = formatter.convertToString(from: item.date)
cell.setCell(with: item.message, dateString: dateString)
return cell
}
이제 Snapshot
에 대한 작업을 진행하면 된다. 채팅 앱을 기준으로 보면 처음 실행 시에 기존 대화를 불러오고, 상대나 본인이 작성한 내용들이 지속적으로 추가가 되어야 한다. 최초의 Snapshot
을 생성해주고 대화가 진행됨에 따라 해당 Snapshot
이 계속 업데이트 되는 구조인 것이다.
func fetchBaseDatas(with sections: [DiffableSection: [ChatDTO]]) {
var snapshot: NSDiffableDataSourceSnapshot<DiffableSection, ChatDTO> = .init()
let allKeys = sections.map { $0.key }
snapshot.appendSections(allKeys)
allKeys.forEach {
guard let items = sections[$0] else { return }
snapshot.appendItems(items, toSection: $0)
}
DispatchQueue.global(qos: .background).async {
self.dataSource?.apply(snapshot, animatingDifferences: false)
}
}
func addItems(with sections: [DiffableSection: [ChatDTO]]) {
guard let dataSource else { return }
let allKeys = sections.map { $0.key }
var snapshot = dataSource.snapshot()
allKeys.forEach {
guard let items = sections[$0] else { return }
snapshot.appendItems(items, toSection: $0)
}
DispatchQueue.global(qos: .background).async {
self.dataSource?.apply(snapshot, animatingDifferences: false)
}
}
fetchBaseDatas
에서는 최초로 Snapshot
을 생성했고 전달받은 데이터에 맞추서 Snapshot
도 섹션과 섹션별 아이템을 초기화시켜준다. 이후 addItems
를 통해서 기존의 Snapshot
을 DataSource에서 받아오고 여기에 이어진 대화 데이터들을 추가해주어 최신화를 유지해준다.
이제 마지막으로 최신화된 Snapshot
을 기준으로 UI도 업데이트하면 된다. DataSource에 apply
를 호출하고 최신 Snapshot
을 인자값으로 넣어주면 아주 쉽고 깔끔하게 UI도 최신화된다.
self.dataSource?.apply(snapshot, animatingDifferences: false)
위의 모든 작업이 적용된 예시 프로젝트이다. animatingDifferences
가 false로 주어져서 그렇지 true로 주어지면 애니메이션도 잘 적용된다.
처음에는 DiffableDataSource
의 개념이 어렵게 느껴졌었지만, RxDataSource
를 학습하고나니 개념이 비슷해서 그런지 훨씬 쉽게 습득할 수 있었다. 앞으로는 DiffableDataSource
를 따로 관리해줄 수 있는 객체로 분리할 수 있는 방법을 찾아보도록 할 예정이다.
추가적으로 해당 프로젝트는 본인 깃허브에 업로드하였으며, DiffableDataSource
는 해당 이름을 가진 브랜치에 업로드되어 있는 상태이다.