iOS App Performanceの改善を行いました(2)- DiffableDataSource

はじめに

こんにちは アプリ開発チームの@レイです。

本記事では、iOS App Performanceの改善を行いました(1) - Image Memory Cache - Retty Tech Blogの続きとして、 約1ヶ月間で行ったRettyのiOSアプリパフォーマンスの改善の「DiffableDataSourceを導入した」という話をしようと思います。

流れとしては前回と同じように

  • 今回の改善の必要性について
  • どのような改善をしたのか
  • どのような効果があったのか

という内容で紹介していこうと思います

DiffableDataSourceの導入の背景

今回の改善では、”店舗詳細をレンダリングするまでの時間を減らす”ということを目指していました。
そのためにはリストのロジックを改善する必要がありました。
改善前の問題点としては下記のようなCellBinderタイプを利用し、データが更新されたら毎回リストのSectionやrowを作って高さを更新したり、セルに必要な値を渡してたところです。
これは当時「イケてる実装」を目標として開発しましたが、パフォーマンス的にはあまり良くない実装になったためです。

当時Rettyで採用した「イケてる実装」コードの一部

protocol BindableNibCell: AnyObject {
    static var nib: UINib? { get }
    static var reuseIdentifier: String { get }
    static var estimatedHeight: CGFloat { get }
    var height: CGFloat { get }

    associatedtype Value
    func bind(_ value: Value)
}

extension BindableNibCell {
    static func makeBinder(with value: Value) -> CellBinder {
        return CellBinder(cellType: Self.self, value: value)
    }
}

struct CellBinder {
    let nib: UINib?
    let reuseIdentifier: String
    let configureCell: (UITableViewCell) -> Void
    let estimatedHeight: CGFloat
    let height: (UITableViewCell) -> CGFloat

    fileprivate init<Cell: BindableNibCell>(cellType: Cell.Type, value: Cell.Value) {
        nib = cellType.nib
        reuseIdentifier = cellType.reuseIdentifier
        estimatedHeight = cellType.estimatedHeight
        configureCell = { cell in
            guard let cell = cell as? Cell else {
                fatalError("Could not cast UICollectionView cell to \(Cell.self)")
            }
            cell.bind(value)
        }
        height = { cell in
            guard let cell = cell as? Cell else {
                return UITableView.automaticDimension
            }
            return cell.height
        }
    }

    fileprivate func height(tableview: UITableView) -> CGFloat {
        guard let cell = tableview.dequeueReusableCell(withIdentifier: reuseIdentifier) else {
            return UITableView.automaticDimension
        }
        return height(cell)
    }
}

しかし、現在はここにある機能はほとんど標準APIでもできるようになりましたし、 データやセルの高さの更新のために毎回テーブルをリロードするのはレンダリングが遅くなる原因となるため、 今回はiOS13から新しくできたDiffableDataSourceを導入し、ロジックの改善を行いました。

DiffableDataSourceを導入することで悩んだこと

DiffableDataSourceを作るにはSectionとRowのタイプが必要になります

方法としては下記のように、3点ほどあります。

① タイプを<section, item>のようにItemに各Rowで必要なデータを入れる という方法がありました。
しかしこの方法を採用するには全てのデータのタイプをhashableにする必要がありますが、そうすると差分が大きくなる問題がありました。
またAppleWWDCの動画にも下記のように話しているので、適切でないロジックと判断しました。 https://developer.apple.com/videos/play/wwdc2021/10252/?time=106

DiffableDataSource is build to store identifiers of items in your model, and not the model objects themselves

そこで② <section, id>のようにRowをidごとに表示して、各Rowの中で必要な場合TableViewをまた追加するという方法もありました。 しかしここ方法だと最低サポートバージョンがiOS15だったら新しくできた dataSource.sectionIdentifier(for: Int) APIを利用すると各SectionにRowを入れる実装が可能になりますが、
Rettyの場合まだ最低サポートバージョンがiOS14だっだため、この方法だとItemがどのSectionのRowなのか確認する方法がありませんでした。 一応idを1つのsectionを指すように迂回的な方法もありますが、問題はもし1つのSectionでいくつかのRowを表示する場合には、1つのRowの中で再びTableViewを作って表示し、そのtableViewの高さ更新のために再びセルを作るしかないため非効率的だと判断しました。

