본문 바로가기
강의 정리

WWDC 2019: Advances in UI Data Sources에 관하여

by iOS 개린이 2023. 8. 25.

1. Current State-of-the-Art

우리가 UITableView나 UICollectionView에서 UIDatasource와 상호작용하는 방법은 무엇입니까?

아래 코드는 UICollectionViewDataSource 구현 예시입니다.

 

func numberOfSections(in collectionView: UICollectionView) -> Int {
    return models.count
}

func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
    return models[section].count
}

func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
    let cell = collectionView.dequeueReusableCell(withReuseIdentifier: reuseIdentifier, for: indexPath)
    
    //configure cell
    return cell
}

 

 

이렇게 각 섹션의 섹션 수와 아이템의 수에 대해 질문하고, 컨텐츠가 렌더링되면 셀을 요청한다.

이것은 굉장히 간단하면서 좋습니다.

 

 

 

 

이 방식은 약 10년동안 잘 작동해왔으며, 우리에게 친숙한 방식입니다.

 

예제로 제시된 코드처럼, 데이터 소스와 상호 작용하는 방법은 간단하고 이해하기 쉽습니다.

섹션의 수와 섹션 내 아이템의 수를 지정하고, 각 셀을 구성하는 방식으로 작동합니다.

 

또한 이것은 데이터 구조가 특정 형태를 따를 필요가 없기 때문에 유연성이 높습니다.

데이터 소스는 단순한 일차원 배열이거나, 이차원 배열일 경우에도 유연하게 처리가 가능합니다.

 

 

 

하지만 우리의 앱은 종종 일차원 또는 이차원 배열보다 더 복잡합니다.

앱은 매년 더 많은 기능을 추가하고, 사용자들은 더 많은 기능을 요구한다. 이로 인해 앱의 복잡성은 증가하고 있으며, 이 복잡성은 데이터 소스 및 관련 컨트롤러에도 영향을 미친다.

 

컨트롤러는 CoreData나 웹서비스와 상호작용하는 등 다양한 작업을 수행할 수 있고, 해당 작업으로 인한 복잡성은 컨트롤러에 의해 데이터 소스로 이어집니다. 

 

 

 

UI Layer와 Controller간의 상호작용에 대해서 설명해보겠습니다.

 

처음에는 UI layer가 컨트롤러에게 섹션의 아이템 수를 묻거나, 셀을 렌더링해달라고 요청하는 등 매우 단순한 대화가 이루어집니다. 그러나 시간이 지나면서 복잡성이 증가합니다.

 

컨트롤러가 웹 서비스 요청으로 데이터를 가져와야 하는 경우 등 복잡한 상황이 발생할 수 있습니다. 예를 들어, 트윗 데이터를 가져온다고 할 때, 복잡해진 컨트롤러가 변경사항을 전파합니다.

 

컨트롤러에서 데이터 변경이 발생하면, UI layer가 이를 감지하고 적절히 반응해야 합니다. 하지만 UITableView나 CollectionView에 대한 변경사항을 적용하는 것은 복잡할 수 있으며, 이러한 변형을 올바르게 관리하는 것은 쉽지 않을 수 있습니다.

 

 

해당 에러는 컬렉션 뷰에서 데이터를 업데이트할 때, 섹션의 수가 일치하지 않아 발생하는 것이다. 

 

이 에러의 원인을 찾고 수정하려고 노력하지만,

때로는 문제를 해결하는 것이 힘들어 간단한 해결책으로 'reloadData' 를 호출하는 경우가 있습니다.

reloadData는 데이터를 다시 불러오는 방법이며, 테이블 뷰나 컬렉션 뷰를 다시 그리지만, 애니메이션 효과가 없이 갑작스럽게 변하게 됩니다. 그리고 이런 변화는 사용자 경험에 좋지 않습니다.

 

 

Truth - 실제 데이터의 상태

 

컨트롤러와 UI layer 둘 다 자신만의 truth를 가지고 있다.

UI layer 코드는 컨트롤러의 truth와 UI의 truth를 항상 동기화해야 하는 책임이 있다.

