UICollectionViewやUITableViewのreloadDataを呼ぶ必要はほとんどない

この記事は eureka Native Advent Calendar 2017 – Qiita の4日目の記事です。

Pairs Global事業部のiOSエンジニアのmuukiiです 🤠

eureka Native Advent Calendar 2017 1日目の記事 にてPairs iOSアプリのGlobal版では、ほとんどDBを使用していないという記事を書きました。

DBを使用しないことでデータの生成・破棄などの管理は楽になります。ですが、その代わりにRealmやCoreDataが提供しているデータ変更の通知やInsert, Delete, Updateの具体的な変更を知ることができなくなります。
データの集合にどのような変更があったのかを知ることが出来なければ、UICollectionViewやUITableViewに表示しているデータに変更があった場合やアイテムの追加・削除を行った際にreloadDataを使った更新しかできなくなってしまいます。
これではパフォーマンスも良くないですし、画面がチラつく原因となってしまいます。

理想的な実装は、更新が必要なデータを特定し、適切にUICollectionViewやUITableViewに対し部分更新のAPIを呼び出すことです。

今回の話題に関して、UITableViewとUICollectionViewで差はほとんどないので、ここから先はUICollectionViewにフォーカスして話します。

UICollectionViewは次のような部分的にアイテムを更新するAPIを持っています。

func insertItems(at indexPaths: [IndexPath])
func moveItem(at indexPath: IndexPath, to newIndexPath: IndexPath)
func deleteItems(at indexPaths: [IndexPath])

func insertSections(_ sections: IndexSet)
func moveSection(_ section: Int, toSection newSection: Int)
func deleteSections(_ sections: IndexSet)

これらを呼び出だすことでreloadDataより高速にアイテムの更新が行えます。

ですが、私はこれらを適切に呼び出すことには結構難しい印象を持っています。🤔
まず、更新の差分を抽出するアルゴリズムは大変です。
さらにUICollectionViewは繊細で、正確にデータとUIの更新を行う必要があります。
insert, move, deleteを呼び出してassertされてしまうことはよくありました。 😱

このように滑らかに更新できると気持ち良いですよね😆 reloadDataではこれは難しいです😢

安全に更新してくれる仕組みを作る🚀

私は、データが入った配列をもとに、配列の内容の変更に応じてUICollectionViewを安全に更新するためにmuukii/DataSourcesというライブラリを作りました。

DataSourcesはUICollectionView, UITableView, そして ASCollectionNode(Texture) にも対応しています。

RxDataSourcesやIGListKitを参考に🙏

DataSourcesの開発にあたって、いくつかのライブラリを参考にしました。
代表的と言えそうなライブラリとして、RxDataSourcesIGListKitがありますが、
PairsのGlobal版iOSアプリはTextureが持つASCollectionNodeを利用しているため、接続に苦労することもありました。

Diffアルゴリズムの選定

配列の差分を抽出し、UICollectionView, UITableViewを更新するライブラリ、またそれをサポートする差分抽出アルゴリズムのライブラリはいくつか公開されています。

各ライブラリが採用しているアルゴリズムによって次のようなトレードオフが存在しています。

  • 多次元配列によるSectionを表現した配列の差分には対応せずに高速化 (IGListKit)
  • データがユニークIDを持ち、配列内に重複がない前提でSectionの表現を可能にしつつ高速化 (RxDataSources)

DataSourcesではIGListKitと同じアルゴリズムを選択しています。

DataSourcesの使い方

差分計算が可能なデータを定義する

まずはUICollectionViewに表示するアイテムとして使用するModelの準備です。
差分計算を行うためにModelにはDiffableを実装する必要があります。

public protocol Diffable {
  associatedtype Identifier : Hashable
  var diffIdentifier: Identifier { get }
}

diffIdentifierはModelが持つユニークとなるIDを使用すると良いです。😬

struct Model : Diffable {

  var diffIdentifier: String {
    return id
  }

  let id: String
}

例としてModelオブジェクトを定義します。

表示する画面の準備

UICollectionViewに表示するデータを保持しておく役割としてSectionDataControllerがあります。

class ViewController : UIViewController {

  let collectionView: UICollectionView
  var models: [Model] = […] {
    didSet {
      // modelsが変更されたら更新処理を呼び出す
      sectionDataController.update(items: items, updateMode: .partial(animated: true), completion: {
        // 更新完了通知
      })
    }
  }

  lazy var sectionDataController = SectionDataController<Model, CollectionViewAdapter>(
    adapter: CollectionViewAdapter(collectionView: self.collectionView),
    isEqual: { $0.id == $1.id } // ModelのEqual判定
  )
}

SectionDataControllerUICollectionViewへinsert, delete, updateを要求し、それらが安全に実行されるようにデータを正しい状態で保持します。

SectionDataControllerのinitの引数であるisEqualはModelが同じものであるかを判定するために使用されます。
これが必要な理由は、UICollectionViewに対し、insertでもdeleteでもなくupdateであると判断するためです。
例えばmodels内において、diffIdentifierで比べた時には同じ位置に存在しているが、isEqualfalseの場合、UICollectionViewupdateを要求します。

SectionDataControllerからUICollectionViewにデータを返却する

最後にUICollectionViewDataSourceを実装します。
データへのアクセスはSectionDataControllerで行います。

extension ViewController : UICollectionViewDataSource {
  func numberOfSections(in collectionView: UICollectionView) -> Int {
    return 1
  }

  func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
    return sectionDataController.numberOfItems()
  }

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

    let model: Model = sectionDataController.item(for: indexPath)

    ...
  }
}

準備はこれで完了です。
modelsの変更に合わせてUICollectionViewが自動的に更新されていきますよ ✨

おわりに

PairsのGlobal版iOSアプリはTextureに加え、DataSourcesによってアイテムの更新を効率よく、見た目も綺麗に動作させることに成功しています。また、reloadDataを一度も実行していないこともポイントです。

今回、このライブラリを作るにあたって、IGListKitやRxDataSourcesを研究していましたが、
RxDataSourcesのSectionを跨いだ差分抽出アルゴリズムはすごいものでした。「これはRxに依存したままではもったいない!」と思い、アルゴリズムだけModuleに切り出すPRを出したものの、途中で疲れて放置してしまったのですがいつの間にかマージされていました。ありがたい!

このPRです https://github.com/RxSwiftCommunity/RxDataSources/pull/154

DataSourcesのリポジトリはこちらです✨
https://github.com/muukii/DataSources

  • このエントリーをはてなブックマークに追加

エウレカでは、一緒に働いていただける方を絶賛募集中です。募集中の職種はこちらからご確認ください!皆様のエントリーをお待ちしております!

Recommend

エウレカという組織で、スクラムがチームに起こした変化

【イベントレポート】golang.tokyo#1にCTO金子が登壇いたしました!