そのため③ <section, rowId>のようにRowIdごとに各Rowを表示する 方法を考えました。 各セクションで必要なrowを管理するため、最も簡単で、既存のロジックと大きく変わらないと判断しました。

上記の理由から各データのidではなくRowIndexを利用してDataSourceを実装する③のロジックが現在のRettyの状況に最も適していると判断しました。

店舗詳細でDiffableDataSourceを導入

1. DiffableDataSourceのタイプに入れるSectionとRowの指定

diffableDataSourceを入れる前に、各必要なセルのSectionやRowの指定を下記のように追加しました。

DiffableDataSourceのためのSectionとRowを定義

extension RestaurantViewController {
    enum RestaurantSection: Int, CaseIterable {
        case topImage
        case name
        case buttons
        case addOriginalList
        case redirect
        case calendar
        case catchCopy
        case menu
        case horizontalRestaurantPhoto
        case course
        case news
        case otherReport
        case report
        case staff
        case recommendRestaurant
        case edit
        case browsingHistory
    }

    enum RestaurantRowId: Hashable {
        case topImage
        case name
        case buttons
        case addOriginalList
        case redirect
        case calendar
        case catchCopy
        case menu
        case horizontalRestaurantPhoto

        case courseHeader
        case course(Int)
        case courseMore

        case newsHeader
        case news
        case newsMore

        case reportHeader
        case report(Int)
        case noReport
        case reportMore
        case staff
        case recommendRestaurant
        case edit
        case browsingHistory
    }
}

2. DiffableDataSourceを作る

上記のようにDiffableDataSourceを作っています。

店舗詳細のDiffableDataSourceの一部

private lazy var dataSource: UITableViewDiffableDataSource<RestaurantSection, RestaurantRowId> = .init(
        tableView: self.reportTableView
    ) { [weak self] (tableView: UITableView, indexPath: IndexPath, rowId: RestaurantRowId) -> UITableViewCell in
        guard let self = self else {
            return UITableViewCell()
        }

        switch rowId {
        case .topImage:
            let cell = tableView.dequeueReusableCell(withType: RestaurantTopImageTableCell.self, for: indexPath)
                        ・・・
            cell.bind(値)
            return cell
        case .name:
            let cell = tableView.dequeueReusableCell(withType: UIHostingCell<RestaurantTopView>.self, for: indexPath)
                        ・・・
            cell.bind(値)
            return cell
        case .menu:
            let cell = tableView.dequeueReusableCell(withType: RestaurantMenuCell.self, for: indexPath)
                        ・・・
            cell.bind(値)
            return cell
        case .horizontalRestaurantPhoto:
            let photoListData = self.restaurantRowData.horizontalPhotoListData
                        ・・・
            cell.bind(値)
            return cell
        case .buttons:
            let cell = tableView.dequeueReusableCell(withType: RestaurantButtonsCell.self, for: indexPath)
                        ・・・
            cell.bind(値)
            return cell
        case .reportHeader:
            let cell = tableView.dequeueReusableCell(withType: RestaurantHeaderReportCell.self, for: indexPath)
                        ・・・
            return cell
        case let .report(index):
            let cell = tableView.dequeueReusableCell(withType: RestaurantReportCell.self, for: indexPath)
                        ・・・
            cell.bind(値)
            return cell
        case .noReport:
            let cell = tableView.dequeueReusableCell(withType: RestaurantNoReportCell.self, for: indexPath)
                        ・・・
            cell.bind(値)
            return cell
        case .reportMore:
            let cell = tableView.dequeueReusableCell(withType: RestaurantReportMoreLoadCell.self, for: indexPath)
                        ・・・
            cell.bind(値)
            return cell

                ・・・

        default:
            return UITableViewCell()
        }
    }