하지만 이것은 어려울 때가 많으며, 위 에러 메세지와 같은 문제를 일으킬 수 있다.

 

아무튼 현재의 상호 작용 방식은 에러가 발생하기 쉽다.

 

 

A New Approach(Diffable Data Source)

Diffable DataSource 에는 performBatchUpdates가 없습니다.

performBatchUpdates로 인한 관련된 복잡성, 충돌, 문제들을 완화시킵니다.

 

Diffable DataSource는 단일 메서드 Apply를 사용하는데, 

Apply는 데이터의 변화를 감지하고, 자동으로 UI를 업데이트하는 과정을 단순화합니다.

 

그리고 Snapshot이라는 새로운 구조를 사용하여 이 작업을 수행합니다.

 

 Snapshot은 현재 UI 상태의 truth를 나타냅니다. 즉, 어떤 데이터가 화면에 어떻게 보여야 하는지에 대한 정확한 상태를 담고 있습니다.

 

기존의 indexPaths 대신, 각 섹션과 아이템에 대해 고유한 식별자를 사용하고, 이를 통해 업데이트 과정을 더욱 단순하고 안정적으로 만듭니다.

 

Snapshot 예제

 

우리는 현쟂 FOO, BAR, BIF 라는 세 개의 아이템을 가지고 있습니다. 이들은 고유한 식별자를 가지고 있습니다.

이 과정에서 컨트롤러가 변경되었고, 새로운 스냅샷을 적용해야 합니다.(BAR, FOO, BAZ)

 

그냥 apply() 만 적용해주면 다음과 같이 변경된다.

 

apply() 메서드는 현재 스냅샷의 상태와 새로운 스냅샷의 상태간의 차이점을 자동으로 감지하고, 필요한 변경을 수행합니다.

이 과정은 자동으로 처리되며, 개발자가 복잡한 동기화 작업을 수행할 필요가 없습니다.

 

 

 

Diffable DataSource는 플랫폼에 따라 여러 클래스들을 제공합니다.

 

iOS, iPadOS - UICollectionViewDiffableDataSource, UITableViewDiffableDataSource

masOS - NSCollectionViewDiffableDataSource

 

NSDiffableDataSourceSnapshot 클래스는 모든 플랫폼에서 공통으로 사용되며, 현재 UI의 상태를 나타내는 스냅샷을 책임집니다. 

 

먼저 알아야 할 부분!

1. UICollectionViewDiffableDataSource

@available(iOS 13.0, tvOS 13.0, *)
class UICollectionViewDiffableDataSource<SectionIdentifierType, ItemIdentifierType> : NSObject, UICollectionViewDataSource 
            where SectionIdentifierType : Hashable, SectionIdentifierType : Sendable, 
                  ItemIdentifierType : Hashable, ItemIdentifierType : Sendable { ... }

 

UICollectionViewDiffabbleDataSource의 정의 부분입니다.

SectionIdentifierType과 ItemIdentifierType의 조건을 보면 'Hashable' 을 준수해야 한다는 것을 볼 수 있습니다.

 

2. NSDiffableDataSourceSnapshot

struct NSDiffableDataSourceSnapshot<SectionIdentifierType, ItemIdentifierType> : @unchecked Sendable 
       where SectionIdentifierType : Hashable, SectionIdentifierType : Sendable, 
             ItemIdentifierType : Hashable, ItemIdentifierType : Sendable { ... }

 

NSDiffableDataSourceSnapshot의 정의 부분입니다.

SectionIdentifierType과 ItemIdentifierType의 조건을 보면 'Hashable' 을 준수해야 한다는 것을 볼 수 있습니다.

 

 

DiffableDataSource 사용예시

상황 설명

 

다음과 같이 검색 UI는 세계 각지의 산봉우리를 나열하고 있고, 사용자가 검색 필드에 입력을 시작하면 일치하는 항목만 자동으로 필터링합니다. 여기서 DiffableDataSource 를 사용하여 멋진 애니메이션 적용이 가능하고, 굉장히 간단한 코드로 구현이 가능합니다.

 

extension MountainsViewController: UISearchBarDelegate {
    func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) {
        performQuery(with: searchText)
    }
}

 

