Upgrade to Pro — share decks privately, control downloads, hide ads and more …

RxDataSourceをNSDiffableDataSourceへ置き換える際のTips集紹介

 RxDataSourceをNSDiffableDataSourceへ置き換える際のTips集紹介

potatotips #79 iOS/Android開発Tips共有会での登壇資料になります。

iOS13から登場したUICollectionViewCompositionalLayoutとNSDiffableDataSourceの登場により、iOSアプリ開発におけるUICollectionViewを利用したUI実装や機能実装がより安全かつシンプルになりました。

そしてこれより以前では、RxSwiftをベースとしたiOSアプリ開発がトレンドとなっていたタイミングでは、UICollectionViewやUITableViewを利用する実装をする際には、差分更新が考慮に加えて様々な便利機能を備えたRxDataSourceを利用した経験がある方もいらっしゃると思います。

私自身もRxSwiftについてはかれこれ4~5年ほど触れてきた経験があり、丁度元々はRxDataSourceで実装されていた画面をNSDiffableDataSourceの乗せ替え対応をする機会がありました。

今回のスライドについては、この対応及びリファクタリングを進めていくにあたって、ポイントになりそうな部分やしっかりと気を配っていくと良さそうな部分を幾つかのTipsとしてまとめたものになります。

Fumiya Sakai

October 31, 2022
Tweet

More Decks by Fumiya Sakai

Other Decks in Technology

