LoginSignup
4
2

DiffableDataSourceを具体的にわかりやすく解説してみた

Posted at

本記事はSwiftWednesday Advent Calendar 2023の19日目の記事です。
昨日は@huiping192 さんでした。

私はSwift学習始めたての時、UITableViewへのセルの表示は、UITableViewDataSourceというプロトコルに準拠させることで行う、という認識をしていました。

しかしプロジェクトでtableViewへのデータの表示にUITableViewDiffableDataSourceというものが使われており、なんじゃこりゃ?snapshot??identifier??となったので、今回はDiffableDataSourceの謎を可能な限り具体的に解明していきました。

DiffableDataSourceとは?

DiffableDataSourceとは、差分の更新部分を改良した、改良版DataSourceです。

従来のDataSource

DiffableDataSourceの理解のためには、通常のDataSourceについての理解が必要です。
UITableViewやUICollectionViewへのデータの表示は、それぞれUITableViewDataSource, UICollectionViewDataSourceという物が担っています。
以下ではCollectionViewに絞って話を進めていきますが、以下の話はどれもTableViewにも適用できるものです。

下記にUICollectionViewDataSourceを使用したデータ表示の例を示します。

DataSourcePageViewController
import UIKit

final class DataSourcePageViewController: UIViewController {
    var viewModel: DataSourceViewModelProtocol!
    @IBOutlet private var collectionView: UICollectionView! {
        didSet {
            collectionView.dataSource = self
            // reuseIdentifierからインスタンス化する際、どのカスタムセルを使うのか指定する
            collectionView.register(UINib(nibName: "CollectionViewCell",
                                          bundle: nil),
                                    forCellWithReuseIdentifier: "CollectionViewCell")
        }
    }
}

// 表示に必要な情報を提供するためのプロトコル。
extension DataSourcePageViewController: UICollectionViewDataSource {
    // 一セクション(一つのデータのまとまり)ごとにいくつのセルを表示するか
    func collectionView(_ collectionView: UICollectionView,
                        numberOfItemsInSection section: Int) -> Int {
        return viewModel.items.count
    }

    // それぞれのIndexPath(座標のようなもの)でどんなセルを表示するか
    func collectionView(_ collectionView: UICollectionView,
                        cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "CollectionViewCell",
                                                      for: indexPath) as! CollectionViewCell
        // 表示に必要なデータを提供するためのモデル(MVVMアーキテクチャ)
        let vm = CollectionViewCellViewModel(text: viewModel.items[indexPath.row].string)
        cell.viewModel = vm
        return cell
    }
}

これでreloadData()を行うと必要最低限、各セルにデータを表示させることができます。
しかし、表示しているセルのうち、特定のセルだけ編集・削除したい場合にある問題が発生します。
UI部分で特定のセルだけを削除する場合、以下のような書き方をします。

DataSourcePageViewController
final class DataSourcePageViewController: UIViewController {
    ...
    @IBOutlet private var deleteButton: UIButton! {
        didSet {
            deleteButton.addAction(.init {_ in
                self.collectionView.deleteItems(at: [IndexPath(item: 2, section: 0)])
            },
                                    for: .touchUpInside)
        }
    }
}

deleteButtonを押し、2番目のアイテムを消そうとすると以下のエラーが出ます。

Performing reloadData as a fallback — Invalid update: invalid number of items in section 0. 
The number of items contained in an existing section after the update (100) must be equal to the number of items contained in that section before the update (100), 
plus or minus the number of items inserted or deleted from that section (0 inserted, 1 deleted) and plus or minus the number of items moved into or out of that section (0 moved in, 0 moved out).

UI部分で消したitemの数とDataSourceのnumberOfItemsInSectionの数(つまり、viewModel中のitemsの数)が合っていない、と怒られています。
この、UI部分とDataSourceは連動しておらず、別物であるというのがポイントになってきます。
deleteItemsの後にviewModelのitemsも消したitemの分だけ消し、DataSourceに反映していれば問題ないのですが、いちいち表示部分での削除とDataSourceのnumberOfItemsInSectionへの反映を同時に記述していては大変です。
また、viewModel中でのデータ削除APIを呼び、削除している最中にさらにUI部分でdeleteItemsを実行してしまったら、また上記の不整合が生じ、怒られてしまいます。

reloadDataでいいのでは?
現在のDataSourceに合わせて強制的にcollectionViewのセル表示を書き換えるのがreloadData()です。これによってUI部分とDataSourceの整合性を強制的に取ることができます。
データが少ないうちはそれで大丈夫ですが、データが増えてくると処理が重くなり、アプリのパフォーマンスの低下につながります。

DiffableDataSourceの登場

このようなDataSourceの欠点を補ったのがDiffableDataSourceです!こちらの特徴は以下のようにまとめられます。

  • snapshotと呼ばれる、collectionViewのUI全体の状態を表す設計図のようなものが使われる。
    • snapshotを変化させapply()すると、変化した差分のみをUI上で更新する。
  • section、itemそれぞれに対し一意に定まるためのidentifierを定義し、同一identifierは同じitemとみなす。
    • identifierはHashable(ハッシュ化して一意にするためのプロトコル)に基づく必要がある。
    • 同じidentifierの中で変更があればセルが更新される。

コードの例を以下に示します。

DiffableDataSourcePageViewController
import UIKit

