본문 바로가기
Swift

DiffableDataSource 알아보기.

by SeoB-P 2022. 7. 25.

DiffableDataSource

얘는 대체 뭘까?
The object you use to manage data and provide cells for a collection view. - 애플
TableView 혹은 CollectionView의 데이터를 관리하고 cell을 제공하기 위한 object.
근데 이미 우리는 dataSource를 사용하고 있지 않나?
이 친구는 어째서 나오게 된걸까?

 

탄생 배경
애플의 표현에 따르면 단하나의 Truth Data를 가질 수 있게 하기 위해서라고 한다.
Truth Data가 뭔데??

우리가 일반적인 DataSource를 이용해서 CollectionView를 구현할 때를 살펴보자.
DataSource에 있는 함수들을 이용해서 우리는 View를 그리게 된다.

 

이 함수들은 어떠한 Model 값에 의존해서 view를 그리게 된다.
여기서 중요한 점은 UI가 가지고 있는 값과 Model이 가지고 있는 값이 차이가 날수 있다는 것이다. 이때 우리가 가끔 봤던 에러가 발생하게 된다.

 

 

Centralize된 truth (model의 데이터)가 없다.

그래서 보통 이런 에러는 `reloadData`라는 메서드를 이용해서 Controller가 가지고 있는 값 (보통 DataSoure를 Controller에서 구현하므로)과 UI가 가지고 있는 값을 맞춰준다. 
이 방법이 나쁜 것은 아니라고 애플도 인정했지만, 이보다 더 나은 방법을 제시하기 위해 나온 것이 DiffableDataSource이다.

사용 방법(기본)

가장 기본적인 DiffableDataSoure를 만들어보자.

 

1. SectionIdentifierType과 itemIdentifierType 정의
먼저, DiffableDataSource는 `제네릭 타입을 두개 정의` 해야한다.
여기서 정의되는 제네릭 타입은 `Section과 Item이다.`
Section은 말그대로 CollectionView의 Section이고, Item은 이 DataSource를 채울 (우리가 Cell에 채울) Model이다.
이때 Section에 넣는 타입과 Item은 모두 `Hashable해야한다.`
DiffableDataSource의 원리는 현재 상태를 SnapShot을 찍은다음 비교해서 적용하는 원리인데, 값이 고유하지않으면(hashable)하지 않으면 `현재의 값과 바꿀 값을 비교할 수 없기 때문이다.`
(마치 Equatable Protocol을 채택하지 않으면 값을 비교할 수 없는것처럼.)
보통 Enum 값을 이용해서 SectionType을 정의하는게 보편적이다.

enum SectionType {
    case main
}

struct Person: Hashable {
    let name: String
    let age: Int
}

var diffableDataSource: UICollectionViewDiffableDataSource<SectionType, Person>!

 

2. Cell provider 정의
이제 Cellprovider라는 클로저를 정의 해주어야 한다.
얘는 우리가 알던 CollectionView의 cellForItemAt을 정의해준다고 생각하면 편하다.
따라서, 코드도 매우 흡사하다.
여기서 중요한 점은 우리가 미리 정의한 `ItemIdentifier로 넣은 값이 클로저의 인자로 들어온다는 것`이다.
만약에, ItemIdentifierType으로 String값을 넣었다면 Person이 아닌 String타입이 매개변수로 들어오게 된다.

 

self.diffableDataSource = UICollectionViewDiffableDataSource<SectionType, Person>(collectionView: collectionView) {
    collectionView, indexPath, person in // Item으로 PersonType을 넣었기 때문에 person이 인자로 들어옴.
    guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "PersonCell", for: indexPath)
            as? PersonCell else { return UICollectionViewCell()}
    cell.config(name: person.name, age: person.age)
    return cell
}

 

 

3. SnapShot 정의
위에서 DiffableDataSource의 원리가 SnapShot으로 비교하는 것이라고 했다.
snapShot을 만들고 item과 Section을 추가해보자.

  func snapShot(people: [Person]) {
      var snapShot = NSDiffableDataSourceSnapshot<SectionType, Person>()              // 빈 SnapShot 생성
      snapShot.appendSections([.main])    // Section 추가(미리 지정한 SectionType만 가능)
      snapShot.appendItems(people)        // Item 추가.(미리 지정한 ItemType)
      self.diffableDataSource.apply(snapShot, animatingDifferences: true)        // 적용
  }

 

이제 이 함수에 매개변수를 넣어주면 될 성공이다.
기존에  Controller Extension에 있던 DataSource로직은 과감히 지워도 된다!

 

