こんにちは、 juginonです。
Swift備忘録シリーズ3、今回はUICollectionViewの更新処理についてです(他シリーズはzenn参照)。
Index out of range
UICollectionViewで引き起こしやすいエラーに Index out of range
があります。
その発生原因は様々ありますが、一般的には以下のような原因であることが多いです。
-
データの不整合
collectionViewのdataSourceの配列に要素を追加/削除/変更した場合に、その変更が正しく反映されず、indexPathが期待通りのものと異なるためエラーが起きる -
レイアウトの不整合
collectionViewのセルの数や位置を変更すると、変更前のindexPathを参照してしまっている場合エラーが起きる -
Viewの再描画
collectionViewがデバイスの向きの変更やアプリの状態が変わった場合に再描画され、indexPathが期待通りではなくなりエラーが起きる -
上記に関連するロジックのバグ
indexPathが参照する部分のロジックにバグがあり、indexPathが期待の範囲を超えた部分を参照してしまいエラーが起きる
実際のコードでクラッシュ原因を追う
以下のコードは、collectionViewの更新部分を実装していた実際のコードを簡略化したものです。
このcollectionViewでは再現方法がわかっていない Index out of range
が発生しており、その原因を調査していました。
// Pull to Refresh時は refreshControl != nil
private func load(initial: Bool, refreshControl: UIRefreshControl? = nil) {
// 差分更新でないリロードの場合、initial = true
if initial {
// 既に設定されているViewModelの状態をリセットする
viewModel.reset()
// dataSourceに応じてセルを作成する
viewModel.buildCollectionViewData()
// collectionViewを更新
collectionView.reloadData()
}
// fetchDataでAPI通信を行う
viewModel.fetchData(completion: { [weak self] result in
guard let self = self else { return }
refreshControl?.endRefreshing()
switch result {
case .success(let result):
self.viewModel.buildCollectionViewData()
collectionView.reloadData()
case .failure(let error):
// 失敗時はdataSourceにエラーメッセージを入れ更新する
self.collectionViewDataManager.sections = [
CollectionViewDataManager.buildMessageSection(message: error.localizedDescription)
]
self.viewModel.buildCollectionViewData()
collectionView.reloadData()
}
})
}
上記のコードで実装されているUIは以下のような構成をしています。
- collectionViewの中にはcellの絞り込みを行うfilterボタンがあり、filterボタンを押すと
load(initial: true)
が呼び出される - UIRefreshControlをwrapした独自実装のRefreshControlがあり、collectionViewを下に引っ張ると
load(initial: true, refreshControl)
が呼び出される - collectionViewは一定のcellを最初に読み込み、下にスクロールすると
load(initial: false)
が呼ばれ、cellが追加読み込みされる
このとき、数十回に1回くらいの頻度で以下の操作を行うとクラッシュが再現できました。
- filterボタンを押し
load(initial: true)
を呼び、API通信を行う(collectionViewはリロード中のセルを表示) - リロード中のセルが表示されている間に Pull to Refresh を行う
- (クラッシュする場合は)リロード中のセルが表示されたまま固まり、
Index out of range
クラッシュの原因
ログで Index out of range
が起きた際のindexPathを確認したところ、不具合が起きる原因は以下のような状況が生まれていたためでした。
- ②のreloadData()によるcellForItemAtが呼ばれる前に、①のbuildCollectionViewData()によってdataSourceが変わり、読み取るデータに不整合が生まれる
private func load(initial: Bool, refreshControl: UIRefreshControl? = nil) {
if initial {
viewModel.reset()
① viewModel.buildCollectionViewData()
collectionView.reloadData()
}
viewModel.fetchData(completion: { [weak self] result in
guard let self = self else { return }
refreshControl?.endRefreshing()
switch result {
case .success(let result):
self.viewModel.buildCollectionViewData()
② collectionView.reloadData()
case .failure(let error):
...
}
})
}
こういった処理の順番が前後する動作をする場合はどちらかの処理がバックグラウンドスレッドで行われていることが考えられますが、調査したところ両方の buildCollectionViewData()
はどちらもメインスレッドで実行されており、かつクラッシュが起きる時と起きない時でスタックトレースに違いはありませんでした。
では、なぜ上記のような動作が実現できてしまうのでしょうか?
UICollectionViewの更新処理には段階がある
collectionView.reloadData()
は1行の処理になっていますが、実際のcollectionViewの表示を更新する際には以下のような段階が存在します。
-
numberOfSections(in:)
やcollectionView(_:numberOfItemsInSection)
などがreloadData()
から呼ばれ、セクション数やセクション内のアイテム数を変更する(ここでreloadData()
自体は終わり) - セクション数やセクション内アイテムの変更を検知し、
layoutSubviews()
が呼ばれcollectionView(_:cellForItemAt:)
などのViewのレイアウトに関するメソッドが呼ばれ再レイアウトが行われる
つまり、上記を踏まえてクラッシュの原因を改めて考え直すと、以下のような状況であることがわかります。
-
fetchData()
内のreloadData()
が呼ばれ、セクション数やセクション内アイテム数が変更される(赤枠部分) -
fetchData()
内の再レイアウト(青枠部分)が行われる前に、initial
内のreloadData()
が行われセクション数やセクション内アイテム数が再び変更される -
fetchData()
内の再レイアウトを行おうとするが、dataSourceは既にinitial
内のreloadData()
によって置き換わっており、クラッシュが発生
解決方法
上記のような状況が生まれてしまう場合、根本のデータ更新の処理を見直し、 reloadData()
が行われてから再レイアウトが行われるまでの間に余計なデータ変更が入らないようにする必要があります。
ただ、コードの状況的に上記のような修正が難しい場合、強制的にレイアウトの更新をデータ変更の前に行うことで解決することができます。
明示的にレイアウトの更新を行うには、setNeedsLayout()
と layoutIfNeeded()
を reloadData()
の直後に置きます。
collectionView.reloadData()
self.view.setNeedsLayout()
self.view.layoutIfNeeded()
上記はあくまで簡単な解決方法の一つであり、クラッシュの原因を突き止めるための手段として使うことをおすすめします(再レイアウトが行われるまでの間に余計なデータ変更が入らないように根本を修正する方が望ましいです)。
おわりに
Index out of rangeはcollectionViewを使っていればほぼ必ず遭遇する不具合ですが、データとレイアウトの更新の流れをしっかり把握していないと解決するのが難しい場合もあるんだなと思いました。
クラッシュの原因を追っていく中でcollectionViewの更新周りについての知識が増えたのは一つの収穫ですね。
この記事がIndex out of range解決の一つのヒントになれば幸いです。