유저가 searchBar에서 입력을 시작하면, 콜백이 컨트롤러에게 전달됩니다.

그리고 입력된 검색 텍스트를 'performQuery' 함수에 전달하여 검색 쿼리를 실행합니다.

 

스냅샷 만들기

1. 새로운 스냅샷을 생성: 컬렉션 뷰나 테이블 뷰에 새로운 데이터 세트를 넣을 때마다 스냅샷을 생성합니다.

2. 스냅샷 채우기: 해당 업데이트 주기에서 표시하려는 항목의 설명으로 스냅샷을 채웁니다.

3. 스냅샷 적용: 스냅샷을 적용하여 자동으로 UI 변경 사항을 커밋합니다.

extension MountainsViewController {
    func performQuery(with filter: String?) {
        //검색 필터에 일치하는 산을 필터링하고 이름별로 정렬합니다.   
        let mountains = mountainsController.filteredMountains(with: filter).sorted { $0.name < $1.name }
        
        //빈 스냅샷 객체를 생성합니다.
        let snapshot = NSDiffableDataSourceSnapshot<Seaction, MountainsController.Mountain>()
        
        //'main' 섹션을 추가합니다. 
        snapshot.appendSections([.main])
        
        //필터링된 산 목록을 스냅샷에 추가합니다.
        snapshot.appendItems(mountains)
        
        //스냅샷을 적용하고 변경 사항을 애니메이션으로 표시합니다.
        dataSource.apply(snapshot, animatingDifferences: true)
    }
}

 

performQuery 메서드 내부에서는 Model Layer 객체인 mountainsController 에게 검색어와 일치하는 산 목록을 필터링하고, 정렬하도록 요청합니다. 이렇게 필터링된 배열을 가지고, 새로운 스냅샷으로 만들어 DiffableDataSource에게 적용해주면, DiffableDataSource에서는 현재 스냡샷과 새로운 스냅샷을 비교하여 셀을 재구성해줍니다.

 

스냅샷의 생성 및 구성

1. 빈 NSDiffableDataSourceSnapshot 객체를 생성합니다.

2. 단일 섹션을 표시하기 위해 섹션을 추가합니다.

3. 업데이트에서 표시하려는 아이템의 식별자를 추가합니다. 

 

일반적으로 식별자로 이루어진 Array를 전달하지만, Swift에서는 구조체나 열거형을 사용할 수 있으며, 해당 타입이 'Hashable' 을 준수하고 있다면, 그 타입의 객체를 직접 전달할 수 있습니다.

 

3번까지 작업까지 완료하여 스냅샷을 구성하면, 해당 스냅샷을 DiffableDataSource에 Apply하면 됩니다.

 

dataSource.apply()' 를 호출하여 스냅샷을 적용하면, DiffableDataSource는 이전 업데이트와 다음 업데이트 사이에 무엇이 변경되었는지를 파악합니다. 이 과정은 자동으로 이루어지기 때문에 개발자가 직접 변경 사항을 추적할 필요가 없습니다.

 

기존의 indexPath는 특정 업데이트에만 의미가 있고, 변하기 쉽습니다.

반면, 식별자(identifiers)를 사용하면 견고하며, 지속적입니다. 또한 코드가 더 간결해지고, 관리하기 쉬워집니다.

 

섹션이 여러 개인 경우

appendSections([.section1])
appendItems(items1)
appendSections([.section2])
appendItems(items2)

예시에서는 단일 섹션으로 스냅샷을 구성했는데,

만약 섹션이 여러 개인 경우에는 코드와 같이 섹션과 아이템을 순서대로 호출해주면 됩니다.

 

현재 스냅샷 가져오기

let snapshot = dataSource.snapshot()

예시에서는 빈 스냅샷을 만들어 새롭게 구성했는데, 

만약 현재 스냅샷을 가지고 오고 싶다면 코드와 같이 사용하면 됩니다.

 

기존 아이템을 삭제하고, 새로운 아이템을 반영하기

