📚

UICollectionViewDiffableDataSourceのRuntime Errorによる危険の可能性を取り除く

2021/05/21に公開

はじめに

こんにちは。最近、会社のProjectで利用していたRxDataSourceを切るため、UICollectionViewDiffableDataSourceを導入していたところ、ちょくちょくRuntime Errorで詰まりました。最初のほうは、すぐにFixできたのであまり気にならなかったのですが、サーバーサイドなどのデータのやり取りが絡むと、気づかないうちにクラッシュが起こるようになったりする可能性もあります。

みなさんがそのような不都合に当たらないよう、一応このように記事に残しておくことにしました。ぜひ皆さんにもお役に立てればな、と思っています。

利用バージョン等について

この記事の内容は、以下のバージョンのツールをもとに検証・作成されています。

- Xcode 12.4
- iOS Simulator 14.4

対象・事前知識

UICollectionViewDiffableDataSourceの利用方法を予めさらっている方が対象です。もしそうでない方は、以下の記事などを参考に利用してから記事を読んでいただけると幸いです。
https://techlife.cookpad.com/entry/2020/12/24/130000

冒頭でもでてきたRxDataSourceに関しての知識は必要はありませんが、ちょくちょくUICollectionViewDiffableDataSourceとの違いを挙げられることがあります。

Fatal: supplied identifiers are not unique.

皆さんは、UICollectionViewDiffableDataSourceを利用した際、このRuntime Errorに直面したことはありますか?

これは、UICollectionViewDiffableDataSource<SectionIdentifierType, ItemIdentifierType>型のオブジェクトにデータを適用しようとした際に起こりうる可能性があります。

では、まずはRuntime Errorの再現例を説明します。この問題に直面したこと無い方は、一度どのようにクラッシュが起こるかを知るために知っておいたほうが良いと思います。

再現方法を知っている方は次の項目まで読み飛ばしてもらっても構いません。

エラーの再現

さて、ここに、ItemIdentifierType用の Item型を準備します。ItemIdentifierTypeを利用するルール通り、Hashableに準拠して書きます。

内部では、それぞれのItemを識別できるようにするため、id という変数を準備しましょう。コンテンツを表す別の変数などは省きます。[1]

struct Item: Hashable {
   var id: Int
}

こんな感じになりますね。SectionIdentifierType は1つだけcaseを持ったenumで今回は十分なので、そのようなSection型を作成します。こちらもHashableに準拠しましょう。

enum Section: Hashable {
   case list
}

ではこれらの型をもとに、dataSourceオブジェクトを作ります。

let dataSource = UICollectionViewDiffableDataSource<Section, Item>(collectionView: collectionView, cellProvider: ...)

そして、Collection Viewにデータを反映する際は、次のようにセクションの情報とともにdataSourceapplyを利用します。これで、データ反映は完了です。Collection View上にItemが一つだけ表示されると思います。

var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
snapshot.appendSections([.list])
snapshot.appendItems([Item(id: 0)], toSection: .list)
dataSource.apply(snapshot)

では、2つのItemを表示させてみましょう。appendItemsで追加しているItemを一つ増やします。2つ目は1つ目とは別のidを指定しなければなりません。

var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
snapshot.appendSections([.list])
snapshot.appendItems([Item(id: 0), Item(id: 1)], toSection: .list) // id = 1 の`Item`追加
dataSource.apply(snapshot)

これで2つのItemが表示されます。さて、あえてこのコードでRuntime Errorを起こしてみましょう。先程の2つ目のItemidを1つ目と同じにするだけで簡単に起こすことができます。

var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
snapshot.appendSections([.list])
snapshot.appendItems([Item(id: 0), Item(id: 0)], toSection: .list) // id = 0 の`Item`追加
dataSource.apply(snapshot)

すると次のようなRuntime Errorとともにアプリが落ちます。

Fatal: supplied identifiers are not unique.

一体これはどういうことでしょうか?

ItemIdentifierType型のオブジェクトは、すべてそれぞれ別のものとして識別されなければならない

この項目のタイトルにもある通り、DataSourceには「ItemIdentifierType型のオブジェクトはすべてそれぞれ別のものとして識別されなければならない」というルールがあります。これは、特にRxDataSourceから変更しようとした人にとっては困ったことの一つになったかもしれません。

たとえば、ItemIdentifierTypeにあたるItem型を作ったとして、こちらのidHashableがそれぞれのItemオブジェクトを識別しているとします。[1:1]

struct Item: Hashable {
   var id: Int
}

