Diffable DataSource 구현

Zeto·2023년 1월 26일
0

Swift_UIKit

목록 보기
11/12

우리는 보통 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 또한 사용하면서 이런 아쉬움을 많이 느꼈고 동작 방식이나 사용 방법 등이 유사하다면 애플에서 제공하는 라이브러리의 것들을 사용하는 편이 나을 듯 싶어 학습해보고자 하였다.

Diffable DataSource

🗒 주요 개념 (Snapshot과 apply)

DiffableDataSource에서는 현재 UI의 truth를 의미하는 Snapshot이라는 개념이 도입되었다. 이전에는 섹션과 아이템에 대하여 업데이트를 하기위해 IndexPath가 필요했으나, 이제는 각각의 섹션과 아이템이 자신만의 유니크한 identifiers를 갖게 되고 이를 통해 업데이트를 진행하게 된다.

이처럼 Snapshot을 활용하여 현재 UI의 truth를 업데이트할 수 있는데 이때 사용되는 메서드가 apply(_:animatingDifferences:completion:)다.

해당 메서드를 통해 Snapshot의 데이터 상태에 맞도록 UI를 업데이트하고 이러한 변경사항에 애니메이션을 선택적으로 적용하거나 completion handler를 실행할 수 있다. apply를 하면 새로운 Snapshot으로 적용된다.

💻 구현해보기

1. Connect a diffable data source to your collection view

먼저 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는 앞으로 다른 곳에서도 호출이 될 예정이므로 간편하게 전역 변수로 다뤄주거나 따로 관리 객체를 두면 좋다.

2. Implement a cell provider to configure your collection view's cells

해당 작업은 우리가 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
}

3. Generate the current state of the data

이제 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에서 받아오고 여기에 이어진 대화 데이터들을 추가해주어 최신화를 유지해준다.

4. Display the data in the UI

이제 마지막으로 최신화된 Snapshot을 기준으로 UI도 업데이트하면 된다. DataSource에 apply를 호출하고 최신 Snapshot을 인자값으로 넣어주면 아주 쉽고 깔끔하게 UI도 최신화된다.

self.dataSource?.apply(snapshot, animatingDifferences: false)

위의 모든 작업이 적용된 예시 프로젝트이다. animatingDifferences가 false로 주어져서 그렇지 true로 주어지면 애니메이션도 잘 적용된다.

🗂 마무리

처음에는 DiffableDataSource의 개념이 어렵게 느껴졌었지만, RxDataSource를 학습하고나니 개념이 비슷해서 그런지 훨씬 쉽게 습득할 수 있었다. 앞으로는 DiffableDataSource를 따로 관리해줄 수 있는 객체로 분리할 수 있는 방법을 찾아보도록 할 예정이다.

추가적으로 해당 프로젝트는 본인 깃허브에 업로드하였으며, DiffableDataSource는 해당 이름을 가진 브랜치에 업로드되어 있는 상태이다.

profile
중2병도 iOS가 하고싶어

0개의 댓글