HCN DEV

Read in English

DiffableDataSource로 안전하게 UIKit List 업데이트하기

DiffableDataSource로 안전하게 UIKit List 업데이트하기 feature image

1. WWDC - Advances in UI Data Sources

Diffable Data SourceUICollectionViewUITableView의 DataSource 업데이트를 좀 더 안전하고, 편리하게 수행할 수 있도록 만들어진 API입니다. 이 글에서는 iOS 13부터 지원되는 Diffable Data Source를 소개하는 WWDC 주요 내용(Advances in UI Data Sources)을 정리하고, 이를 적용한 후기에 대해 작성해보았습니다.

Current state-of-the-art

  • UICollectionView, UITableView를 구성하기 위해서 개발자는 아래와 같은 DataSource 코드를 작성해야 했습니다.

no1

그리고 이 DataSource를 업데이트하기 위해서는 reloadData()performBatchUpdates() 사용해야 합니다. 전체 데이터를 갱신하는 reloadData() 는 작은 데이터를 갱신할 때에는 유용하지만, 보여주는 데이터의 양이 많아지거나 일부 Cell만 업데이트하고자 할 때에는 performBatchUpdates()를 통해 개별 Section, 혹은 Item을 업데이트해주어야 합니다. 하지만, performBatchUpdates() 업데이트가 잘못 되었을 때 아래와 같은 크래시가 발생합니다.

no2

Diffable DataSource

  • Diffable DataSource는 UI의 업데이트를 간단하고, 크래시 없이 수행할 수 있도록 하기 위해 iOS 13에서 새롭게 소개된 API입니다.
  • Diffable DataSource는 Cell의 insert, delete 등의 동작을 사용자가 직접 수행하지 않도록 합니다. 그리고, 데이터의 업데이트를 Snapshot을 적용(apply)하는 방식으로 수행하도록 합니다.

no3

Snapshots

Snapshot은 UI의 상태를 가지고 있는 객체로 Snapshot을 통해 데이터 업데이트시 IndexPath 접근 없이 데이터를 업데이트 할 수 있습니다. no4

UI 업데이트가 필요할 경우 새로운 Snapshot을 만들거나, 기존에 반영된 Snapshot을 가져와서 DataSource에 apply()해주면 됩니다.

no5

How to Use

1. Diffable DataSource property 정의

UIKit에서는 UICollectionView, UITableView에 맞는 Diffable DataSource를 정의하여 다음과 같이 제공합니다.

@available(iOS 13.0, tvOS 13.0, *)
open class UITableViewDiffableDataSource<SectionIdentifierType, ItemIdentifierType> : NSObject, UITableViewDataSource 
    where SectionIdentifierType : Hashable, ItemIdentifierType : Hashable {

    public typealias CellProvider = (_ tableView: UITableView,
                                     _ indexPath: IndexPath,
                                     _ itemIdentifier: ItemIdentifierType) -> UITableViewCell?

    public init(
        tableView: UITableView,
        cellProvider: @escaping UITableViewDiffableDataSource<SectionIdentifierType, ItemIdentifierType>.CellProvider)
}
  • Diffable DataSource를 생성시 제공해야 하는 SectionIdentifierType, ItemIdentifierType과 관련해서 유의해야 할점은 해당 타입이 모두 Hashable이어야 한다는 점입니다.
class ViewController: UIViewController {

    @available(iOS 13.0, *)
    private lazy var diffableDataSource: UITableViewDiffableDataSource<Int, ViewModel> = {
        return UITableViewDiffableDataSource<Int, ViewModel>(tableView: logTableView,
                                                             cellProvider: { [weak self] (tableView, indexPath, itemIdentifier) in

            guard let cell = tableView.dequeueReusableCell(withIdentifier: "MyTableViewCell",
                                                           for: indexPath) as? MyTableViewCell,
                  let viewModel = self?.viewModel else {
                      return tableView.dequeueReusableCell(withIdentifier: "UITableViewCell",
                                                           for: indexPath)
                  }

            let cellViewModel = ViewModel(identifier: itemIdentifier)
            cell.viewModel = cellViewModel
            return cell
        })
    }()

    override func viewDidLoad() {
        super.viewDidLoad()

        if #available(iOS 13.0, *) {
            tableView.dataSource = diffableDataSource
        } else {
            tableView.dataSource = self
        }
    }
}
  • Diffable DataSource는 위처럼 사용할 수 있습니다. 여기서 CellProvidercellProvider 클로저는 기존의 cellForItemAt()의 로직을 대체하는 형태로 사용할 수 있습니다.
  • UITableViewDiffableDataSourceUITableViewDataSource를 따르고 있기 때문에 이를 사용하게 되면 기존의 UITableViewDataSource는 호출되지 않습니다.

2. 새로운 Snapshot 적용하기

위처럼 정의한 DataSource에 새로운 데이터를 추가하고 싶은 경우, 아래처럼 NSDiffableDataSourceSnapshot을 만들고 모델을 추가해주면 됩니다.

var snapshot = NSDiffableDataSourceSnapshot<Int, >()
snapshot.appendSections([0])
snapshot.appendItems(messages, toSection: 0)
self?.diffableDataSource.apply(snapshot,
                               animatingDifferences: false,
                               completion: { [weak self] in
    self?.scrollToBottom(animated: true)
})

유의사항 및 추가 내용

  • Diffable Data Source를 적용한 시점부터는 performBatchUpdates(), insertItems(), deleteItems() 등의 API를 사용하면 안 됩니다.

no6

  • Snapshot은 새로운 인스턴스 혹은 기존 Diffable Data SourceSnapshot을 통해 생성할 수 있습니다.