もし、idがかぶっているItemがもう一つNSDiffableDataSourceSnapshotappendItemsされたら、↑のルールに反すことになり内部でFatalErrorが起きてしまいます。

対処法としては、appendする際はそれぞれidに違うものを割り振ることです。RxDataSourceなどの他のライブラリではかぶりは許容されていることが多く、UICollectionViewDiffableDataSourceのこの点はとてもやりにくいですね。

具体的にどういうときにクラッシュの危険があるか?

さて、id に違う値をいれてクラッシュしないようにできました。
しかし、別の場面では様々なユースケースで気をつけなければ、意図しないタイミングで同様のFatal Errorによるクラッシュの危険があると私は考えています。

特にRxDataSourceなどからの移行を考えた人にとって問題になることがあると思います。

たとえば、フォルダを表示するCollection Viewを考えます。Itemは以下のように作ってみます。
案件の仕様通り「表示するフォルダは最大10件までとして、10件に満たない場合、残りは空を示すアイテムを表示したい」という条件で作ってみます。

enum Item: Hashable {
   case folder(Folder) // `Folder`は`Hashable`な前提
   case empty
}

// 一応 Section も作っておく
enum Section: Hashable {
   case folderList
}

さて、実際にItemSectionを仕様通りNSDiffableDataSourceSnapshotに追加してみましょう。例として、一つだけFolderを作ってアイテムとして表示し、残りの9アイテムは空を示すアイテムを表示させます。

var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
snapshot.appendSections([.list])
var items: [Item] = [.folder(Folder(...))] // 一つだけFolderを作る
items += Array(repeating: .empty, count: 9) // あとは全部空のItem

snapshot.appendItems(items, toSection: .list)
dataSource.apply(snapshot)

察した方もいると思いますが、これは先程のRuntime Errorを起こします。
こちらの行に注目してみます。ここでは9つのemptyを入れています。

items += Array(repeating: .empty, count: 9) // あとは全部空のItem

これらのemptyはすべて同じものとして識別されてしまいます。たとえ見た目も内容もすべて同じItemを用意したい場合でも、そのItemもすべて識別可能にしなければなりません。

対処法の一つとしては、emptyにAssociated ValueとしてIntを追加し、それを識別用として利用するという方法があります。これでそれぞれのemptyが識別可能になります。

enum Item: Hashable {
   case folder(Folder)
   case empty(Int)
}

実際に利用する際は、

...
var items: [Item] = [.folder(Folder(...))] // 一つだけFolderを作る

// あとは全部空のItemを埋める
for i in 0..<9 {
    items.append(.empty(i)) 
}
...

このように、Associated Value に違う値を入れるようにしましょう。もしローディングごとに内容を変更したいなどの要件があれば、同じ数字を使いまわすとアイテムが更新されないので、IntではなくUUIDなどを利用してください。

サーバーサイドから得るレスポンスやユーザーの入力のような動的なデータが関連する場合は特に注意

表題の通りですが、もしItemでいうidのような識別のために使われる変数が、様々な要因の上で動的に決まる場合は注意して実装・テストしてください。

たとえば、サーバーからデータを取り入れて表示する際、サーバーサイド側とすりあわせがうまく行われておらず、複数のアイテムの識別用のデータに同じ値が入ってしまうなどの可能性もあります。その場合、本番環境で知らずのうちにクラッシュするなどの危険も出てきます。

対策としては、識別用の変数をUUIDにするのが手っ取り早いと思います。しかし、アイテムのapplyの際、毎度違うUUIDをつけると、意図時には同様のアイテムが別のアイテムとして認識されるかもしれません。これではすべてのアイテムを対象にリロードが走ってしまい、効率も悪くアニメーションに影響するかもしれません。急いでない場合はなるべくUUIDは使わない設計にすることをお勧めします。

まとめ

UICollectionViewDiffableDataSourceで気付かずに引っかかってしまうFatal: supplied identifiers are not unique.の問題と、関連する考えられる危険性について少々まとめてみました。

  • ItemIdentifierType型のオブジェクトは、すべてそれぞれ別のものとして識別されるようにHashableに準拠する
  • サーバーからのレスポンスのような動的なデータが関連する場合、識別用の変数の値がかぶらないように注意する

アイテムのかぶりを許してくれる点では、RxDataSourcesのようなライブラリがまだ利用しやすいかもしれません。

脚注
  1. タイトルといった別のコンテンツ用の変数を用意する際は、idだけを識別するようにhasherなどを実装してください ↩︎ ↩︎

Discussion