본문 바로가기
Dev/WWDC 정리

[WWDC19] Advances in UI Data Sources (DiffableDataSources)

by Mintta 2023. 6. 12.

컴포지셔널 레이아웃에 이어서 연달아 보고 정리해놓았는데 노션에만 놓고 옮기는 것을 깜빡해서 지금이라도 옮겨적는다..!

적용기는 이후에 다시 남겨야겠다!

기존 DataSource

  • 나름 굉장히 직관적이다. 하지만 앱은 계속해서 복잡해져간다.

Controller Layer와 UI Layer의 대화

Smooth sailing

UI: Controller, 섹션에 있는 항목의 수를 알려줘.

UI: 콘텐츠를 렌더링할 때 셀을 알려줘.

문제는 여기서 발생한다. (more complex)

Controller: UI야 나 바꼈는데??? (didChange)

UI: 어? 그러네 그러면 나 TableView나 CollectionView UI 바꿔야겠는데??😮

이건 좀 복잡한 작업이다.

그리고 만약 정말 잘 처리해줘도 아래와 같은 에러가 발생할 수 있다.

그러면 아 맞다 ! reloadData를 해주면 잘되지 않을까? 맞다. 잘될 것이다.

하지만 reloadData는 non-animated effect이고, 사용자 경험을 저하시킨다.

문제의 근원 (DiffableDataSource 등장배경)

진실은 어디에?

세션에서는 진실(Truth)이라고 표현했다. 나는 이것을 일종의 state(상태)라고 이해하고 들었다.

data controller도 자기만의 truth(state)를 가지고 있고, UI도 자기만의 버전의 truth(state)를 가지고 있다. 그리고 이것은 항상 바뀐다.

UI Layer는 항상 메인스레드에서 동작하고 앞서 살펴본 것처럼 이 작업은 어렵다. 따라서 현재의 방식은 오류가 발생하기 쉽다(error prone).

그리고 무엇보다도 중앙 집중화된 진실에 대한 개념이 없다.

(no notion of centralized truth)

DiffableDataSource🚀

Snapshots

  • 스냅샷이라는 새로운 구조로 이 작업을 수행한다.
  • 현재 UI 상태의 진실이다.
    • Unique identifiers for sections and items
    • No more IndexPaths

어떻게 이게 가능한가?

Diffable Data Source

  • UICollectionViewDiffableDataSource
  • UITableViewDiffableDataSource
  • NSCollectionViewDiffableDataSource (MAC)
  • NSDiffableDataSourceSnapshot

Code Example

3-Step process🚀

  1. 전체 데이터 원본이 있는 컬렉션 뷰 또는 UITableView에 새로운 변경 사항 집합, 새로운 데이터 집합을 넣으려면 언제든지 스냅샷을 만들기만 하면 됩니다.
  2. 해당 업데이트 주기에 표시하려는 item에 대한 description으로 해당 스냅샷을 채웁니다.
  3. 그런 다음 스냅샷을 apply하여 UI에 변경 사항을 자동으로 커밋합니다. DiffableDataSource는 UI 요소에 대한 모든 변경 사항 적용 및 발행을 처리합니다.

extension MountainsViewController: UISearchBarDelegate {
    func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) {
        performQuery(with: searchText)
    }
}
func performQuery(with filter: String?) {
    let mountains = mountainsController.filteredMountains(with: filter).sorted { $0.name < $1.name }

    var snapshot = NSDiffableDataSourceSnapshot<Section, MountainsController.Mountain>()
    snapshot.appendSections([.main])
    snapshot.appendItems(mountains)
    dataSource.apply(snapshot, animatingDifferences: true)
}
  1. snapshot을 만든다

이제 이 스냅샷은 처음에 비어 있다. 아무것도 포함되어 있지 않습니다. 따라서 원하는 섹션과 항목으로 채우는 것은 사용자의 몫이다.

enum Section: CaseIterable {
    case main
}
  • 현재 section은 하나밖에 없기 때문에 main으로 이름지은 section 1개.
  1. 다음으로, 이 업데이트에 표시할 item의 identifier를 추가합니다.

우리는 보통 여기에 identifier array를 전달한다. 하지만 Swift에서는 여기에 고유한 네이티브 타입으로 작업하여 훨씬 더 우아하게 만들 수도 있습니다. 따라서 네이티브 타입이 있고 struct나 enum과 같은 값 타입일 수도 있지만, 해당 타입을 Hashable으로 만들면 Swift 구문으로 자신만의 네이티브 객체를 전달할 수 있다.

public enum TodoBottomSheetDataSource { }

extension TodoBottomSheetDataSource {
  enum Section: Int, Hashable, CaseIterable {
    case homies
  }
  enum Item: Hashable, Equatable {
    case homie(HomieCellModel)
  }
}
  1. 이제 DiffableDataSource한테 apply를 요청하면된다 !
var dataSource: UICollectionViewDiffableDataSource<Section, MountainsController.Mountain>!
dataSource.apply(snapshot, animatingDifferences: true)

NSDiffableDataSourceSnapshot