上記のようにDiffableDataSourceを作っています。 ここでは昔のUITableViewのcellForRowAt APIと同じようなことをやっています。 特徴としては

case let .report(index):

上記のコードのように各rowのindexをitemとして持っています。

例えば、店舗詳細の投稿は1つではなく複数の投稿を表示しています。
なので、複数のセルを表示する必要があり、どのrowのitemなのかを分かる必要があるため、各rowごとにindexを渡すようにしています。

3. viewDidLoad

Sectionがちゃんと並び順で表示できるようにするため、viewDidLoadで一回dataSourceにappendSectionsをやっています

viewDidLoadの一部

var snapshot = dataSource.snapshot()
snapshot.appendSections(RestaurantSection.allCases)
dataSource.applySnapshot(snapshot, animated: false)

4. データ更新

RettyアプリではReSwiftを導入しているので、データの更新はnewStateで行なっています。
なので、newStateの中で各データごとにセルを追加したり、削除するなどの更新を行います。

リスト更新のため、DataSourceのsnapshotに必要なデータを入れるコード

func newState(state: StoreSubscriberStateType) {
        DispatchQueue.global().async { [weak self] in
            guard let self = self else {
                return
            }

            var snapshot = self.dataSource.snapshot()

                        /*
                               セルに必要なデータ入れるなどを行う
                       */

            let topImageId: RestaurantRowId = .topImage
            if RestaurantTopImageTableCell.isBindable(restaurantTopImages: restaurant.topImages, topImages: restaurantViewImages.topImages) {
                self.restaurantRowData.topImageRowData = restaurantViewImages
                if snapshot.itemIdentifiers(inSection: .topImage).contains(topImageId) {
                    if #available(iOS 15.0, *) {
                        snapshot.reconfigureItems([topImageId])
                    } else {
                        snapshot.reloadItems([topImageId])
                    }
                } else {
                    snapshot.appendItems([topImageId], toSection: .topImage)
                }
            } else {
                if snapshot.itemIdentifiers(inSection: .topImage).contains(topImageId) {
                    snapshot.deleteItems([topImageId])
                }
            }

            
                        /*
                               セルに必要なデータ入れるなどを行う
                       */

            let nameId: RestaurantRowId = .name
            if snapshot.itemIdentifiers(inSection: .name).contains(nameId) {
                if #available(iOS 15.0, *) {
                    snapshot.reconfigureItems([nameId])
                } else {
                    snapshot.reloadItems([nameId])
                }
            } else {
                snapshot.appendItems([nameId], toSection: .name)
            }

                        /*
                               セルに必要なデータ入れるなどを行う
                       */


