Compositional Layoutsを使ってラクマのiOSアプリを改修した話

f:id:Rakuma:20210318101509p:plain

前書き

初めまして、ラクマのTsurutaです。

ラクマのアプリでは「保存した検索条件」の一覧が、ホームのタブから見られるようになったことをご存知でしょうか?

この画面を構成するのにあたり、ラクマではiOS12以下のサポートを行っていないため、iOSの新しいAPIである Compositional Layouts を使って構成することにしました。

実装

① iOSアプリの構成

現状ラクマのアプリは MVP をベースとした構成になっています。

UICollectionView を実装する場合、DelegateDataSource の実装が ViewController に依存してしまう問題があります。

これは Compositional Layouts を普通に実装した場合でも同じ問題に直面します。

そのため、ラクマのiOSでは以下のような構成で、疎結合な実装を実現しました。

抽象的なプロトコルとして書き出して共通化し、ViewController依存しないようにしています。

② レイアウトの抽象化

レイアウトに対して、以下のような粒度で抽象化します。

セクションに共通する処理を整理して抽象しています。 例として、抽象化するとこのようになります。

protocol SectionProtocol {
    // セクションのアイテム数
    var numberOfItems: Int { get }

    // レイアウトの生成
    func layoutSection(_ view: UIView) -> NSCollectionLayoutSection

    // セルの生成
    func configureCell(_ view: UICollectionView, 
                       at indexPath: IndexPath) -> UICollectionViewCell
}

これはただ抽象化しただけではなく、画面を構成するモデルの役割もはたします。

このプロトコルを各セクションごとに継承し、セクションごとに設定を記載していきます。

struct SectionA: SectionProtocol {

    let numberOfItems = 1

    func layoutSection(_ view: UIView) -> NSCollectionLayoutSection {
        /* 略 */
        return UICollectionViewCompositionalLayout(section: section)
    }

    func configureCell(_ view: UICollectionView, 
                       at indexPath: IndexPath) -> UICollectionViewCell {

        let cell = view.dequeueReusableCell(
            withReuseIdentifier: "your cell id", 
            for: indexPath
        ) as! SectionACell

        /* 略 */

        return cell
    }
}

各セクションを別で定義することで、ViewController からレイアウト部分を、別クラスとして分離することができます。

③ MVPでの実装

別クラスとして分離したので、ViewControllerPresenter はこのようにすっきりとした形になります。

protocol Presentable: AnyObject {
    var sections: [SectionProtocol] { get }
}

final class Presenter: Presentable {
    private var sections: [SectionProtocol]
}
final class ViewController: UIViewController {

    private(set) var presenter: Presentable!

    private lazy var collectionView: UICollectionView = {
        let collection = UICollectionView(
            frame: .zero, 
            collectionViewLayout: compositionalLayout
        )
        /* ~ 略 ~ */
        return collection
    }()

    private lazy var compositionalLayout: UICollectionViewLayout = {
        return UICollectionViewCompositionalLayout { [weak self] section, _ in
            return self?.sections[section].layoutSection(self ?? .init())
        }
    }()
}

また、抽象化した他のプロパティやメソッドは、以下のように呼び出すことができます。

extension ViewController: UICollectionViewDataSource {

    func numberOfSections(in collectionView: UICollectionView) -> Int {
        presenter.sections.count
    }

    func collectionView(
        _ collectionView: UICollectionView, 
        numberOfItemsInSection section: Int
    ) -> Int {
       presenter.sections[section].numberOfItems
    }

    func collectionView(
        _ collectionView: UICollectionView, 
        cellForItemAt indexPath: IndexPath
    ) -> UICollectionViewCell {
       presenter.sections[indexPath.section].configureCell(collectionView,
                                                           at: indexPath)
    }
}

抽象化されているため、呼び出しが集約されてとても綺麗な実装になっています。

詳しい実装は以下に掲載してありますので、興味があればご覧になっていただけると幸いです。

qiita.com

github.com

Compositional Layouts の不具合

今回の実装において苦労したこともあり、Compositional Layouts でしか発生しない不具合に遭遇しました。

その1つとして、

  • iPad

  • 特定の条件下

  • 画面回転

この時に、アイテムが全て消えてしまうという現象がありました。

既存の UICollectionView のレイアウト実装では発生せず、検索しても出てこないため、対応方針をどうしようか悩みました。

結論としては、画面回転時に画面をリセットしてから再描画することで対応することにしました。

ただ再描画するだけでは解決せず、リセットする(UICollectionView が参照している Section 全てを空にしてリロードする)作業を行うことが、この問題を解決することのポイントになります。

ただし、この解決方法は、再描画するため回転前のポジションを維持できないという弊害もあります。内部的にポジションを保持して引き継ぐことも可能ですが、特定の条件下ということもあり、対応コストとメンテナンス性を考えて、ポジションの維持は行わない決断となりました。

終わりに

徐々に導入しているプロジェクトが増えてはきているものの、まだそんなに母数が多くないため、Compositional Layouts のベストプラクティスな構成を見つけるのは難しいかもしれません。

また、SwiftUIも台頭してきており、今後のことを見据えて疎結合な実装で分離しておくことは、プロジェクトにおいてメンテナンス性の高い実装になると思います。

その他

ラクマでは、User Firstをコア・バリューの一つに掲げ、一緒にアプリ開発をしてくれるメンバーを募集しています。

www.wantedly.com

www.wantedly.com

www.wantedly.com

カジュアルな面談から、是非お待ちしております!