Sansan Builders Box

Sansanのものづくりを支える技術やデザイン、プロダクトマネジメントの情報を発信

VIPER の実装コストを下げるために

こんにちは! Sansan iOS チームの髙橋佑一朗です。

Sansan iOS アプリでは現在、 VIPER をアーキテクチャとして採用していますが、VIPER の構成で実装していくに当たって1画面作るのにコードを書く量が多いなぁと少しづつ感じてくるようになりました。
そこで、今回は実装時のコストを下げるために考えたことや試したことを書いていきたいと思います。

VIPER は実装コストが高い?

VIPER についてお話をするときによく聞く話ですが 実装コストが高い というのは具体的に以下の2点から来ているのかなと思っています。

  1. 画面あたりのファイル数の多さ
  2. 各種 VIPER コンポーネント実装時のボイラープレート (初期化時のコード等)

順番にさらっと見ていきましょう。

1. 画面あたりのファイル数の多さ

VIPER の特徴は責務が細かく分割されているということですね。
Clean Architecture を iOS 向けに最適化したもので、元の Clean Architecture ほどではありませんが比較的細かく分けられています。
それぞれのコンポーネントの責務が明確になり、どこか一つのクラスが肥大化してしまうという事態を防ぐことができます。
また、アプリとしてロジックの置き場所などの一貫性を保ちやすくすることでコードの見通しがとてもよくなります。
反面、細かく分けられているがゆえにファイル数は多くなりがちです。

VIPER といえばこちらの記事が有名かと思いますが

cheesecakelabs.com

こちらの構造に従って VIPER を実装すると1画面あたりに必要なファイルは

  • HogeViewController.swift
  • Hoge.storyobard (option)
  • HogePresenter.swift
  • HogeInteractor.swift
  • HogeRouter.swift

となり、5(storyboard がない場合は 4)ファイル作成することになります。
まだこれだけであれば苦にはならないですが、次のボイラープレートも合わさると少々しんどくなってきます。

2. 各種コンポーネント実装時のボイラープレート

それでは本実装を始めるに当たって書かなくてはならないコードたちを紹介します。
今回作成する画面の名前は HogeList とします。

まずは外部とのつなぎ目であり、ビジネスロジックも担う Interactor。
画面を実装するに当たってどの API を叩けば良いか大抵の場合分かっているのでこの段階で API クラスをインジェクトします。

protocol HogeListInteractorInterface {
    // ここに実装すべきメソッドを書いていく
}

final class HogeListInteractor {
    private let hogeAPI: HogeAPIInterface

    init(hogeAPI: HogeAPIInterface = HogeAPI()) {
        self.hogeAPI = hogeAPI
    }
}

extension HogeListInteractor: HogeListInteracotrInterface {
    // 本実装時に追加していく
}

各種 VIPER コンポーネントのブリッジである Presenter。
様々なコンポーネントとやりとりしなくてはいけないため必要な引数が多くなりがち。

protocol HogeListPresenterInterface {
    // ここに実装すべきメソッドを書いていく
}

final class HogeListPresenter: HogeListPresenterInterface {
    private let interactor: HogeListInteractorInterface
    private let router: HogeListRouterInterface
    
    private weak var view: HogeListView?

    init(interactor: HogeListInteractorInterface, 
        router: HogeListRouterInterface, 
        view: HogeListView) {
        self.interactor = interactor
        self.router = router
        self.view = view
    }
}

遷移とモジュールの組み立てを担当する Router。
組み立てを行う都合上、自分以外の他のコンポーネントを初期化しなくてはならず、一番ボイラープレートが大きいです。

enum HogeListRouterNavigationDestination {
    // 遷移先を追加
    case fuga
}

protocol HogeListRouterInterface: RouterInterface {
    func navigate(to destination: HogeListRouterNavigationDestination)
}

final class HogeListRouter: BaseRouter {
    init() {
        let viewController = HogeListViewController()
        super.init(viewController)

        let interactor = HogeListInteractor()
        let presenter = HogeListPresenter(interactor: interactor, router: self, view: viewController)
        viewController.presenter = presenter
    }
}

extension HogeListRouter: HogeListRouterInterface {
    func navigate(to destination: HogeListRouterNavigationDestination) {
        // 本実装時に switch で遷移先を振り分けていく
    }
} 

画面表示をする View (ViewController)。
ここは自動生成されるものが多いので特に問題はないです。

final class HogeListViewController: UIViewController {
    var presenter: HogeListPresenterInterface!

    override func viewDidLoad() {
        super.viewDidLoad()
    }
}

extension HogeListViewController: HogeListViewInterface {
    // 本実装時にコードを追加
}

はい。
ここまできてやっと画面の実装に取り掛かることができます。
コードも単体で見るとそこまで大きい訳ではありませんが、1画面作り始める際に毎回この手順を踏むのは少ししんどいです。

なんとか改善できないでしょうか?

試したこと

ここからは実際に試したこと、やってみたことのご紹介です。

1. コードスニペットを登録して使う

一番最初に思いつきました。
Xcode のコードスニペットに登録して presenter 等のキーワードで呼び出します。
導入自体はお手軽で共有も簡単なのですが、結局ファイルは全て手動で作成しなくてはならず、スニペットで書かなくてはならない範囲を全てカバーできる訳ではなく、効果も薄いと感じました。
試しに少し VIPER 使ってみようかという時にあるとパッとコードがかけて便利かなという程度だと思います。

2. 自動生成ツールを使う (Generamba)

今回やってみて一番わかりやすく効果を感じられたツールです。

github.com

Generamba(ジェネラムバ?) は VIPER のために作られた自動生成ツールで、Ruby製なので gem をつかってインストールすることができます。
VIPER を意識して作られてはいますが、記述した template ファイルを元にファイルとコードを生成してくれるので
公式の説明にもあるとおり、 VIPER だけでなく他の構成でも使えるようになっています。
なお、テンプレートエンジンは liquid です。

