UICollectionViewDiffableDataSourceのRuntime Errorによる危険の可能性を取り除く
はじめに
こんにちは。最近、会社のProjectで利用していたRxDataSource
を切るため、UICollectionViewDiffableDataSource
を導入していたところ、ちょくちょくRuntime Errorで詰まりました。最初のほうは、すぐにFixできたのであまり気にならなかったのですが、サーバーサイドなどのデータのやり取りが絡むと、気づかないうちにクラッシュが起こるようになったりする可能性もあります。
みなさんがそのような不都合に当たらないよう、一応このように記事に残しておくことにしました。ぜひ皆さんにもお役に立てればな、と思っています。
利用バージョン等について
この記事の内容は、以下のバージョンのツールをもとに検証・作成されています。
- Xcode 12.4
- iOS Simulator 14.4
対象・事前知識
UICollectionViewDiffableDataSource
の利用方法を予めさらっている方が対象です。もしそうでない方は、以下の記事などを参考に利用してから記事を読んでいただけると幸いです。
冒頭でもでてきた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にデータを反映する際は、次のようにセクションの情報とともにdataSource
のapply
を利用します。これで、データ反映は完了です。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つ目のItem
のid
を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
型を作ったとして、こちらのid
でHashable
がそれぞれのItem
オブジェクトを識別しているとします。[1:1]
struct Item: Hashable {
var id: Int
}
もし、id
がかぶっているItem
がもう一つNSDiffableDataSourceSnapshot
にappendItems
されたら、↑のルールに反すことになり内部で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
}
さて、実際にItem
とSection
を仕様通り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
のようなライブラリがまだ利用しやすいかもしれません。
Discussion