no7

  • 모든 데이터 업데이트는 Snapshot을 통해 수행됩니다. 그에 따라 Snapshot은 모델을 업데이트하는 API를 제공합니다.

no8

성능

  • Diffable Data Source에서 사용하는 Diff 알고리즘은 빠릅니다.
  • 새로운 Snapshot을 적용하는 apply() 메소드는 기존 reload API들과 다르게 항상 메인 쓰레드에서 수행되지 않아도 됩니다. 하지만, 항상 동일한 Queue에서 apply()가 호출되어야 합니다.

no9

2. 실제 적용기

Diffable DataSource는 유용하게 사용할 수 있지만, 적용시 고려해야 할 것들이 있습니다. 여기에서는 Diffable DataSource를 실제 프로젝트에 적용하면서 고려했던 내용들을 몇 가지 소개하고자합니다.

1. Hashable 타입

Protocol Type

Diffable DataSourceSectionIdentifierType, ItemIdentifierType은 모두 Hashable이어야 합니다. HashableGeneric Type을 사용하기 때문에 ProtocolHashable을 따를 경우 해당 Protocol은 타입으로 사용할 수 없습니다. 이 때문에 SectionIdentifierType, ItemIdentifierTypeHashable 타입을 따를 수 있는 Concrete Type의 모델을 따로 구성해주어야 합니다.

예를 들어서, 다양한 ViewModel을 사용하는 ViewController는 ViewModel을 Protocol로 정의하여 사용하는 경우가 많습니다.

protocol ViewModelable {}

struct ViewModel1: ViewModelable {}
struct ViewModel2: ViewModelable {}
struct ViewModel3: ViewModelable {}

class ViewController: UIViewController {
    var viewModel: ViewModelable
}

그런데 Diffable DataSource를 적용하기 위해서는 해당 Protocol이 Hashable이어야 하는데 이 경우 ViewModelable을 타입으로 사용할 수가 없습니다.

protocol ViewModelable: Hashable {}

class ViewController: UIViewController {
    var viewModel: ViewModelable // compile error
}

이 문제를 해결하기 위한 방법은 해당 Protocol을 associated Value로 가지는 enum을 정의하는 것입니다.

enum ViewModel: Hasable {
    case normal(viewModel: ViewModelable)
    case error(viewModel: ViewModelable)
}

class ViewController: UIViewController {
    var viewModel: ViewModel
}

이와 같이 처리하게 되면, 해당 ViewModel에 다양한 타입을 추가하면서도 Diffable DataSource를 적용할 수 있게 됩니다.

Concrete Type

또한, 기존 모델이 Concrete type인 경우에도 문제가 생길 수 있습니다. HashableEquatable을 따르고 있는데, 이 Equatable을 위해 ==(lhs:rhs:)을 구현하면서 상당히 많은 boilerplate 코드를 작성해야 합니다. 이를 회피하기 위해 기존 모델에 UUID를 주입하는 방식을 통해 Hashable을 따르는 모델을 쉽게 구성할 수 있습니다.

protocol UUIDHashable: Hashable {
    var uuid: UUID { get set }
}

extension UUIDHashable {
    func hash(into hasher: inout Hasher) {
        hasher.combine(uuid)
    }
}

2. 성능

데이터가 많고, UI 업데이트가 반복적으로 자주 발생하는 경우에 새로운 Snapshot을 새로 생성해서 사용할 경우 성능이 떨어지는 현상이 발생합니다. 예를 들어서, 새로운 Snapshot을 0.1초마다 생성해서 적용할 경우, 스크롤이 버벅이는 현상이 나타납니다.

var snapshot = NSDiffableDataSourceSnapshot<Int, ViewModel>()
snapshot.appendSections([0])
snapshot.appendItems(viewModels, toSection: 0)
diffableDataSource.apply(snapshot)

이는 reloadData()를 빠르게 여러번 수행할 경우 성능 저하가 발생하는 것과 유사한 이슈입니다. 따라서 이 문제를 개선하기 위해 항상 새로운 Snapshot을 만들고 적용하는 것이 아니라, Diffable DataSource에 바인딩된 Snapshot에 수정되는 모델만 추가, 제거하는 방식을 사용할 수 있습니다.

func applySnapshot(newItems: [ViewModel]) {
    var snapshot = diffableDataSource.snapshot()
    snapshot.appendItems(newItems, toSection: 0)
    diffableDataSource.apply(snapshot)
}

3. Background Queue

WWDC에 언급된 것처럼 apply() 메소드는 메인 쓰레드에서 수행되지 않아도 됩니다. 다만, 다른 Queue에서 업데이트를 수행할 때에는 apply() 메소드가 명시적으로 같은 Queue에서 호출되어야 합니다. 여기서 명시적이라는 말의 의미는 apply()가 로직상으로 같은 Queue에서 호출되는 것으로 예상할 수 있더라도, apply() 수행을 명시적으로 Queue로 감싸주어야 하는 것을 의미합니다. 이를 처리해주지 않으면 warning이 발생하고, 예상하지 못 한 상황에 크래시가 발생할 수도 있습니다.

또한, 한 번 다른 Queue에서 apply()가 수행된 경우에는 이를 Main Queue에서 업데이트해선 안됩니다. 즉, Queue를 바꿔가면서 apply()를 수행해서는 안됩니다.

let queue = DispatchQueue(label: "Update Queue")

// applyQueueToSnapshot()이 queue에서 호출되더라도 apply()는 queue로 감싸주어야 합니다.
func applySnapshot(newItems: [ViewModel]) {
    queue.async { [weak self] in
        guard let self = self else { return }
        var snapshot = self.diffableDataSource.snapshot()
        snapshot.appendItems(newItems, toSection: 0)
        self.diffableDataSource.apply(snapshot)
    }
}

참고자료