사용 방법(심화)

하지만, 위방법은 뭔가 아쉽다. CollectionView를 사용하는 이유는 뭔가... 생각해보면 Section별로 다른 Item과 flow를 주고싶을 때이지 않은가? 실제 Data와 흡사하게? DTO를 설정해보고 함께 Section별로 다른 데이터를 넣어보자.

banner, grid, scroll section이 각각 있고, 
ImageData안에는 해당 section에 들어가야할 imageURL데이터가 배열로 들어가 있다고 가정.

enum SectionType: Int {
    case banner
    case grid
    case scroll

struct ImageData: Decodable, Hashable {
    let BannerImages: [BannerImage]
    let gridImages: [GridImage]
    let scrollImages: [ScrollImage]
}

struct BannerImage: Decodable, Hashable {
    var id: String = UUID().uuidString   // 만약 Item이 같으면, Error가 나기 때문에 id를 만들어줍니다.
    let imageURL: String
}

struct GridImage: Decodable, Hashable {
    var id: String = UUID().uuidString
    let imageURL: String
}

struct ScrollImage: Decodable, Hashable {
    var id: String = UUID().uuidString
    let imageURL: String
}

 

그리고, 각 Section에 맞는 cell을 regist해야겠지만, CollectionView에 있는 CellRegistation을 사용해보자.
이 방법을 사용하면, Cell을 Regist하는 것 뿐만 아니라 Configuration도 할 수 있다.

 

정의를 보면 꽤나 까다로워 보이지만 `어떤 Cell`에 `어떤 Data`를 넣을 꺼니?로 생각하면 쉽다.
GenericType을 정의하는 곳에 넣어주면된다.

이쯤되면 Controller가 매우 비대해지니 새로운 객체들을 만들면서 진행해본다.

struct CollectionViewRegistrator {
    
    func makeBannerCellRegister() -> UICollectionView.CellRegistration<BannerCell, BannerImage> {
        UICollectionView.CellRegistration.init { cell, indexPath, banner in
            cell.config(banner.imageURL)
        }
    }
    
    func makeGridCellRegister() -> UICollectionView.CellRegistration<GridCell, GridImage> {
        UICollectionView.CellRegistration.init { cell, indexPath, banner in
            cell.config(banner.imageURL)
        }
    }
    
    func makeScrollCellRegister() -> UICollectionView.CellRegistration<ScrollCell, ScrollImage> {
        UICollectionView.CellRegistration.init { cell, indexPath, banner in
            cell.config(banner.imageURL)
        }
    }
}

 

이제 이 함수들을 실행시켜주면, 기존에 있던 regist구문을 사용하지 않아도 된다! 심지어 CellIdentifier마저도 사용이 필요없는 모습.

 

lazy var collectionView: UICollectionView = {
    guard let layout = self.createLayout() else { return UICollectionView() }
    let collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout)
    // 요밑 부분 제거 가능!
    collectionView.register(BannerCell.self, forCellWithReuseIdentifier: "BannerCell")
    collectionView.register(GridCell.self, forCellWithReuseIdentifier: "GridCell")
    collectionView.register(ScrollCell.self, forCellWithReuseIdentifier: "ScrollCell")
    return collectionView
}()

어떤 Cell이 어떤 Data를 받아 config할지를 정의해주었으니 이제 진짜 DataSource를 만들러 가보자.

마찬가지로 DataSourceManager를 하나더 만들고 진행해본다.


1. typealias 
UICollectionViewDiffableDataSource<SecionType, AnyHashable> 너무 길다.
typealias 기능을 이용해서 타입명을 바꿔준다.
여기서, 왜 ImageData Type이 아니라 AnyHashable Type을 넣었는지는 SnapShot 부분에서 다시 확인해본다.

 

typealias DataSource = UICollectionViewDiffableDataSource<SectionType, AnyHashable>

 

2. 단 하나의truth Data인 DataSource를 만들고 DataSource를 정의하는 함수를 만들자.
이번에 중요한 것은 Cell을 dequeue하는 방식이다.

 

기존방식

func dequeueReusableCell(withReuseIdentifier identifier: String, 
                     for indexPath: IndexPath) -> UICollectionViewCell

 

새로운 방식

여기서 Using부분이 보이는가?
이부분에 우리가 정의한 CellRegistration을 넣어주면된다.

@MainActor func dequeueConfiguredReusableCell<Cell, Item>(using registration: UICollectionView.CellRegistration<Cell, Item>, 
                                                      for indexPath: IndexPath, 
                                                     item: Item?) -> Cell where Cell : UICollectionViewCell

 

