はじめに
本記事では、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にする必要がありますが、そうすると差分が大きくなる問題がありました。
またAppleのWWDCの動画にも下記のように話しているので、適切でないロジックと判断しました。
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で気軽にお話ししましょう!