ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • UICollectionViewDiffableDataSource, UITableViewDiffableDataSource 로 깔끔한 콜렉션 뷰 데이터 관리하기
    iOS 2020. 2. 26. 01:36

    서문

    iOS 13 이상에서는 'UITableViewDiffableDataSource, UICollectionViewDiffableDataSource'이 새로 생겼습니다.

    https://developer.apple.com/documentation/uikit/uitableviewdiffabledatasource

    기존에 테이블뷰와 콜렉션뷰의 데이터는 적당한 NSObject 클래스에,

    UICollectionViewDataSource 을 설정해 주는 방식으로 구현하고 있었습니다.

    데이터가 바뀌면, 어느 indexPath 가 바뀌었는지 개발자가 판단하여 insertItems(at:) 등의 적절한 함수를 호출하곤 했습니다.

    그런데 만약, 개발자가 판단한 데이터의 변동과, 실제 변동의 값이 다르다면...

    이런 식으로, 여지없이 런타임 종료를 내버리곤 했습니다.

    diffabledatasource[42617:2905307] *** Terminating app due to uncaught exception 'NSInternalInconsistencyException', reason: 'attempt to insert item 0 into section 0, but there are only 0 items in section 0 after the update'

    UITableViewDiffableDataSource, UICollectionViewDiffableDataSource은 개발자가 데이터의 변동을 비교적 쉽게 건드릴 수 있도록 하여, 코드의 복잡함을 줄이고 런타임 에러를 없애고자 등장했습니다.

    본론

    UITableViewDiffableDataSource, UICollectionViewDiffableDataSource의 정의

    제너릭 타입으로, Hashable 을 준수하는 두 타입을 가집니다.

    SectionIdentifierType은, 각각의 섹션에 들어갈 데이터를 정의합니다.

    ItemIdentifierType은, 섹션 속 아이템들에 들어갈 데이터를 정의합니다.

    이 객체에 데이터들을 집어 넣고 적용하면, 내부적으로 데이터를 관리해주게 됩니다.

    구현하기

    Section과 Item 을 정의합니다.

    enum Section: Hashable {
      case cat
      case dog
    }
    
    struct Item: Hashable {
      let name: String
    }

    Section 열거형과 Item 구조체를 각각 SectionIdentifierType, ItemIdentifierType 로 갖는 datasource 또한 정의해줍니다.

     

    var datasource: UICollectionViewDiffableDataSource<Section, Item>!

     

    이제 datasource에 객체를 할당해주어야 하겠습니다.

    UICollectionViewDiffableDataSource의 생성자는

    datasource 와 연결될 콜렉션뷰와, cellProvider 라는 특이한 클로저를 가집니다.

     

    기존 데이터 소스에서 아래 메서드를 이용해, 셀에 유아이를 뿌려주었는데요. cellProvider는 동일한 기능을 클로저로 구현합니다.

    func collectionView(_ collectionView: UICollectionView, 
          cellForItemAt indexPath: IndexPath) -> UICollectionViewCell

     

    기존에 하던 것처럼 콜렉션뷰에 셀을 등록해 줍니다.

     

    self.collectionView.register(Cell.self, forCellWithReuseIdentifier: "Cell")

     

    그리고 클로저에 셀 뿌리는 작업을 구현합니다.

     

    self.datasource = .init(collectionView: self.collectionView, cellProvider: { collectionView, indexPath, item -> UICollectionViewCell? in
          let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "Cell", for: indexPath) as! Cell
          cell.label.text = item.name
          return cell
        })

    데이터 삽입 / 수정

    UICollectionViewDiffableDataSource 은 snapshot() 메서드로 NSDiffableDataSourceSnapshot 을 반환하여 사용합니다.

     

     

    NSDiffableDataSourceSnapshot 은 데이터를 접근할 수도 있고, 특정 인덱스의 데이터를 삽입하거나 삭제할 수도 있습니다.

    충분히 데이터를 조작하고 나면, apply 메서드를 통해 변경사항을 적용해줄 수 있습니다.

     

     

    func insertDogAndCats() {
        var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
        snapshot.appendSections([.cat, .dog])
        snapshot.appendItems([.init(name: "yangdal")], toSection: .cat)
        snapshot.appendItems([.init(name: "uyou")], toSection: .dog)
        self.datasource.apply(snapshot)
    }

    세부 특이사항

    1. 동일한 아이템이 두 개 이상 들어간다면 (1)

    func insertDogAndCats() {
        var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
        snapshot.appendSections([.cat, .dog])
        snapshot.appendItems([.init(name: "yangdal")], toSection: .cat)
        snapshot.appendItems([.init(name: "uyou")], toSection: .dog)
        snapshot.appendItems([.init(name: "uyou")], toSection: .cat)
        snapshot.appendItems([.init(name: "catcat")], toSection: .cat)
        self.datasource.apply(snapshot)
     }

     

    이런 코드를 적용하면, 어떻게 될까요? dog 섹션과 cat 섹션에 모두 uyou 아이템이 생성될까요?

     

    확인 결과, dog 섹션에는 uyou가 없고, 마지막 반영값인 cat 에만 우유가 등장했습니다.

    완전히 같은 데이터값을 두 개 이상 넣은 결과, 마지막 값만 반영이 된 것인데요.

     

    2020-02-26 01:01:40.143056+0900 diffabledatasource[52736:3032892] [UIDiffableDataSource] Warning: 2 inserted identifier(s) already present; existing items will be moved into place for this current insertion. Please note this will impact performance if items are not unique when inserted.

     

    이런 경고가 출력되었습니다. 동일한 id 데이터가 들어올 경우, 존재하던 게 새 값으로 변경된다고 하네요.

    2. 동일한 아이템이 두 개 이상 들어간다면 (2)

    아래 코드는 어떨까요?

     

    func insertDogAndCats() {
        var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
        snapshot.appendSections([.cat, .dog])
        snapshot.appendItems([.init(name: "uyou"), .init(name: "uyou")], toSection: .dog)
        self.datasource.apply(snapshot, animatingDifferences: true)
      }

    이것도 dog 섹션에 uyou 가 하나만 들어갈까요?

     

    이 경우, 이렇게 런타임 에러가 출력됩니다.

     

    snapShot에 시간차를 두어서 동일 아이템을 삽입하면 문제가 없지만... 한번에 집어넣으면 터지는 모양이네요.

    3. 동일한 섹션을 두 번 이상 들어간다면

    아이템을 했으니, 이번엔 섹션이 궁금합니다. 이 코드는 어떻게 동작할까요?

     

    func insertDogAndCats() {
        var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
        snapshot.appendSections([.cat])
        snapshot.appendSections([.cat])
        snapshot.appendItems([.init(name: "uyou")], toSection: .cat)
        self.datasource.apply(snapshot, animatingDifferences: true)
    }

     

    이런 식으로, 런타임 오류가 뜹니다.

     

    동일한 아이템이 두 개 들어가는건 허용하지만, 동일한 섹션이 두 번 들어가는 모양새는 허용하지 않는 모양입니다.

     

    4. 스냅샷 적용을 시간차로 적용한다면

    거의 동시에 현재 두개의 스냅샷을 뜹니다.

    하나는 고양이 섹션에 양달을 넣습니다.

    하나는 강아지 섹션에 백호를 넣습니다.

    그리고 두 스냅샷을 순차적으로 적용하면, 어떻게 될까요?

     

      func insertDogAndCats() {
        var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
        snapshot.appendSections([.cat, .dog])
        snapshot.appendItems([.init(name: "cat")], toSection: .cat)
        snapshot.appendItems([.init(name: "dog")], toSection: .dog)
        self.datasource.apply(snapshot)
    
        DispatchQueue.main.asyncAfter(deadline: .now() + 5) {
    
          var snapShotOne = self.datasource.snapshot()
          snapShotOne.appendItems([.init(name: "yangdal")], toSection: .cat)
    
          var snapShotTwo = self.datasource.snapshot()
          snapShotTwo.appendItems([.init(name: "backho")], toSection: .dog)
    
    
          self.datasource.apply(snapShotOne, animatingDifferences: true) {
            DispatchQueue.main.asyncAfter(deadline: .now() + 5) {
    
              self.datasource.apply(snapShotTwo, animatingDifferences: true)
            }
          }
        }
    
      }

     

    실행시켜본 결과,

    처음에는 [고양이] [강아지] 아이템이 존재하고, 두 스냅샷이 이 데이터형태를 본을 뜹니다.

    5초 뒤 [고양이, 양달] [강아지] 로 변경됩니다.

    10초 뒤 [고양이] [강아지, 백호] 로 변경됩니다.

     

     

    두 가지 사실을 알 수 있습니다.

     

    1. 스냅샷은 동시성에서 자유롭습니다. 데이터 변경할 때 일관성을 유지하기 위해서 락을 걸 필요가 없습니다. 

    2. 결국 첫 번째 스냅샷의 액션인 '양달을 삽입하라' 는 5초 뒤 무시되었습니다. 동시에 연달아 두 개의 값이 들어갈 때, 스냅샷 기준 이전, 이후만 판단하기 때문에 이전의 스냅샷이 무시될 수 있습니다.

     

     

    동일 아이템을 두 번 들어가기를 피하기

    섹션이 유니크하지 않으면 터지는 거는 어느 정도 감수하겠습니다만..

    아이템이 그러는 경우는 좀 곤란합니다. 그 살다보면 사람이 실수할 수도 있어 동일 데이터를 한번에 삽입하면 터진다는 건데요. 이것까지는 어떻게 고친다고 해도, 이런 시나리오가 생각나네요.

    1. 서버에서 완전히 동일한 데이터를 내립니다.

    2. 그리고 동일한 모델로 매핑합니다.

    3. 앱이 터집니다.

    음. 골치아픕니다.

    그러면 어떻게 하면 이 난제를 해결할 수 있을까요?

    UUID

     

    UUID() 는 16바이트의 난수를 값으로 가집니다.

    겹칠 확률이, 없지는 않지만. 지극히 낮답니다.

     

    이런 식으로 살짝 모델을 수정합니다.

    struct Item: Hashable {
      let name: String
      let id = UUID()
    }

     아까 터졌던 이 코드를 다시 빌드합니다.

    func insertDogAndCats() {
        var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
        snapshot.appendSections([.cat, .dog])
        snapshot.appendItems([.init(name: "yangdal")], toSection: .cat)
        snapshot.appendItems([.init(name: "uyou")], toSection: .dog)
        snapshot.appendItems([.init(name: "uyou")], toSection: .cat)
        self.datasource.apply(snapshot)
    }

     

    실행시켜 보겠습니다.

     

     

     

    터지지 않은데다가 dog 와 cat 섹션에 모두 uyou 가 등장했습니다.

Designed by Tistory.