var dataSource: DataSource?

mutating func setDataSource(in collectionView: UICollectionView) {
// 방금 정의했던 Registarator를 이용해 registration을 만든다.
    let registrator = CollectionViewRegistrator()

    let bannerCellRegistration = registrator.makeBannerCellRegister()
    let gridCellRegistration = registrator.makeGridCellRegister()
    let scrollCellRegistration = registrator.makeScrollCellRegister()

// DataSource를 만든다.
    let dataSource: DataSource? =
        .init(collectionView: collectionView) { collectionView, indexPath, imageData in
        // indexPath의 Section을 이용해서 우리가 정의한 SectionType(Enum)을 만든다.
            guard let section = SectionType(rawValue: indexPath.section) else { return nil } // make SectionType
            switch section {
            case .banner:
                guard let imageData = imageData as? BannerImage else { return nil }
                return collectionView.dequeueConfiguredReusableCell(
                    using: bannerCellRegistration,
                    for: indexPath,
                    item: imageData)
            case .grid:
                guard let imageData = imageData as? GridImage else { return nil }
                return collectionView.dequeueConfiguredReusableCell(
                    using: gridCellRegistration,
                    for: indexPath,
                    item: imageData)
            case .scroll:
                guard let imageData = imageData as? ScrollImage else { return nil }
                return collectionView.dequeueConfiguredReusableCell(
                    using: scrollCellRegistration,
                    for: indexPath,
                    item: imageData)
        }
    }
    self.dataSource = dataSource
}

 

3. Snapshot을 정의한다.
SnapShot의 원리는 원래 있던 값들을 캡처하듯이 찍어둔 다음에 변경 사항이 있을때 바꿔주는 거라고 했다.
근데, 3개의 Section중에 하나의 Section만 바뀌었는데 모든 Section을 바꿔버리면 낭비이다.
(마치, reloadData을 해서 모든 Data를 reload해버리면 낭비인 것 처럼.)
따라서, Section별로 SnapShot을 찍어서 바꾸어주는게 좋은데 여기서 문제가 생긴다.

 

var bannerSnapShot = NSDiffableDataSourceSectionSnapshot<[BannerImage]>()
bannerSnapShot.append(bannerImages)
self.dataSource?.apply(bannerSnapShot, to: .banner) // Error!

BannerSection에는 BannerImage의 Data를 넣어야하는데 위의 작업을 하는 순간 Error가 날것이다.

 

Why?

DiffableDataSource의 GenericType은 Section과 Item으로 이루어 져있는데.
Item에 들어가는 타입은 하나이기 때문이다.
ImageData Type으로 dataSource를 만들면 아무리 ImageData안에 있는 값이더라도. 

다른 타입이기 때문에 dataSource에 apply가 되지 않았던 것이다.

따라서, AnyHashable Type으로 Item을 선언해준 것이다.
(Protocol같은 것을 사용하면 해결할 수 있을것 같으나.. 어짜피 모든 Item들은 Hashable을 채택해야하기 때문에 이방법을 택했다)

 

UICollectionViewDiffableDataSource<SectionType, ImageData> // can apply only ImageDataType

UICollectionViewDiffableDataSource<SectionType, AnyHashable> // can apply only AnyHashable Type

 

SnapShot전체 코드

mutating func snapShot(imageData: ImageData) {
    let bannerImages: [BannerImage] = imageData.BannerImages
    let gridImages: [GridImage] = imageData.gridImages
    let scrollImages: [ScrollImage] = imageData.scrollImages

    var bannerSnapShot = NSDiffableDataSourceSectionSnapshot<AnyHashable>()
    bannerSnapShot.append(bannerImages)

    var gridSnapShot = NSDiffableDataSourceSectionSnapshot<AnyHashable>()
    gridSnapShot.append(gridImages)

    var scrollSnapShot = NSDiffableDataSourceSectionSnapshot<AnyHashable>()
    scrollSnapShot.append(scrollImages)

    self.dataSource?.apply(bannerSnapShot, to: .banner)
    self.dataSource?.apply(gridSnapShot, to: .grid)
    self.dataSource?.apply(scrollSnapShot, to: .scroll)
}

 

DataSoure 전체 코드.

typealias DataSource = UICollectionViewDiffableDataSource<SectionType, AnyHashable>

struct CollectionViewDataSourceManager {
    
    var dataSource: DataSource?
    
