[iOS] Diffable DataSource
Diffable DataSource 관련 내용을 기록합니다.
기존 DataSource 이슈
*** Terminating app due to uncaught exception
‘NSInternalInconsistencyException’, reason: ‘Invalid update: invalid number of sections.
The number of sections contained in the collection view after the update (10)
must be equal to the number of sections contained in the collection view before the update (10),
plus or minus the number of sections inserted or deleted (0 inserted, 1 deleted).’
***
기존 DataSource 방식에서는 Truth, 즉 시간이 지남에 따라 변화하는 버전이 맞지 않는 이슈가 있어 reloadData() 호출을 통해 해결함. 하지만 애니메이션이 없어 사용자 경험이 저하됨.
이 이슈를 해결하기 위해 UI와 DataSource 를 중앙화된 Truth로 관리하는 Diffable DataSource가 등장함.
Diffiable DataSource
iOS 13+
Cell 을 구성하고 Snapshot(섹션과 아이템) 을 생성하여 이를 DataSource 에 적용함.
Snapshot 은 현재 UI 상태의 truth 를 뜻하며 Generic Struct 타입
데이터가 변경될 때마다 새로운 Snapshot을 생성하고 이를 다시 DataSource 에 적용함, 이 과정에서 뷰가 애니메이션을 통해 자연스럽게 업데이트됨.
performBatchUpdates 메서드 대신 apply 메서드 사용
IndexPath 가 아닌 Snapshot 을 사용하며 Snapshot 의 Section 및 Item identifier(Unique identifier, Hashable 준수) 를 이용하여 UI 업데이트함.
DataSource 는 Protocol, Diffiable DataSource는 Generic Class
DataSource 는 UI 사이의 불일치로 인해 런타임 에러가 발생할 가능성이 있고 직접 관리해야 함.
Diffiable DataSource 는 데이터와 UI 사이의 동기화를 자동화하며, 뷰를 보다 효율적으로 업데이트할 수 있음.
NSDiffableDataSourceSnapshot
UI State의 Truth이며 IndexPath 대신 Section 과 Item의 Unique identifier를 사용함.
Section과 Item 모두 Hashable 을 준수해야함.
apply()
snapshot 에 데이터의 상태를 반영하여 UI를 업데이트함. UI변경 시 애니메이션 동작 여부를 설정할 수 있음.
장점
- 변경사항이 있을 때 애니메이션이 적용되어 UI가 자연스럽게 업데이트되므로 사용자 경험을 저하시키지 않음.
- Centralized Truth를 사용하기 때문에 UI와 DataSource 간에 Truth가 맞지 않아 크래시나 에러가 발생하는 일이 없음.
- Hashable 기반으로 O(n)의 빠른 성능을 가지고 있음
해시 충돌
같은 값은 해시함수에 들어가면 무조건 같은 해시값으로 계산되지만,
두 값이 같은 해시값을 가졌다고 해서 무조건 두 값이 같은 것은 아니다.
그러니깐 서로 완전 다른 두 값이 우연히 같은 해시값으로 변환될 수 있다는 것이다.
이것을 해시 충돌이라고 하는데, Hash함수는 단순히 어떤 객체를 정수의 키값으로 변환하는 역할만 하기때문에 두 객체가 서로 같은지는 Equatable을 이용하여 비교해야하는 것이다.
해시 충돌을 방지하기 위해 UUID를 이용하여 고유한 id 값을 만들어 hash 키 값으로 사용할 수 있음.
struct Video: Hashable {
let title: String
let category: String
let identifier = UUID()
func hash(into hasher: inout Hasher) {
hasher.combine(identifier)
}
}
iOS 15+
Structuring data
struct DestinationPost: Identifiable {
// 각 포스트는 고유한 id를 갖고 있음.
var id: String
var title: String
var numberOfLikes: Int
var assetID: Asset.ID
데이터소스를 채우기 위해 빈 스냅샷을 만들고 기본 섹션을 추가함. 모든 포스트를 가져와 해당 식별자를 추가함.
이렇게 하면 DestinationPost의 다른 프로퍼티 중 하나가 변경되면 id 가 변경되지 않으므로 안정적으로 DiffableDatasource를 데이터로 채울 수 있음.
// Setting up diffable data source
class DestinationGridViewController: UIViewController {
// Use DestinationPost.ID as the item identifier
var dataSource: UICollectionViewDiffableDataSource<Section, DestinationPost.ID>
private func setInitialData() {
var snapshot = NSDiffableDataSourceSnapshot<Section, DestinationPost.ID>()
// Only one section in this collection view, identified by Section.main
snapshot.appendSections([.main])
// Get identifiers of all destination posts in our model and add to initial snapshot
let itemIdentifiers = postStore.allPosts.map { $0.id }
snapshot.appendItems(itemIdentifiers)
dataSource.apply(snapshot, animatingDifferences: false)
}
}
iOS 15 이전에는 애니메이션 없이 스냅샷을 적용하면 내부적으로 reloadData가 호출되었음. 컬렉션 뷰가 화면의 모든 셀을 버리고 다시 만들어야 하므로 성능이 좋지 않았음.
iOS 15 부터 애니메이션 없이 스냅샷을 적용하면 차이점만 적용되고 추가 작업은 수행되지 않음.
iOS 15에서는 DiffableDatasource 에 표시되는 셀의 내용을 쉽게 업데이트할 수 있는 reconfigureItems 메서드가 제공됨. 메서드에 관한 자세한 내용은 아래에 이어짐.
Cell Registrations
// Cell registrations
let cellRegistration = UICollectionView.CellRegistration<DestinationPostCell,
DestinationPost.ID> {
(cell, indexPath, postID) in
let post = self.postsStore.fetchByID(postID)
let asset = self.assetsStore.fetchByID(post.assetID)
cell.titleView.text = post.region
cell.imageView.image = asset.image
}
UICollectionView는 각 Cell 등록에 대해 재사용 대기열을 유지하므로 한 번만 등록을 생성해야 함.
아래 처럼 DataSource에 선언한 cellRegistration을 주입할 수 있음.
let dataSource = UICollectionViewDiffableDataSource<Section.ID,
DestinationPost.ID>(collectionView: cv){
(collectionView, indexPath, postID) in
return collectionView.dequeueConfiguredReusableCell(using: cellRegistration,
for: indexPath,
item: postID)
}
추가적으로 다양한 Cell 을 등록하고 싶다면, Enum 을 이용해서 분기하여 dequeueConfiguredReusableCell 메서드를 리턴하여 처리할 수 있음.
Cell LifeCycle
셀의 라이프 사이클은 아래 이미지와 같이 dequeue 한 후 prepareForReuse 에서 초기화 된 후 다시 register와 configure 한 후에 셀 크기에 맞게 화면에 표시됨.
willDisplayCell 호출 후 화면에 Cell이 보여지고 화면 밖으로 스크롤되면 didEndDisplaying 이 호출되어 재사용 풀로 돌아감. 재사용 풀에서 이 프로세스를 반복하여 셀을 다시 대기열에서 제거할 수 있음.
Cell Prepatching
콜렉션뷰를 스크롤했을 때 끊기는 느낌이 드는 현상을 ‘hitch’ 라고 함. 이 현상이 발생하는 원인은 셀이 화면에 보여질 때 커밋 기한을 놓치게 되어 발생하게 됨.
커밋이란 스크롤 뷰의 contentOffset 은 팬(Pan) 동작 중에 변경되어 포함된 모든 뷰의 화면 위치가 변경되고 변경의 결과로 앱의 뷰와 레이어가 레이아웃을 설정하는 프로세스를 말함.
디바이스에 따라 이 커밋 기한이 다르고 display의 refresh rate 가 높을수록 시간이 짧아짐. 예를 들어, 120Hz의 iPad Pro는 60Hz 아이폰보다 커밋 기한이 짧음.
아래 이미지에서 prefetching 이 없을 때 commit을 나타내는 초록색 칸이 커밋 기한을 넘어 hitch 현상이 발생함. iOS 15에서는 이러한 끊기는 현상을 해결하기 위해 새로운 Cell Prepatch 매커니즘이 생겼음.
Cell lifecycle with prefetching
셀이 prefetching 된 후에는 셀이 표시되기 전 prepared cells 상태가 있음.
이 prepared cells 는 사용자가 스크롤 방향을 변경했을 때, 셀이 표시되지 않을 수 있으며 셀이 표시되면 화면 밖으로 나가고 나서 다시 대기 상태로 돌아갈 수 있음.
Updating cell content
Image loading hitches
스크롤 할 때 새 이미지가 표시되면서 발생하는 지연 현상이 있음. (이미지가 고해상도일수록 오래 걸림)
셀의 이미지를 비동기적으로 불러올 때 캐싱된 이미지가 없다면 placeholder 이미지로 설정함.
이미지 다운로드 후에 reconfigureItems 메서드를 호출하여 준비된 셀을 로딩함.
- reconfigureItems 메서드를 사용하여 이미 화면에 있는 셀의 내용을 비동기적으로 업데이트할 수 있음.
- Prepared cells에 reconfigureItems 메서드를 호출하면 해당 cell registration config handler를 다시 실행하게 됨.
- → 새로운 Cell 을 구성(reloadItems)하지 않고 기존 Cell 을 재사용하여 성능을 향상시킴.
fetchByID 메서드를 통해 내부적으로 id를 이용해 캐싱된 이미지를 먼저 찾고 없다면, placeholder을 리턴함.
isPlaceholder가 true 라면, 이미지를 바로 업데이트하는 것이 아님(비동기).
reconfigureItems 메서드를 호출하여 cell registration config handler 을 재실행해서 셀을 재사용함.
// Updating cells asynchronously
let cellRegistration = UICollectionView.CellRegistration<DestinationPostCell,
DestinationPost.ID> {
(cell, indexPath, postID) in
let post = self.postsStore.fetchByID(postID)
let asset = self.assetsStore.fetchByID(post.assetID)
if asset.isPlaceholder {
self.assetsStore.downloadAsset(post.assetID) { _ in
self.setPostNeedsUpdate(id: post.id)
}
}
cell.titleView.text = post.region
cell.imageView.image = asset.image
}
준비시간을 최대화하기 위해, prefetchingDataSource 내부에서 이미지 다운로드를 할 수 있음. prefetchItemsAt 메서드 내부에서 다운로드 작업을 진행하여 Placeholder를 보는 시간을 줄일 수 있음.
// Data source prefetching
var prefetchingIndexPaths: [IndexPath: Cancellable]
func collectionView(_ collectionView: UICollectionView,
prefetchItemsAt indexPaths [IndexPath]) {
// Begin download work
for indexPath in indexPaths {
guard let post = fetchPost(at: indexPath) else { continue }
prefetchingIndexPaths[indexPath] = assetsStore.loadAssetByID(post.assetID)
}
}
func collectionView(_ collectionView: UICollectionView,
cancelPrefetchingForItemsAt indexPaths: [IndexPath]) {
// Stop fetching
for indexPath in indexPaths {
prefetchingIndexPaths[indexPath]?.cancel()
}
}
준비시간을 최대화하기 위해, prefetchingDataSource 내부에서 이미지 다운로드를 할 수 있음. prefetchItemsAt 메서드 내부에서 다운로드 작업을 진행하여 Placeholder를 보는 시간을 줄일 수 있음.
// Data source prefetching
var prefetchingIndexPaths: [IndexPath: Cancellable]
func collectionView(_ collectionView: UICollectionView,
prefetchItemsAt indexPaths [IndexPath]) {
// Begin download work
for indexPath in indexPaths {
guard let post = fetchPost(at: indexPath) else { continue }
prefetchingIndexPaths[indexPath] = assetsStore.loadAssetByID(post.assetID)
}
}
func collectionView(_ collectionView: UICollectionView,
cancelPrefetchingForItemsAt indexPaths: [IndexPath]) {
// Stop fetching
for indexPath in indexPaths {
prefetchingIndexPaths[indexPath]?.cancel()
}
}
Image preparation
이미지를 화면에 표시하기 위해서는 이미지가 비트맵 형태여야 함. 하지만 다운로드된 이미지의 형태는 JPEG, PNG, HEIC 과 같은 다른 포멧이므로 변환 작업이 필요함.
비트맵: 서로 다른 점(픽셀)들의 조합으로 그려지는 이미지 표현 방식
ImageView 가 메인스레드에서 변환 작업을 처리하는데 Image prepartions API를 통해 사전에 처리하여 hitch 현상을 없앨 수 있음.
pixel 데이터는 메모리 캐시에 원본 데이터는 디스크 캐시에 저장해야 함.
원본 이미지를 prepareForDisplay 메서드를 호출하여 바로 디스플레이될 수 있도록 준비해놓은 상태
메인 스레드를 막지 않고 백그라운드 스레드에서 작업을 하므로 hitch 현상을 방지할 수 있음.
// Using prepareForDisplay
// Initialize the full image
let fullImage = UIImage()
// Set a placeholder before preparation
imageView.image = placeholderImage
// Prepare the full image
fullImage.prepareForDisplay { preparedImage in
DispatchQueue.main.async {
self.imageView.image = preparedImage
}
}
아래는 이미지 다운로드했을 때 예시 코드이며, 캐시에 저장된 이미지를 확인한 후에 다운로드 및 prepareForDisplay 메서드를 호출하는 것을 확인할 수 있음.
// Asset downloading – with image preparation
func downloadAsset(_ id: Asset.ID,
completionHandler: @escaping (Asset) -> Void) -> Cancellable {
// Check for an already prepared image
if let preparedAsset = imageCache.fetchByID(id) {
completionHandler(preparedAsset)
return AnyCancellable {}
}
return fetchAssetFromServer(assetID: id) { asset in
asset.image.prepareForDisplay { preparedImage in
// Store the image in the cache.
self.imageCache.add(asset: asset.withImage(preparedImage!))
DispatchQueue.main.async {
completionHandler(asset)
}
}
}
}
Ref.