private func deleteAndApplySnapshot(newListProducts: [item]) {

    let previousProducts = snapshot.itemIdentifiers(inSection: .main)
    
    // 기존 아이템 삭제
    snapshot.deleteItems(previousProducts) 
    
    //모든 아이템 삭제
    snapshot.deleteAllItems()
    
    //새로운 아이템 등록
    snapshot.appendItems(newListProducts, toSection: .main)
    
    dataSource.apply(snapshot, animatingDifferences: true)
}

 

SectionIdentifierType

enum Section: CaseIterable {
     case main
}

 

Section은 단 한 개의 case 'main' 을 가지고 있습니다.

이는 단일 섹션을 갖는 일반적인 경우에 유용한 기법입니다.

 

Swift의 enum은 연관값이 없을 경우, 자동으로 Hashable하기 때문에, 별도의 구현이 필요 없습니다.

(모든 연관값이 Hashable하면 enum도 Hashable하다.)

 

MountainsController(ItemIdentifierType)

class MountainsController {
    
    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)
        }
    }
    
    func filteredMountains(with filter: String?) -> [Mountain] {
        
        return mountains.filter { $0.contains(filter) }
    }

    private lazy var mountains: [Mountain] = {
       return generateMountains()
    }()
}

 

구조체 Mountain은 'Hashable' 프로토콜을 준수하며, 이를 통해 DiffableDataSource와 함께 원활하게 사용할 수 있습니다. 

 

'identifier'는 각 산마다 부여되는 고유한 식별자이며, hash() 메서드를 통해 고유 식별자만을 사용하여 해시 값을 제공합니다. 또한, 동일성 검사를 위한 '==' 연산자를 오버라이드하여 두 'Mountain' 인스턴스가 동일한지 비교합니다.(식별자가 같은 경우에만 동일하다고 판단)

 

'MountainsController' 클래스의 'filteredMountains(with:)' 메서드는 특정 필터 문자열을 기반으로 산 목록을 필터링합니다.

 

이렇게 Hashable을 준수하는 Mountain 구조체는 고유 식별자와 해시 함수를 통해 DiffableDataSource가 업데이트 간에 각 산을 추적하는 데 도움을 줍니다.

 

DiffableDataSource 만들기

func configureDataSource() {
        dataSource = UICollectionViewDiffableDataSource
        <Section, MountainsController.Mountain>(collectionView: collectionView) {
            (collectionView: UICollectionView, indexPath: IndexPath, mountain: MountainsController.Mountain) -> UICollectionViewCell? in
            
            guard let mountainCell = collectionView.dequeueReusableCell(withReuseIdentifier: SearchCollectionViewCell.identifier, for: indexPath) as? SearchCollectionViewCell else {
                fatalError("Cannot create new cell")
            }
            
            mountainCell.titleLabel.text = mountain.name
            return mountainCell 
        }
    }

 

먼저 UICollectionViewDiffableDataSource 를 생성하면서 섹션과 아이템 타입을 매개변수로 전달합니다.

그리고 전달되는 클로저는 컬렉션 뷰의 각 셀을 설정하는 역할을 합니다.

'collectionView', 'indexPath', 'mountain' 을 전달하며, 이들을 사용해 특정 셀을 설정합니다.

 

이 클로저는 우리가 DataSource를 구현할 때, 일반적으로 작성해야 하는 'cellForItemAt()' 메서드의 코드이다.

이렇게 하면 해당 Cell의 indexPath 에서 적절한 타입의 셀을 요청하고, 원하는 데이터로 셀을 채워 넣은 다음 반환하는 코드를 편리하게 래핑할 수 있습니다.

 

더 좋은 점은, 요청받은 아이템의 indexPath뿐만 아니라 해당 아이템의 식별자 또는 Swift의 값 타입도 전달받는다는 것입니다. 예를 들어, 이 경우에는 'mountain' 객체가 전달되므로 Model Layer 객체를 찾아볼 필요가 없습니다.

 

받은 'mountain' 객체에서 산의 이름을 가져와 Cell의 라벨 텍스트로 설정하면 됩니다.

이 외에 컬렉션뷰를 설정하고 구성하는 방법은 이전과 동일하며, 별도의 performBatchUpdates 코드가 숨겨져 있지 않습니다.