    // regist Cell, configure Cell
    mutating func setDataSource(in collectionView: UICollectionView) {
        let registrator = CollectionViewRegistrator()
        
        let bannerCellRegistration = registrator.makeBannerCellRegister()
        let gridCellRegistration = registrator.makeGridCellRegister()
        let scrollCellRegistration = registrator.makeScrollCellRegister()

        let dataSource: DataSource? =
            .init(collectionView: collectionView) { collectionView, indexPath, imageData in
                guard let section = SectionType(rawValue: indexPath.section) else { return nil } // make SectionType
                switch section {
                case .banner:
                    guard let imageData = imageData as? BannerImage else { return nil }
                    return collectionView.dequeueConfiguredReusableCell(
                        using: bannerCellRegistration,
                        for: indexPath,
                        item: imageData)
                case .grid:
                    guard let imageData = imageData as? GridImage else { return nil }
                    return collectionView.dequeueConfiguredReusableCell(
                        using: gridCellRegistration,
                        for: indexPath,
                        item: imageData)
                case .scroll:
                    guard let imageData = imageData as? ScrollImage else { return nil }
                    return collectionView.dequeueConfiguredReusableCell(
                        using: scrollCellRegistration,
                        for: indexPath,
                        item: imageData)
            }
        }
        self.dataSource = dataSource
    }
    
    mutating func snapShot(imageData: ImageData) {
        let bannerImages: [BannerImage] = imageData.BannerImages
        let gridImages: [GridImage] = imageData.gridImages
        let scrollImages: [ScrollImage] = imageData.scrollImages
        
        var bannerSnapShot = NSDiffableDataSourceSectionSnapshot<AnyHashable>()
        bannerSnapShot.append(bannerImages)
        
        var gridSnapShot = NSDiffableDataSourceSectionSnapshot<AnyHashable>()
        gridSnapShot.append(gridImages)
        
        var scrollSnapShot = NSDiffableDataSourceSectionSnapshot<AnyHashable>()
        scrollSnapShot.append(scrollImages)
        
        
        self.dataSource?.apply(bannerSnapShot, to: .banner)
        self.dataSource?.apply(gridSnapShot, to: .grid)
        self.dataSource?.apply(scrollSnapShot, to: .scroll)
    }
}

 

이제 ViewController에서 적용시켜주면 성공!

 

func setDataSource() {
    dataSourceManager.setDataSource(in: collectionView)
    dataSourceManager.snapShot(imageData: mockImageData)
}

장점 및 단점

느낀 장점 및 단점을 정리해 봤다.


장점

1. 통일되지 않은 Model Data로 인한 오류를 줄일 수 있다.

2. 추가된 Data에 대한 Animation을 쉽게 줄 수 있다.

이번 예시에서는 Data의 변동이 없었지만, CollectionView의 Data가 변할 때, SnapShot에 
있는 animate기능을 이용하면 CollectionView에 Animation을 보다 쉽게 줄수 있다.
기존의 dataSource는 뚝뚝? 끊기는 느낌이 난다.

 

3. 구현방식이 좀 더 다양해 졌다.
기존의 DataSource방식은 ViewController에서 extension을 하던지, DataSource를 상속받는 Class를 만들어서 정해진 함수를 정의해야 했다면, 이제는 그럴 필요가 없다.
기본적인 dequeue함수만 구현한다면, Struct를 만들 든, class를 만들 든 자신이 원하는대로 만들면 된다.
(인터넷을 뒤져보니 Class를 이용해서 만든 예제도 많았다.)

 

단점

1. 배우기 어렵다.
솔직히 기존의 DataSource로도 할수 있는 부분들이 많은데, 굳이?? 라는 생각이 들기는 했다.
하지만, 익숙해진 다음에는 기존의 DataSource보다 안전하고, animation도 쉽게 넣을 수 있다. 

2. SupplymentaryView를 설정하기 어려웠다
내가 못찾는건지 왜 안만든지는 모르겠지만, SupplymetaryView의 registration은 제네릭으로 Model을 받지 않는다. 
따라서, Header나 Footer를 어떠한 Model값으로 config할때는 조금 복잡해진다

 

3. 데이터가 고유해야하기 때문에 ID를 만들어 줘야한다.

 

주의. 개린이라 틀린 부분이 있을 수 있습니다!

총 코드: https://github.com/Piggy-Seob/DiffableDataSourcePractice

'Swift' 카테고리의 다른 글

Swift Concurrency 알아보기  (0) 2023.01.08
클래스의 초기화  (0) 2022.08.07
정규 표현식 in Swift  (2) 2022.06.14
Type Erasure in Swift  (0) 2022.05.23
Data Testing in Swift  (0) 2022.05.12

댓글