・・・

                    /*
                               セルに必要なデータ入れるなどを行う
                       */

            let noReportId: RestaurantRowId = .noReport
            if !reportData.isEmpty {
                if snapshot.itemIdentifiers(inSection: .report).contains(noReportId) {
                    snapshot.deleteItems([noReportId])
                }
                        /*
                                       セルに必要なデータ入れるなどを行う
                               */

                let reportHeaderId: RestaurantRowId = .reportHeader
                if !snapshot.itemIdentifiers(inSection: .report).contains(reportHeaderId) {
                    snapshot.appendItems([reportHeaderId], toSection: .report)
                }

                let indexes = self.restaurantRowData.reportsRowData.enumerated().map { $0.offset }
                let reportIds: [RestaurantRowId] = indexes.map { RestaurantRowId.report($0) }
                for reportId in reportIds {
                    if snapshot.itemIdentifiers(inSection: .report).contains(reportId) {
                        if #available(iOS 15.0, *) {
                            snapshot.reconfigureItems([reportId])
                        } else {
                            snapshot.reloadItems([reportId])
                        }
                    } else {
                        snapshot.appendItems([reportId], toSection: .report)
                    }
                }
                let oldNumberOfReports: Int = beforeReportsData.count,
                    newNumberOfReports: Int = reportData.count,
                    diff = oldNumberOfReports - newNumberOfReports
                if diff > 0 {
                    let removedOffsets = (1 ..< diff).map { RestaurantRowId.report(newNumberOfReports + $0) }
                    snapshot.deleteItems(removedOffsets)
                }

                let reportMoreId: RestaurantRowId = .reportMore
                if indexes.count >= self.maxRestaurantReportCount {
                    if !snapshot.itemIdentifiers(inSection: .report).contains(reportMoreId) {
                        snapshot.appendItems([reportMoreId], toSection: .report)
                    }
                } else {
                    if snapshot.itemIdentifiers(inSection: .report).contains(reportMoreId) {
                        snapshot.deleteItems([reportMoreId])
                    }
                }
            } else if reportData.isEmpty {
                let beforeReport = self.restaurantRowData.reportsRowData
                if !beforeReport.isEmpty {
                    let removedReportIds = beforeReport.enumerated().map { RestaurantRowId.report($0.offset) }
                    for removedReportId in removedReportIds {
                        if snapshot.itemIdentifiers(inSection: .report).contains(removedReportId) {
                            snapshot.deleteItems([removedReportId])
                        }
                    }
                    self.restaurantRowData.reportsRowData = []
                }

                let reportHeaderId: RestaurantRowId = .reportHeader
                if !snapshot.itemIdentifiers(inSection: .report).contains(reportHeaderId) {
                    snapshot.appendItems([reportHeaderId], toSection: .report)
                }

                if !snapshot.itemIdentifiers(inSection: .report).contains(noReportId) {
                    snapshot.appendItems([noReportId], toSection: .report)
                }
            }
・・・

            DispatchQueue.main.async { [weak self] in
                guard let self = self else {
                    return
                }
                self.dataSource.applySnapshot(snapshot, animated: false)
                if restaurant.restaurantStatus != nil {
                    self.delegate?.removeLoadingView()
                }
            }
        }
}

上記のように各セルに必要な情報を入れたりセルを表示するためのdataSourceにアイテムを入れたりしています。

「2. DiffableDataSourceを作る」に書いた、各rowのindexを渡す部分については以下のように各データのoffsetを持つようにしています。

let indexes = self.restaurantRowData.reportsRowData.enumerated().map { $0.offset }
let reportIds: [RestaurantRowId] = indexes.map { RestaurantRowId.report($0) }

あとはリアルタイムで投稿数などが変わる場合もありますが、更新されたアイテムが既存より少ない場合、その差のindexを削除するようにしています。

今後改善するもの

1. 今後最低サポートバージョンが iOS 15になったら

「DiffableDataSourceを導入することで悩んだこと」で紹介した通り、dataSource.sectionIdentifier(for: Int) APIが使えるので、これを利用してロジックをもう少しきれいにしたいと思います

2. DataSourceの整理

今は店舗詳細のViewControllerと同じファイルでDiffableDataSourceを作っているので、
これを別のファイルでNSObjectとして管理するようにしようと思っています。

3. 他の画面でもDiffableDataSourceを導入する

今はまだお知らせ画面と店舗詳細画面しかDiffableDataSourceを作っているので、他の画面でも使えるようにしようとしています。

効果

データの更新や高さの更新などのロジックが効率的に動くようになり、リストを表示するまでの時間が以前より短くなりました🚀

前のバージョンとの比較動画


おわりに

今回の記事ではパフォーマンス改善のための第2STEPとして、店舗詳細でDiffableDataSourceを導入した話についてご紹介しました。

導入した結果、店舗詳細の画面がとても軽くなり、 店舗詳細がより早く開けるようになりました!

Rettyアプリチームでは、今後もこのような改善を続け、より良いアプリとなるように努力したいと考えています。🚀

アプリ開発チームのメンバーとMeetyでお話しできます!本記事でRettyのアプリ開発チームについて興味が湧きましたら、ぜひMeetyで気軽にお話ししましょう!

meety.net