11
10

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

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

Posted at

こんにちは、 juginonです。
Swift備忘録シリーズ3、今回はUICollectionViewの更新処理についてです(他シリーズはzenn参照)。

Index out of range

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

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

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

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

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が追加読み込みされる
collectionViewのイメージ

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

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

名称未設定3 (1)_page-0001.jpg

クラッシュの原因

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

  • ②のreloadData()によるcellForItemAtが呼ばれる前に、①のbuildCollectionViewData()によってdataSourceが変わり、読み取るデータに不整合が生まれる
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() 自体は終わり)
  2. セクション数やセクション内アイテムの変更を検知し、layoutSubviews() が呼ばれcollectionView(_:cellForItemAt:) などのViewのレイアウトに関するメソッドが呼ばれ再レイアウトが行われる

名称未設定4_page-0001.jpg

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

  1. fetchData() 内の reloadData() が呼ばれ、セクション数やセクション内アイテム数が変更される(赤枠部分)
  2. fetchData() 内の再レイアウト(青枠部分)が行われる前に、 initial 内の reloadData() が行われセクション数やセクション内アイテム数が再び変更される
  3. 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解決の一つのヒントになれば幸いです。

11
10
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
11
10

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?