$30 off During Our Annual Pro Sale. View Details »

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. Several Tips to convert from RxDataSource

    to NSDiffableDatasource & UICollectionView
    potatotips #79
    2022/10/31
    Fumiya Sakai

    View Slide

  2. 自己紹介
    ・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

    View Slide

  3. iOSのUI実装本を執筆しています!
    書籍に掲載したサンプルのバージョンアップや続編等に現在着手中です。
    少しの工夫で実現できるTIPS集やライブラリ表現の活用集をはじめとした、iOSア
    プリ開発の中でも特にUI実装やUIKitを利用した画面の中で特徴を与える様な表現
    という題材に焦点を当てた書籍となっております。
    現在は電子書籍版のみとなります。 こちらは全て¥1,000となっております。
    https://just1factory.booth.pm/
    概要:
    https://book-tech.com/
    価格:
    📖 Booth
    📖 Book Tech

    View Slide

  4. UI実装であると嬉しいレシピブックの最新情報
    UI実装であると嬉しいレシピブックVol.3として昨年10月に商業化しました!
    Still

    WIP
    これまでの同人誌として頒布したものに加えて、Vol.1及びVol.2に頒布したものの
    中で書籍に載せきれなかったものや表現や動きが特徴的でユーザーにもほんの少し
    遊び心を与える様なUI実装を紹介したものをVol.3としています。
    概要:
    これからの構想:
    こちらで購入可能です:
    Amazon / Google Play / Apple Books / KINOKUNIYA / Rakuten BOOKS etc..
    🏊 iOS: SwiftUIを利用したUI実装や動画関連の実装
    🏊 Android: Jetpack Composeの基本やその他気になるUI表現の考察

    View Slide

  5. 今回のスライドにつきまして
    業務や個人開発でRxDataSourceからDiffableDataSourceへのリプレイスを実施
    RxDataSourceを利用した処理を置き換えていく部分を中心にご紹介をできればと思います。
    1. RxDataSource&RxSwiftベースの実装においてDiffableDataSource部分だけを置き換えていきたい:
    RxDataSource&RxSwiftベースでも画面処理には差し支えがないものの、iOS14以降であればDiffableDataSourceでの差分更新は
    もちろんCellRegistrationを利用したCellの生成処理も活用可能である点は魅力に感じていました。
    2. UICollectionViewCompositionalLayoutへの布石とする:
    実はDiffableDataSourceを利用していたとしても従来通りのUICollectionViewDelegateLayoutを利用することはできるものの、
    UICollectionViewCompositionalLayoutを利用する方が画面構造的にもシンプルに済ませられるケースも多そうでした。
    3. 置き換えを進めていくにあたって考え方を変える必要がある部分もあった:
    最初は結構似ているかもしれないと思っていたけれども、しっかりと見ていくと実は意外と落とし穴があって見事にハマった
    り、想定以上に時間を要してしまった部分もありました(ここは私自身がRxDataSourceをあまり使わなかったため)。

    View Slide

  6. RxDataSourceに関する簡単な概要紹介
    RxSwift&UITableView・UICollectionViewでの実装を便利にする優れもの
    (参考)RxDataSource: https://github.com/RxSwiftCommunity/RxDataSources

    View Slide

  7. RxDataSourceで構築された画面の置き換え方針
    一番のポイントとなる部分はDataSourceやCell要素構築に関する部分
    1. DataSource部分の乗せ替え対応:
    必要に応じてRxSwiftベースで作られているMVVMまたはMVPのViewModelやPresenter側の処理に変更が必要となる。
    2. 従来のLayout処理をUICollectionViewCompositionalLayoutへの乗せ替え対応:
    UICollectionViewCompositionalLayoutの宣言的なLayout構成を活用する事でよりシンプルな形になるよう変更する。
    アプリのTOP画面の様に1つの画面に多くのSectionがある画面を対応する際はまずはここを完璧に整える事を目標に!
    補足. HorizontalなCarousel表現について:
    UICollectionViewCompositionalLayoutでは特にUICollectionViewを入れ子にしなくて
    も実現可能(そうでなければUICollectionView in UICollectionViewCellの形) スクロール可能
    👉 Cellへ表示データを渡す際に少しだけ形を変形する必要があります。

    View Slide

  8. 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
    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要素とバインドする処理用の変数を準備 :

    View Slide

  9. RxDataSourceで構築された画面処理に関するポイント
    必要なSectionModelの内容を決めて、それが変化すると画面に反映される形
    ※ Header/Footer要素の構築に関してもconfigureSupplementaryViewの部分で構築する形となる
    typealias TopSection = SectionModel
    ViewController側におけるCell生成処理の抜粋 :
    private lazy var dataSource = RxCollectionViewSectionedReloadDataSource(

    configureCell: configureCell,

    configureSupplementaryView: configureSupplementaryView

    )

    private lazy var configureCell: RxCollectionViewSectionedReloadDataSource

    .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要素生成処理

    View Slide

  10. 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の値はこの点には注意する必要があります。

    View Slide

  11. 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
    extension TopViewController {

    class TopDataSource: UICollectionViewDiffableDataSource {}

    }


    DataSourceとViewController・ViewModelが対応
    できる様な形を作っておく点がポイント。

    View Slide

  12. iOS14~利用可能なCellRegistrationにも対応する(1)
    CellRegistrationの処理を行いやすい様にする拡張を定義する
    protocol DynamicRegistrable: UICollectionViewCell {

    associatedtype Item

    static var cellNib: UINib { get }

    static func makeCellRegistration() -> UICollectionView.CellRegistration

    func configure(_ cellViewObject: Item)

    }

    extension DynamicRegistrable {

    static func makeCellRegistration() -> UICollectionView.CellRegistration {

    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(_ 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に 

       ついてもこの方法を応用することで定義

    することができます。

    View Slide

  13. 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表示データを作成すること

    View Slide

  14. 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生成処理

    View Slide

  15. 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()

    // 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に反映して更新

    View Slide

  16. 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定義例: 形によっては調整を要する部分

    View Slide

  17. ViewModel側に定義しているDataSource関連処理(2)
    並び順を担保したレスポンス情報の配列からSnapshotを作成し画面に反映する
    ViewModel側におけるDataSource更新処理の抜粋 :
    private func updateTopDataSource(using responses: [TopResponse]) {

    // 1. 現在Snapshopをリセットする

    currentSnapshot = NSDiffableDataSourceSnapshot()

    // 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変数への追加

    View Slide

  18. おまけ(1):個人的に気になっていた部分の紹介
    UICollectionViewCompositionalLayoutでもDrag&Dropは可能(ただしバグ有り)
    記事はこちら: https://briancoyner.github.io/articles/2021-10-12-reorderable-collection-view/

    View Slide

  19. 1. 機能の一部としてUICollectionViewを活用
    おまけ(2):UICollectionViewで正攻法ではない表現例
    積極的に選択はしないがUICollectionViewを利用して構築可能な事例をいくつか
    ※UICollectionViewLayoutを応用して実現する
    2. レイアウト属性部分を自前で1から定義する
    旧Safariの様な

    動き方をする
    横・縦・斜めで

    スクロール可能
    ※UICollectionViewLayoutクラスを継承したもの

    View Slide

  20. 今後更に考えていきたい部分について
    特にiOS13から利用可能になったUIKitの便利なAPI等の活用
    実務の中で改善や新機能のアイデアや試してみたい事はたくさん見つかった様にも感じています。
    1. RxSwiftベースで実装している部分を徐々に剥がしてCombineへ置き換え:
    RxSwiftでのコードについては私自身もかれこれ4-5年程使ってきて馴染みがある実装である&Combineでは標準搭載されていない
    オペレータもあるのでこの部分を見極めながら置き換えていく必要がありそうと感じています。
    2. async/awaitでの処理の更なる活用と実践 :
    async/awaitの登場によりこれまで複雑になりがちな部分であった部分をシンプルに実装できる可能性には注目しています。(た
    だしこの部分は私自身実務内でも経験はあるものの、正直未だに手探り感は否めません)。
    3. SwiftUIとUIKitの上手な共存とデザインシステム等とも連携した整理:
    業務の中でデザインシステム整備に携わる経験があったので、その際に「SwiftUIの機能でPreviewできると確認しやすいかな」
    と感じる事が多くありつつもUIKitがベースになっている場合が多いので他社事例も見ながら考えていきたいです。

    View Slide

  21. まとめ
    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の構築を行う方が良いかと思います。

    View Slide

  22. Thank you for listening !
    この資料では感覚的にはイメージできそうだけども、具体的な例がないとわかりにくい部分もあったかもしれません。

    改めてUI実装サンプルとも合わせて年内には記事化できる様に進めていければと思います!

    View Slide