Snapshot을 살펴보면 Generic타입으로 되어 있다.

SectionIdentifierType, ItemIdentifierType은 type placeholder이다. 우리가 제네릭을 쓸 때 흔히 쓰는 T와 같다고 보면 된다. 그리고 section과 item 타입 모두 Hashable인 것을 확인할 수 있다.

Hashable

enum Section: CaseIterable {
    case main
}

이렇게 연관값이 없는 열거형의 경우는 Hashable을 채택하지 않아도 자동으로 구현이 된다. 따라서 여기는 Hashable이 필요가 없다. struct또한 Hashable을 채택하는 것만으로 끝.

public enum TodoBottomSheetDataSource { }

extension TodoBottomSheetDataSource {
  enum Section: Int, ~~Hashable~~, CaseIterable {
    case homies
  }
  enum Item: Hashable, Equatable {
    case homie(HomieCellModel)
  }
}

이 코드에서 보면 Section은 연관값이 없기 때문에 Hashable을 지워도 되고

아래 Item은 연관값(HomieCellModel)이 있기 때문에 해당 객체는 Hashable을 채택한다고 선언해주고 ! 연관값의 타입, 즉 HomieCellModel도 Hashable을 채택(구현)하고 있어야 한다. 아래처럼 !

public struct HomieCellModel: Hashable { ... }

다시 예제 코드로 돌아와서…

struct Mountain: Hashable {
    let name: String
    let height: Int
    let identifier = UUID()
    func hash(into hasher: inout Hasher) {
        hasher.combine(identifier)
    }
    static func == (lhs: Mountain, rhs: Mountain) -> Bool {
        return lhs.identifier == rhs.identifier
    }
    func contains(_ filter: String?) -> Bool {
        guard let filterText = filter else { return true }
        if filterText.isEmpty { return true }
        let lowercasedFilter = filterText.lowercased()
        return name.lowercased().contains(lowercasedFilter)
    }
}

여기서 중요한 요건은 각 Mountain을 해시값으로 고유하게 식별할 수 있어야 한다는 것이다. 따라서 각 산에 자동으로 생성된 unique identifier(UUID)를 부여하여 이를 달성한다.

func hash(into hasher: inout Hasher) {
    hasher.combine(identifier)
}
static func == (lhs: Mountain, rhs: Mountain) -> Bool {
    return lhs.identifier == rhs.identifier
}

그리고 여기서 약속한 hashability conformance를 구현하는 곳에서는 해당 식별자만 사용하여 해시 값을 제공한다. 이렇게 하면 산이 값 유형이긴 하지만 각각의 산을 갖게 되고, 산은 그냥 복사해서 전달됩니다. 참조할 수 있는 포인터가 없다.

그러나 identifier와 해당 identifier의 해시 값은 DiffableDataSource가 한 업데이트에서 다음 업데이트까지 추적할 수 있을 만큼 고유하다. 그리고 Hashable의 일부로 여기에서도 동일성 테스트(==)를 구현하고 있다.

(Hashable은 자체적으로 Equatable를 채택하고 있기 때문에 !!)

 

지금까지 DiffableDataSource에 변경 사항을 발행하는 방법을 살펴봤습니다.

어떻게 사용하도록 설정할까요?

Back to How

/// - Tag: MountainsDataSource
func configureDataSource() {

    let cellRegistration = UICollectionView.CellRegistration
    <LabelCell, MountainsController.Mountain> { (cell, indexPath, mountain) in
        // Populate the cell with our item description.
        cell.label.text = mountain.name
    }

    dataSource = UICollectionViewDiffableDataSource<Section, MountainsController.Mountain>(collectionView: mountainsCollectionView) {
        (collectionView: UICollectionView, indexPath: IndexPath, identifier: MountainsController.Mountain) -> UICollectionViewCell? in
        // Return the cell.
        return collectionView.dequeueConfiguredReusableCell(using: cellRegistration, for: indexPath, item: identifier)
    }
}
  • 이것은 단지 해당 cellForItemAt IndexPath 코드를 가져와서 dataSource를 인스턴스화할 때 전달할 멋진 클로저 캡슐화에 편리하게 이식하는 것이다.
  • 기존의 cellForItemAt과 같은 call back.
  • 여기서 편리하고 좋은 점과 다른 점 중 하나는 요청받는 item의 indexPath외에도 해당 item의 identifier(이 경우에는 표시하려는 특정 item에 해당하는 value type: Mountain)도 제공된다는 것이다.

Two-Sections Example

 

/// - Tag: WiFiUpdate
func updateUI(animated: Bool = true) {
    guard let controller = self.wifiController else { return }

    let configItems = configurationItems.filter { !($0.type == .currentNetwork && !controller.wifiEnabled) }

    currentSnapshot = NSDiffableDataSourceSnapshot<Section, Item>()

    currentSnapshot.appendSections([.config]) // 첫번째 section
    currentSnapshot.appendItems(configItems, toSection: .config) // 첫번째 section item삽입

    if controller.wifiEnabled { // wifi 켰으면
        let sortedNetworks = controller.availableNetworks.sorted { $0.name < $1.name }
        let networkItems = sortedNetworks.map { Item(network: $0) }
        currentSnapshot.appendSections([.networks]) // 아래 네트워크 section에
        currentSnapshot.appendItems(networkItems, toSection: .networks) // item 넣기
    }

    self.dataSource.apply(currentSnapshot, animatingDifferences: animated)
}
  • UI가 update될 때 마다 불리는 메서드