Transcript

  1. 自己紹介 ・Fumiya Sakai ・Freelance App Engineer アカウント: ・Twitter: https://twitter.com/fumiyasac ・Facebook:

    https://www.facebook.com/fumiya.sakai.37 ・Github: https://github.com/fumiyasac ・Qiita: https://qiita.com/fumiyasac@github 発表者: ・Born on September 21, 1984 これまでの歩み: Web Designer 2008 ~ 2010 Web Engineer 2012 ~ 2016 App Engineer 2017 ~ Now iOS / Android / sometimes Flutter
  2. 今回のスライドにつきまして 業務や個人開発でRxDataSourceからDiffableDataSourceへのリプレイスを実施 RxDataSourceを利用した処理を置き換えていく部分を中心にご紹介をできればと思います。 1. RxDataSource&RxSwiftベースの実装においてDiffableDataSource部分だけを置き換えていきたい: RxDataSource&RxSwiftベースでも画面処理には差し支えがないものの、iOS14以降であればDiffableDataSourceでの差分更新は もちろんCellRegistrationを利用したCellの生成処理も活用可能である点は魅力に感じていました。 2. UICollectionViewCompositionalLayoutへの布石とする: 実はDiffableDataSourceを利用していたとしても従来通りのUICollectionViewDelegateLayoutを利用することはできるものの、

    UICollectionViewCompositionalLayoutを利用する方が画面構造的にもシンプルに済ませられるケースも多そうでした。 3. 置き換えを進めていくにあたって考え方を変える必要がある部分もあった: 最初は結構似ているかもしれないと思っていたけれども、しっかりと見ていくと実は意外と落とし穴があって見事にハマった り、想定以上に時間を要してしまった部分もありました(ここは私自身がRxDataSourceをあまり使わなかったため)。
  3. RxDataSource用のSectionType・ItemType定義 SectionType・ItemTypeのcaseがUICollectionViewCellと密接に関係する enum TopSectionType { case banner(title: String) case featured(headerViewObject:

    TopHeaderView.HeaderViewObject) case news(headerViewObject: TopHeaderView.HeaderViewObject) case discount(title: String) case photo(headerViewObject: TopHeaderView.HeaderViewObject) } typealias TopSection = SectionModel<TopSectionType, TopItemType> Banner Header: スクロール可能 Featured Carousel: News: enum TopItemType { case banner(bannerViewObject: TopBannerCell.CellViewObject) case featured(featuredViewObjects: [TopFeaturedCell.CellViewObject]) case news(newsViewObject: TopNewsCell.CellViewObject) case discount(discountViewObject: TopDiscountCell.CellViewObject) case photo(photoViewObject: TopPhotoCell.CellViewObject) } ※HorizontalScroll部分は中にUICollectionViewがある … Cell要素1つ分 let items = BehaviorRelay<[TopSection]>(value: []) 1. Enum定義をしてcase内のassociated valueに表示データを定義する : 2. RxDataSource用のTypealias定義とViewControllerでCell要素とバインドする処理用の変数を準備 :
  4. RxDataSourceで構築された画面処理に関するポイント 必要なSectionModelの内容を決めて、それが変化すると画面に反映される形 ※ Header/Footer要素の構築に関してもconfigureSupplementaryViewの部分で構築する形となる typealias TopSection = SectionModel<TopSectionType, TopItemType> ViewController側におけるCell生成処理の抜粋

    : private lazy var dataSource = RxCollectionViewSectionedReloadDataSource<TopSection>( configureCell: configureCell, configureSupplementaryView: configureSupplementaryView ) private lazy var configureCell: RxCollectionViewSectionedReloadDataSource<TopSection> .ConfigureCell = { [weak self] (_, collectionView, indexPath, itemType) in guard let strongSelf = self else { return UICollectionViewCell() } switch itemType { case .banner(let bannerViewObject): // バナー表示用のCell要素を生成する処理を記載する case .featured(let featuredViewObjects): // 水平カルーセル表示用のCell要素を生成する処理を記載する …(必要なCell要素の生成をitemTypeの条件分岐に合わせて生成していく)… } } viewModel = TopViewModel() viewModel.items .asDriver() .drive(collectionView.rx.items(dataSource: dataSource)) .disposed(by: disposeBag) RxSwiftでの処理 ViewModel内の処理で変数itemの内容が更新さ れたタイミングでDataSourceのClosure内部に 定義しているCell・SupplementaryViewの生成 処理が実行されて、画面に内容が反映される let items = BehaviorRelay<[TopSection]>(value: []) ※RxDataSourceで提供するSectionModel配列 Cell要素生成処理
  5. DiffableDataSource用のSectionType・ItemType定義 SectionType・ItemTypeはHashableに準拠する必要がある点に注意! enum TopSectionType: Hashable { case banner(title: String) case

    featured(headerViewObject: TopHeaderView.HeaderViewObject) case news(headerViewObject: TopHeaderView.HeaderViewObject) case discount(title: String) case photo(headerViewObject: TopHeaderView.HeaderViewObject) } Banner Header: スクロール可能 Featured Carousel: News: enum TopItemType: Hashable { case banner(bannerViewObject: TopBannerCell.CellViewObject) case featured(featuredViewObject: TopFeaturedCell.CellViewObject) case news(newsViewObject: TopNewsCell.CellViewObject) case discount(discountViewObject: TopDiscountCell.CellViewObject) case photo(photoViewObject: TopPhotoCell.CellViewObject) } ※UICollectionViewCompositionalLayoutを利用する … Cell要素1つ分 1. Enum定義をしてcase内のassociated valueに表示データを定義する : 2. Hash値を生成するために定義されているViewObjectのassociate valueの値を利用する : Hash値が重複してしまうとCrashするのでCellViewObject・HeaderViewObjectの値はこの点には注意する必要があります。
  6. DiffableDataSourceを利用する際のひと工夫 DiffableDataSourceのどの処理をどの責務で実行するかを考えてみる 1. DiffableDataSourceにまつわる処理をViewControllerで頑張りすぎない形にする : DataSourceの構築とデータの反映・Cell要素の生成・画面レイアウト定義等、実は必要な処理がたくさんあります。 ViewController ViewModel UseCase・Repository Infrastructure

    どこに置くべきか悩ましい😓 ViewModel側 : DiffableDataSource構築 ViewController側 : Layout定義やCell構築 2. AnyHashableで頑張りすぎない形にするための工夫 : 画面専用のDiffableDataSourceクラスを準備する事で定義したEnumと対応ができる様な形としておく。 private var dataSource: UICollectionViewDiffableDataSource<TopSectionType, AnyHashable> extension TopViewController { class TopDataSource: UICollectionViewDiffableDataSource<TopSectionType, TopItemType> {} } △ ◦ DataSourceとViewController・ViewModelが対応 できる様な形を作っておく点がポイント。
  7. iOS14~利用可能なCellRegistrationにも対応する(1) CellRegistrationの処理を行いやすい様にする拡張を定義する protocol DynamicRegistrable: UICollectionViewCell { associatedtype Item static var

    cellNib: UINib { get } static func makeCellRegistration() -> UICollectionView.CellRegistration<Self, Item> func configure(_ cellViewObject: Item) } extension DynamicRegistrable { static func makeCellRegistration() -> UICollectionView.CellRegistration<Self, Item> { return .init(cellNib: cellNib, handler: { cell, _, item in return cell.configure(item) }) } } UICollectionViewクラスの拡張の応用することでEnumに定義したViewObjectと関連付ける : extension NSObjectProtocol { static var className: String { return String(describing: self) } } extension UICollectionView { static func makeNibResource<T: UICollectionViewCell>(_ cellType: T.Type) -> UINib { return UINib(nibName: T.identifier, bundle: nil) } } extension UICollectionReusableView { static var identifier: String { return className } } ※ItemはItemTypeのassociated valueに対応 従来通りのUICollectionViewCellを作り、 DynamicRegistrableに準拠させる様にする。 ※2. UINibのMappingにR.swift等も利用可能 ※1. 今回はxibとクラス定義でCellを作る想定 ※3. Item不要のCell・SupplementaryViewに     ついてもこの方法を応用することで定義 することができます。
  8. iOS14~利用可能なCellRegistrationにも対応する(2) CellRegistrationの拡張に準拠したCellクラスを定義する 該当するCell要素クラスに対してDynamicRegistrableに準拠させた場合の概要 : final class TopBannerCell: UICollectionViewCell, DynamicRegistrable {

    static let cellNib = UICollectionViewCell.makeNibResource(TopBannerCell.self) typealias Item = CellViewObject …(Cell表示のために必要な処理を記載する:@IBOutletや内部プロパティ等)… func configure(_ item: Item) { // バナー表示用のCell要素を表示する処理を記載する } } extension TopBannerCell { // TopItemTypeのassociated valueに定義するViewObjectの構造体定義 struct CellViewObject { let id: Int let identifier: String let imageUrl: URL? } } ※CellのIdentifierからUINibを取得する Cell要素生成処理 makeCellRegistration()と連動している部分になります。 ViewModel等DataSourceを作る部分で期待している処理 ① ItemTypeがHashableに準拠するためのHash値を作成すること ② ItemTypeに応じたCell表示データを作成すること
  9. ViewControllerにおけるCell要素構築処理の概要 CellRegistrationを利用したCellの生成処理の例 private func configureTopDataSource() { let topBannerCellRegistration = TopBannerCell.makeCellRegistration()

    let topFeaturedCellRegistration = TopFeaturedCell.makeCellRegistration()   …(表示する必要がある分だけCellRegistrationを追加する)… topDataSource = TopDataSource(collectionView: collectionView) { [weak self] (collectionView, indexPath, itemType) -> UICollectionViewCell? in guard let self = self else { return UICollectionViewCell() } switch itemType { case .banner(let bannerViewObject): let cell = collectionView.dequeueConfiguredReusableCell(using: topBannerCellRegistration, for: indexPath, item: bannerViewObject) return cell case .featured(let featuredViewObject): let cell = collectionView.dequeueConfiguredReusableCell(using: topFeaturedCellRegistration, for: indexPath, item: featuredViewObject) return cell …(表示する必要がある分だけCell生成処理を追加する)… } } } private var topDataSource: TopDataSource! topDataSource.supplementaryViewProvider = { … } を利用する形になります。 ViewController側におけるCell生成処理の抜粋 : ※ Header/Footer要素の構築に関して: ※ViewModel内にもDataSourceのインスタンスを渡しておく(viewDidLoad時) ① 表示に必要なCellRegistrationの準備 ② CellRegistrationとItemTypeを利用したCell生成処理
  10. ViewModelにおけるDataSource要素構築処理の概要 この部分で主に実行するのはDiffableDataSourceの構築と反映となる ① Section用のEnumを定義する(associated valueを用いてHeader・Footerに表示するViewObjectを作成する) 概要をまとめると下記の様な形となる : ② Item用のEnumを定義する(associated valueを用いてCellに表示するViewObjectを作成する)

    ③ 表示順番に配慮してNSDiffableDataSourceSnapshot<◆◆SectionType,◆◆ItemType>に反映する // MEMO: 変数currentSnapshotに表示対象のSectionTypeとItemTypeを追加する際のalias typealias SnapshotElement = (section: [TopSectionType], items: [TopItem]) private var currentSnapshot = NSDiffableDataSourceSnapshot<TopSectionType, TopItem>() // MEMO: ViewControllerと共有しているDiffableDataSource private var dataSource: TopViewController.TopDataSource! ※viewDidLoad時にViewControllerから渡されるもの ※DataSourceに追加するSnapshot Task { @MainActor in do { let response = try await self.topResponseUseCase.getResponse() self.updateTopDataSource(using: response) } catch let error { // TODO: Error Handling. } } 例. APIリクエストからDataSourceに加工するまでの流れ 1. 順番に配慮した状態でのAPIレスポンスデータを取得する 2. レスポンスデータを元にしてSnapshotに反映して更新
  11. ViewModel側に定義しているDataSource関連処理(1) async/awaitを利用したSectionの並び順を担保したResponse取得処理例 概要をまとめると下記の様な形となる : func getResources() async throws -> [TopResponse]

    { var responses: [TopResponse] = [] var endpoints = ["/banner", "/featured”, "/news", "/special", “/photos”] // エンドポイントの並び順を担保しながらの並列処理を実行する await withTaskGroup(of: [TopResponse].self, body: { [weak self] group in guard let self = self else { return } for endpoint in endpoints { group.addTask { // エンドポイントのパス文字列に応じたResponseを取得する処理 return try await APIRequest.shard.get(endpoint: section) } } for await response in group { responses.append(response) } // TODO: Error Handling. }) return responses } RxSwiftを利用する場合は.zipや.flatMap等を活用する。 ✨ async/awaitを利用した処理 余談: protocol TopResponse: Decodable { var •••: String // …(他に必要なものがあれば定義)… } struct TopBannerResponse: TopResponse { let •••: String // POINT: View表示に必要なデータ格納場所 let content: Content // TODO: CodingKey設定 } extension TopBannerResponse { // POINT: Contentの内容を定義 struct Content: Decodable { let id: Int let identifier: String let imageUrl: URL? // TODO: CodingKey設定 } } Response定義例: 形によっては調整を要する部分
  12. ViewModel側に定義しているDataSource関連処理(2) 並び順を担保したレスポンス情報の配列からSnapshotを作成し画面に反映する ViewModel側におけるDataSource更新処理の抜粋 : private func updateTopDataSource(using responses: [TopResponse]) {

    // 1. 現在Snapshopをリセットする currentSnapshot = NSDiffableDataSourceSnapshot<TopSectionType, TopItem>() // 2. 引数から取得したレスポンスの型を元に分解してSnapshotを生成する for response in responses { if let topBannerResponse = response as? TopBannerResponse { let snapshotElement = getTopBannerSnapshot(topBannerResponse) appendSnapshotElementInCurrentSnapshot(snapshotElement) } …(表示する対象のResponseの分だけSnapshotを生成する処理をする)… } // 3. DiffableDataSourceにデータを反映する dataSource.apply(currentSnapshot, animatingDifferences: true) } // メンバ変数: currentSnapshot(反映対象セクションデータの入れ物)に格納する private func appendSnapshotElementInCurrentSnapshot(_ snapshotElement: SnapshotElement) { currentSnapshot.appendSections(snapshotElement.section) currentSnapshot.appendItems(snapshotElement.items, toSection: snapshotElement.section.first!) } private func getTopBannerSnapshot(_ topBannerResponse: TopBannerResponse) -> SnapshotElement { // バナーに関するデータの実体がある場所 let content = topBannerResponse.content …(表示する対象のResponseの分だけSnapshotを生成する処理をする)… let section = TopSectionType.banner(title: "2022 A/W Selection") let item = TopItemType.banner(bannerViewObject: TopBannerCell.CellViewObject( id: content.id, identifier: content.label, imageUrl: content.imageUrl) ) return SnapshotElement(section: [section], items: [item]) } ① 受け取ったResponseを分解&Section作成 ② 反映対象のSnapshot変数への追加
  13. 今後更に考えていきたい部分について 特にiOS13から利用可能になったUIKitの便利なAPI等の活用 実務の中で改善や新機能のアイデアや試してみたい事はたくさん見つかった様にも感じています。 1. RxSwiftベースで実装している部分を徐々に剥がしてCombineへ置き換え: RxSwiftでのコードについては私自身もかれこれ4-5年程使ってきて馴染みがある実装である&Combineでは標準搭載されていない オペレータもあるのでこの部分を見極めながら置き換えていく必要がありそうと感じています。 2. async/awaitでの処理の更なる活用と実践 :

    async/awaitの登場によりこれまで複雑になりがちな部分であった部分をシンプルに実装できる可能性には注目しています。(た だしこの部分は私自身実務内でも経験はあるものの、正直未だに手探り感は否めません)。 3. SwiftUIとUIKitの上手な共存とデザインシステム等とも連携した整理: 業務の中でデザインシステム整備に携わる経験があったので、その際に「SwiftUIの機能でPreviewできると確認しやすいかな」 と感じる事が多くありつつもUIKitがベースになっている場合が多いので他社事例も見ながら考えていきたいです。
  14. まとめ UICollectionViewを利用した実装はここ数年の進化が目覚ましい部分 1. Section・Itemに対応するEnum定義部分をまずは確認すると良い: RxDataSource・DiffableDataSourceを考えていく場合には、共にSection・Itemに対応するEnum定義の整備が前提ではありますが DiffableDataSourceではHashableへの準拠がポイントになる点があると思います。(特にHash値の重複に注意) 2. Enumのassociated valueを有効活用してCell要素やSupplementaryView要素を上手に構築する: associated

    valueにおいてCell要素やSupplementaryView要素に表示する構造体を定義しておき、UICollectionViewに表示する要 素を生成するCellRegistrationやSupplementaryRegistrationとも連動する様にしておくと便利かと思います。 現在ではiOS14以上のバージョンであるならばその便利な機能を活用可能な余地が多いと感じました。 3. 画面実装側での処理とDataSourceの内容構築部分に関する部分を整える: DataSourceに関する処理については、ViewController側ではCell・SupplementaryView・UICollectionViewCompositionalLayout の構築処理に専念させて、ViewModelやPresenter層でDataSourceの構築を行う方が良いかと思います。