【Swift】UICollectionViewの更新処理には段階がある

こんにちは、 [juginon](https://twitter.com/juginon)です。
Swift備忘録シリーズ3、今回はUICollectionViewの更新処理についてです（他シリーズは[zenn](https://zenn.dev/juginon)参照）。

## Index out of range
UICollectionViewで引き起こしやすいエラーに `Index out of range` があります。
その発生原因は様々ありますが、一般的には以下のような原因であることが多いです。

1. **データの不整合**
collectionViewのdataSourceの配列に要素を追加/削除/変更した場合に、その変更が正しく反映されず、indexPathが期待通りのものと異なるためエラーが起きる
1. **レイアウトの不整合**
collectionViewのセルの数や位置を変更すると、変更前のindexPathを参照してしまっている場合エラーが起きる
1. **Viewの再描画**
collectionViewがデバイスの向きの変更やアプリの状態が変わった場合に再描画され、indexPathが期待通りではなくなりエラーが起きる
1. **上記に関連するロジックのバグ**
indexPathが参照する部分のロジックにバグがあり、indexPathが期待の範囲を超えた部分を参照してしまいエラーが起きる

## 実際のコードでクラッシュ原因を追う

以下のコードは、collectionViewの更新部分を実装していた実際のコードを簡略化したものです。
このcollectionViewでは再現方法がわかっていない `Index out of range` が発生しており、その原因を調査していました。

```Swift:ViewController.swift
// 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が追加読み込みされる

<img width="400" alt="collectionViewのイメージ" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/513327/6fd66ac4-ec0b-8d58-8be4-283b57d382ff.jpeg">

このとき、数十回に1回くらいの頻度で以下の操作を行うとクラッシュが再現できました。

1. filterボタンを押し `load(initial: true)` を呼び、API通信を行う（collectionViewはリロード中のセルを表示）
1. リロード中のセルが表示されている間に Pull to Refresh を行う
1. （クラッシュする場合は）リロード中のセルが表示されたまま固まり、 `Index out of range`

![名称未設定3 (1)_page-0001.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/513327/175b2f51-ba2d-7229-8eaf-bee0d4121bcc.jpeg)

## クラッシュの原因

ログで `Index out of range` が起きた際のindexPathを確認したところ、不具合が起きる原因は以下のような状況が生まれていたためでした。

- ②のreloadData()によるcellForItemAtが呼ばれる前に、①のbuildCollectionViewData()によってdataSourceが変わり、読み取るデータに不整合が生まれる

```Swift: ViewController.swift
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の表示を更新する際には以下のような段階が存在します。

1. `numberOfSections(in:)` や `collectionView(_:numberOfItemsInSection)` などが `reloadData()` から呼ばれ、セクション数やセクション内のアイテム数を変更する（ここで `reloadData()` 自体は終わり）
1. セクション数やセクション内アイテムの変更を検知し、`layoutSubviews()` が呼ばれ`collectionView(_:cellForItemAt:)` などのViewのレイアウトに関するメソッドが呼ばれ再レイアウトが行われる

![名称未設定4_page-0001.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/513327/a1169621-dd3e-2c29-77a5-1969681be80d.jpeg)

つまり、上記を踏まえてクラッシュの原因を改めて考え直すと、以下のような状況であることがわかります。

1. `fetchData()` 内の `reloadData()` が呼ばれ、セクション数やセクション内アイテム数が変更される（赤枠部分）
1. `fetchData()` 内の再レイアウト（青枠部分）が行われる前に、 `initial` 内の `reloadData()` が行われセクション数やセクション内アイテム数が再び変更される
1. `fetchData()` 内の再レイアウトを行おうとするが、dataSourceは既に `initial` 内の `reloadData()` によって置き換わっており、クラッシュが発生

## 解決方法

上記のような状況が生まれてしまう場合、根本のデータ更新の処理を見直し、 `reloadData()` が行われてから再レイアウトが行われるまでの間に余計なデータ変更が入らないようにする必要があります。

ただ、コードの状況的に上記のような修正が難しい場合、強制的にレイアウトの更新をデータ変更の前に行うことで解決することができます。

明示的にレイアウトの更新を行うには、`setNeedsLayout()` と `layoutIfNeeded()` を `reloadData()` の直後に置きます。

```Swift
collectionView.reloadData()
self.view.setNeedsLayout()
self.view.layoutIfNeeded()
```

上記はあくまで簡単な解決方法の一つであり、クラッシュの原因を突き止めるための手段として使うことをおすすめします（再レイアウトが行われるまでの間に余計なデータ変更が入らないように根本を修正する方が望ましいです）。

## おわりに

Index out of rangeはcollectionViewを使っていればほぼ必ず遭遇する不具合ですが、データとレイアウトの更新の流れをしっかり把握していないと解決するのが難しい場合もあるんだなと思いました。
クラッシュの原因を追っていく中でcollectionViewの更新周りについての知識が増えたのは一つの収穫ですね。
この記事がIndex out of range解決の一つのヒントになれば幸いです。