final class DiffableDataSourcePageViewController: UIViewController {
    var viewModel: DataSourceViewModel!
    @IBOutlet private var collectionView: UICollectionView! {
        didSet {
            collectionView.register(UINib(nibName: "CollectionViewCell",
                                          bundle: nil),
                                    forCellWithReuseIdentifier: "CollectionViewCell")
        }
    }
    private lazy var dataSource = DiffableDataSource(collectionView)
}

// ジェネリクスはsectionのidentifier、itemのidentifierを書く。sectionが存在しない場合はIntでOK。
final class DiffableDataSource: UICollectionViewDiffableDataSource<Int, ItemIdentifier> {
    init(_ collectionView: UICollectionView) {
        super.init(collectionView: collectionView) { collectionView, indexPath, itemIdentifier in
            let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "CollectionViewCell",
                                                          for: indexPath) as! CollectionViewCell
            cell.viewModel = itemIdentifier.viewModel
            return cell
        }
    }
}

// MARK: - Public functions
extension DiffableDataSource {
    func apply(_ items: [ItemIdentifier]) {
        // snapshotの初期化
        var snapshot = NSDiffableDataSourceSnapshot<Int, ItemIdentifier>()
        // sectionがない場合は[0]でOK。
        snapshot.appendSections([0])
        // 一意に定まるようにitemを変えたItemIdentifierを付与する。
        snapshot.appendItems(items)

        DispatchQueue.main.async {
            // 新しく作ったsnapshot(つまり新しいUIの状態)を適用する関数。
            self.apply(snapshot,
                       animatingDifferences: true)
        }
    }
}

// 各identifierはHashableに基づく必要がある。
struct ItemIdentifier: Hashable {
    let viewModel: CollectionViewCellViewModel

    static func == (lhs: ItemIdentifier,
                    rhs: ItemIdentifier) -> Bool {
        lhs.hashValue == rhs.hashValue
    }
    
    func hash(into hasher: inout Hasher) {
        // どの値をhash値とするかを決める(複数の組み合わせ可)
        // ここで決めた値が同じ物同士は同じitemとして扱われる。
        hasher.combine(viewModel.cellText)
    }
}

DiffableDataSourceのメリット

1. 差分更新がとても簡単
通常DataSourceの場合、UIの更新とDataSourceの更新を別々に記述しなくてはなりませんでした。
DiffableDataSourceの場合は、snapshotの更新がUIの更新とDataSourceの更新の両方を担っているため、以下のような記述で問題なく差分更新が記述できます!

DiffableDataSourcePageViewController
final class DiffableDataSourcePageViewController: UIViewController {
    ...
    @IBOutlet var deleteButton: UIButton! {
        didSet {
            deleteButton.addAction(.init {_ in
                // 削除したいアイテムを現状のdataSourceから取得
                guard let deleteItem = self.dataSource.itemIdentifier(for: IndexPath(item: 2, section: 0)) else { return }
                // 現状のsnapshotからitemを削除し、apply
                var snapShot = self.dataSource.snapshot()
                snapShot.deleteItems([deleteItem])
                self.dataSource.apply(snapShot)
            },
                                   for:.touchUpInside)
        }
    }
}

viewModel上のデータとは無関係に削除できます。

2. パフォーマンスが維持できる
reloadData()を使えば、UIとDataSourceの整合性を気にする必要はありませんでした。しかし先述の通り、すべてのitemを書き換えるreloadData()は一つのセルの削除や更新の際にはコスパが悪すぎます。
DiffableDataSourceでは、apply()すると変更のあったセルのみ更新処理を行ってくれるため、アプリのパフォーマンスを維持することができます。

3. コードが複雑になりにくい
通常DataSourceの場合、セルの部分的な更新の際にはUI更新処理を書き、dataSource更新処理を書き、さらにデータを提供してくれるviewModel中の変更処理の記述も行い、、とあちこちを気にする必要がありました。
しかしDiffableDataSourceの場合、どんな更新であってもapply()を記述するだけでできるので、コード量が少なく済みます。

個人的にはDataSourceに準拠して必要な関数の値を埋めるとなぜかデータが表示される通常DataSourceより、こういうUI配置が作りたいな〜という設計図的なsnapshotを都度作り、それをDataSourceに適用すると設計図通りに直してくれるDiffableDataSourceの方が、直感的に処理が理解しやすいような気がしています。

余談

DiffableDataSourceだとこんなに洒落たアニメーションをつけることができます!
c09ee182-23e9-b34d-67fa-bae75999e788.gif

まとめ

  • DiffableDataSourceは従来のDataSourceの差分更新を改良したもの。
  • DiffableDataSourceはより簡潔に、そして直感的にセルのデータを更新でき、パフォーマンス改善も期待できる。

終わりに

もう時代はSwiftUIに置き換わりつつありますが、UIKitでcollectionViewやtableViewを使う際には間違いなく触ることになると思います。
初学者の自分のような方にとって、こちらの記事が少しでも参考になりましたら嬉しいです!
まだ勉強中ですので、間違い、ご指摘等ございましたら是非お願い致します。

明日は同じ部署でいつも頼りにさせていただいている@sugiy さんです。よろしくお願いします!

参考文献

https://developer.apple.com/videos/play/wwdc2019/220/
https://ali-akhtar.medium.com/uitableview-diffable-datasource-part-1-13a24e8f23d8

4
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
2