여기서도 3step process는 동일하다 !

  1. display하고 싶은 item을 가져온 후에 snapshot만들고
  2. snapshot 채우고
  3. dataSource에 apply
func configureDataSource() {
    wifiController = WiFiController { [weak self] (controller: WiFiController) in
        guard let self = self else { return }
        self.updateUI()
    }

    self.dataSource = UITableViewDiffableDataSource
        <Section, Item>(tableView: tableView) { [weak self]
            (tableView: UITableView, indexPath: IndexPath, item: Item) -> UITableViewCell? in
        guard let self = self, let wifiController = self.wifiController else { return nil }

        let cell = tableView.dequeueReusableCell(
            withIdentifier: WiFiSettingsViewController.reuseIdentifier,
            for: indexPath)

        var content = cell.defaultContentConfiguration()
        // network cell
        if item.isNetwork {
            content.text = item.title
            cell.accessoryType = .detailDisclosureButton
            cell.accessoryView = nil

        // configuration cells
        } else if item.isConfig {
            content.text = item.title
            if item.type == .wifiEnabled {
                let enableWifiSwitch = UISwitch()
                enableWifiSwitch.isOn = wifiController.wifiEnabled
                enableWifiSwitch.addTarget(self, action: #selector(self.toggleWifi(_:)), for: .touchUpInside)
                cell.accessoryView = enableWifiSwitch
            } else {
                cell.accessoryView = nil
                cell.accessoryType = .detailDisclosureButton
            }
        } else {
            fatalError("Unknown item type!")
        }
        cell.contentConfiguration = content
        return cell
    }
    self.dataSource.defaultRowAnimation = .fade

    wifiController.scanForNetworks = true
}

얘도 사실은 그냥 cellForItemAt랑 똑같은 느낌.

Sorting Examples

func performSortStep() {
        if !isSorting {
            return
        }

        var sectionCountNeedingSort = 0

        // **Get the current state of the UI from the data source.**
        var updatedSnapshot = dataSource.snapshot()

        // For each section, if needed, step through and perform the next sorting step.
        updatedSnapshot.sectionIdentifiers.forEach {
            let section = $0
            if !section.isSorted {

                // Step the sort algorithm.
                section.sortNext()
                let items = section.values

                // Replace the items for this section with the newly sorted items.
                updatedSnapshot.deleteItems(items)
                updatedSnapshot.appendItems(items, toSection: section)

                sectionCountNeedingSort += 1
            }
        }

        var shouldReset = false
        var delay = 125
        if sectionCountNeedingSort > 0 {
            dataSource.apply(updatedSnapshot)
        } else {
            delay = 1000
            shouldReset = true
        }
        let bounds = insertionCollectionView.bounds
        DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(delay)) {
            if shouldReset {
                let snapshot = self.randomizedSnapshot(for: bounds)
                self.dataSource.apply(snapshot, animatingDifferences: false)
            }
            self.performSortStep()
        }
    }
  1. 현재 snapshot가져와서
  2. 과거 item delete하고 현재 바뀐, 적용시키고 싶은 item append
  3. 그리고 dataSource에 새로운 snapshot apply

Snapshot을 만드는 2가지 방법

snapshot을 받게 되면 물어볼 것들이 많다. 아래와 같이

identifier

  • Must be Unique
  • Conforms to Hashable
  • Data model or identifier

Quick template

indexPath based API에서는 indexPath를 통해서 identifier를 얻을 수 있다.

indexPath → identifier O(1)

Performance

개발 중에는 앱을 측정하는 것이 매우 중요합니다. 우리 모두는 이를 잘 알고 있습니다. 사용자 이벤트에 실제로 반응할 수 있도록 메인 대기열을 항상 최대한 비워두는 것이 좋습니다. 그래서 굉장히 빠르게 render합니다.

만약 많은 수의 item들이 있어서 시간이 조금 더 걸리는 경우에 background queue에서 apply 메서드를 호출해도 안전하다 !! 대박

apply가 background queue에서 호출되면

프레임워크 : 난 메인큐에 있어!

차이점이 생기는 작업은 그냥 여기서(다른 스레드)에서 작업하자 !

작업이 다 끝나면 메인큐로 다시 넘어가서 apply하면 되지!

주의점 !

백그라운드 대기열에서 apply을 호출하는 경우 일관성을 유지하세요. 항상 백그라운드 큐에서 호출하세요. 백그라운드 큐에서 호출할 때와 주 큐에서 호출할 때를 혼용해서는 안 됩니다. 항상 같은 방식으로 수행하세요.

댓글