導入方法については詳しい記事がたくさんあるので簡単な導入までの流れと使い方を説明します。

1.導入

まずは gem install generamba で Generamba をインストールします。
次に generamba setup で質問に回答し Rambafile を生成しますが、ここでプロジェクトファイルの場所やプロジェクトターゲットなどを設定します。
ちなみに、Cartfile や Cocoapods を使っているか聞かれますが、こちらの設定は今は使用されていないようです(今後関連した機能が追加される模様。多分。)

f:id:chaaaaanu:20200226165737p:plain
setup 時には色々聞かれます

2. テンプレートの作成

generamba template create <TemplateName> でテンプレートを作成することができます。
今回Sansanでは一から生成しましたが、github等で公開されているテンプレートを利用しても良いと思います。
TemplateName で指定したディレクトリができますのでそのディレクトリに、Router, Interactor, Presenter, View のテンプレートファイル (liquid) を作成していきます。

テンプレートファイルの例:

//
//  Created by {{ developer.company }} on {{ date }}.
//  Copyright © {{ year }} {{ developer.company }} All rights reserved.
//

import RxSwift

protocol {{ module_info.name }}PresenterInterface: PresenterInterface {}

final class {{ module_info.name }}Presenter {
    private let interactor: {{ module_info.name }}InteractorInterface
    private let router: {{ module_info.name }}RouterInterface
    private unowned let view: {{ module_info.name }}ViewInterface

    init(interactor: {{ module_info.name }}InteractorInterface,
         view: {{ module_info.name }}ViewInterface,
         router: {{ module_info.name }}RouterInterface) {
        self.interactor = interactor
        self.router = router
        self.view = view
    }
}

extension {{ module_info.name }}Presenter: {{ module_info.name }}PresenterInterface {}

3. コードの生成

generamba template install で作成したテンプレートを適用したり、githubで公開されているテンプレートをインストールすることができます。

あとは generamba gen <ModuleName> <TemplateName> としてあげれば VIPER の実装に必要なコードとファイルが生成されます。
また、Generamba は生成したファイルやディレクトリを自動的に Xcode にも追加してくれるので地味にありがたいです。

おまけ: --custom-parameters オプションでテンプレートをよりリッチにする

ファイルを生成する generamba gen コマンドは

generamba gen --custom-parameters key1:value1 key2:value2

としてあげることで key-value 形式の簡単な引数を渡すことができます。
例えば今回はSansanで導入した際には以下のように呼び出すことで Interactor のイニシャライザに任意の数の API クラスを追加できるようにしています。
API 等のクラスについては実装に取り掛かる前に大抵の場合どれを使うのか把握できていることが多いのでオプションとして追加し、より実際に書くコードが少なくなるようにしています。

# command
$ generamba gen <MODULE_NAME> <TEMPLATE_NAME> --custom-parameters sansan_api_vars:api1=API1,api2=API2
// 生成された Interactor
import RxSwift

protocol ContactListInteractorInterface: InteractorInterface {}

final class ContactListInteractor {
    let api1: API1
    let api2: API2

    init(api1: API1Interface = API1(), api2: API2Interface = API2()) {
        self.api1 = api1
        self.api2 = api2
    }
}

extension ContactListInteractor: ContactListInteractorInterface {}

3. Generics を使って VIPER のボイラープレートを減らす

以前 Qiita でこのような記事を拝見してこの方法で VIPER のボイラープレートを減らせないか?と思って試してみました。

qiita.com

ツールを導入するでもなく、Genericsを使って VIPER のボイラープレートの問題を改善しようとしており、非常に気に入っていて、なんとか適用できないかと考えていましたが、導入にまではいたりませんでした。残念。
理由としては前提としている VIPER の構成が違ったことです。

例えば記事の中での VIPER は以下の画像のような構成になっていますが

f:id:chaaaaanu:20200226155509p:plain
記事の中で紹介されている VIPER の構成 (元記事より引用)

Sansanではこちらの構成を利用しています。

f:id:chaaaaanu:20200226164951p:plain
Sansan で利用している VIPER の構成 (よく見るやつですね)

Interactor の戻り値を Presenter が直接受け取っていたり、Router が遷移と組み立てのロジックの両方を持っていたりと記事の構成とは違う点が多く、適用するのが少々難しかったです。
Presenter -> View 間など、一部に取り入れることはできたかと思いますが、既に VIPER 化されている画面達を置き換えるコストとそれに見合った効果が期待できるか?という点を鑑みて今回は見送るという形になりました。

ですが今後 Sansan でも Presenter や Interactor の Input/Output を別の protocol として切り出すような構成になるかもしれないという話もあったり、より状況に即した形に変化して行くことが予想されますので、その際に改めてチャレンジしたいと思います。

まとめ

結局今回試した方法の中で一番効果が高いと感じたのは Generamba かなと思います。
導入も難しくなく、テンプレートも既存のものを利用できたりするので非常に使い勝手が良いです。
また Generics を使った方法についても VIPER の構成によっては Generamba 以上に効果を感じられるのではないかなと思っていますし、Generamba と組み合わせても良さそうだなと思っています。
ただ、Generamba についてはテンプレートの中で for などを使い始めるとかなりテンプレートが読みづらくなるなと感じており、そこのデメリットを軽減できないかと考えています。
今回で一定の効果は得られたと思っていますがまだ完全ではないので、より VIPER での開発がしやすくなるように改善を加えていきたいと思います!


buildersbox.corp-sansan.com

buildersbox.corp-sansan.com

buildersbox.corp-sansan.com

© Sansan, Inc.