ANDPAD iOS開発における課題と最近取り組んでいる「マルチモジュール化」について

ANDPADに入社して1年と少しが経ちました、モバイルアプリエンジニアの佐藤(@ushisantoasobu)と申します。

タイトル通りではあるのですが、現状のANDPAD iOS開発における課題と、ここ数ヶ月取り組んできた「マルチモジュール化」について書いていきます。

ANDPAD iOS開発における課題

スーパーアプリ戦略 vs マルチアプリ戦略

ANDPADが向き合っている課題、ここでは一言で「建築DX」と呼んでしまいますが、建築DXはとてつもなくデカい課題だと自分は認識しています。ゆえにモバイルアプリ開発としても以下の2つの戦略のうちどちらかを選択する必要がありました。

  • スーパーアプリ戦略
    • 全ての機能やドメインを1つの巨大なアプリに集約
  • マルチアプリ戦略
    • 機能やドメインごとにアプリをリリース・運用

ANDPADでは後者の「マルチアプリ戦略」を採用しています。そこらへんの経緯などについては、弊社CDOの山下の発表が参考になると思います。

speakerdeck.com

マルチアプリ戦略のメリット・デメリット

それぞれの戦略のメリット・デメリットについては上の山下の資料にもまとめられていますが、ここでも自分が思うメリット・デメリットを自分の言葉で簡単にまとめてみたいと思います。

メリット

外部品質

より良いユーザ体験を提供できると考えています。

スーパーアプリではあるユーザにとっては不要な機能も混在したアプリを操作することになりますが、マルチアプリ戦略では「各ユーザが必要な機能 = アプリだけをインストールして利用する」ことになるため。

内部品質

採用できる技術の幅が広がると考えています。

わかりやすい例でいうと、ANDPADのいくつかのモバイルアプリはFlutterを採用しています。これはスーパーアプリ戦略ではできなかったことなのではと思います。

デメリット

当たり前ですがデメリットもいくつかあると認識していて、ここでは今回のブログに関わる以下のデメリットを取り上げたいと思います。

つまり「アプリ間で共有したいコード」の運用・開発についてです。

現時点では、カメラ、お絵かき、写真ピッカーなどの社内で開発した「ツール」系のライブラリ(「社内ライブラリ」と呼んでいる)が複数存在していて、各アプリが必要なものだけCocoapods経由でインストールするという形をとっています。ただし、

  • 社内ライブラリで修正が必要になったときに、どのチームが担当するのか(現状社内ライブラリ専門のチームというのは存在しない)
  • 何か破壊的な変更が入ったときに、別のチームにどのように共有するか
  • 社内ライブラリが依存するサードパーティのライブラリと、アプリが依存するサードパーティのライブラリとでバージョンが異なると、そもそもビルドができないので気をつけなくてはいけない

といった課題が存在しています。

各アプリが、必要な社内ライブラリのみをCocoapods経由でimportして利用している

新しい課題、「機能モジュール」をアプリ間で共有したい

そして最近、それに類似する新しい課題が出てきました。ある2つのアプリで共通して存在する機能が見つかったのです。 先日この機能に改修が入ったのですが、全く同じ修正を2つのアプリに入れるという残念なことが発生しました。

「どちらが担当するのか = それぞれのアプリの担当エンジニアが実装する vs 実装を把握している同一のエンジニアが2つのアプリに実装する」という問題で軽く揉めたりもしたため、こちらも社内ライブラリとして切り出そうという話になりました。

ただし、これまでの「ツール系」のライブラリと違って、今回切り出したいものはより「機能的」なもの、わかりやすい例でいうと「がっつりAPI処理を含む」ようなものでした。このブログではこういったものを「機能モジュール」と呼ぶことにします。

そしてそういった違いから、これまでの社内ライブラリとは異なるアプローチで機能モジュールを切り出そうという話になりました。これがこのブログのメイントピックとなります。

最近取り組んでいる「マルチモジュール化」について

ということで、ここからはANDPAD iOS開発における「マルチモジュール化」の話をしていきます。 ただしここでいう「マルチモジュール化」はiOS界隈で一般的に言われているもの(主に「ビルド時間短縮による開発速度の改善」を目的とするもの)とは異なり、「複数アプリ間で使い回すために、ある機能をモジュールとして切り出す」ことを指しています。

とはいえ一般的な「マルチモジュール化」のプラクティスを多分に参考にさせていただいたので、「なんだ、複数アプリ間で使い回すためのものか、うちには関係ないな」と思わず読んでいただけると幸いです。

モジュールの「モノレポ」化

議論を重ねていくうちに、今回切り出した機能モジュール以外にも、既存・新規問わずモジュールとして切り出しておくと良さそうだね、というものが複数あることがわかりました。

そこで新しい試みとして、機能モジュールは「モノレポ」で管理してみようということをやっています。

既存のツール系の社内ライブラリはそれぞれ異なるGithubのレポジトリで管理してきましたが、機能モジュールについては1つのレポジトリで複数のモジュールを管理していこうというものです。わかりやすい例でいうとFirebaseのSDKがそれに該当するかなと思います。 これまでツール系の社内ライブラリを運用してきて感じた課題 = レポジトリが複数にまたがるとどうしてもその管理が煩雑になってしまう、に対する解決策として1つのレポジトリで管理することで開発者体験を向上する狙いです。

1つのレポジトリで機能モジュールを管理することにした

が、モノレポのpros/consについてはまだまだ運用期間も短すぎるためわかっておらず、いずれ別の機会で話せたらいいなと思っています。

モジュールの構成

機能モジュールを開発・運用するうえで、基盤となるような共通モジュール、CoreUIComponentというモジュールも作成しました。依存関係としては以下のようになります。

機能モジュールはいくつかの「共通モジュール」に依存している

ここらへんの構成(そして内部的な作りについても)については、クックパッドさんのマルチモジュール開発に強く影響を受けています。

各モジュールの役割

それぞれのモジュールの基本的な役割を簡単に書きます。実際のコードは後ほど出てきます。

Coreモジュール

  • Entityの定義
  • Extensionの定義
  • 依存性のI/Fの定義

「依存性のI/F」というのは、APIクライアントや画面遷移などの処理を外(アプリケーションターゲット)から渡してあげる仕組みになります。「I/Fの定義」なので、APIクライアントや画面遷移などの「実体」はこのCoreモジュールには存在しません。

UIComponentモジュール

  • UIComponentの定義

機能モジュール

  • 該当機能の実装

補足として、一般的なマルチモジュール化の話でよく言われることですが、「機能モジュールが別の機能モジュールを参照することを許容すると、それぞれを参照し合う = 循環参照が起きうる」と認識しているため、機能モジュールが別の機能モジュールを参照するということは禁止としています。

各モジュールの開発

Coreモジュール

EntityやExtensionなどは容易に想像がつくと思うので、ここでは「依存性のI/F」の具体的なコードを書いてみたいと思います。とはいえここも先述したようにクックパッドさんのマルチモジュール開発に強い影響を受けています。

import RxSwift

public protocol SomeFeatureEnvironment {
    var apiClient: SomeFeatureAPIClient { get }
    var userDefault: SomeFeatureUserDefault { get }
    var router: SomeFeatureRouter { get }
}

public protocol SomeFeatureAPIClient {
    func fetchSomeList() -> Single<[Some]>
}

public protocol SomeFeatureUserDefault {
    func getSomeCount() -> Int
}

public protocol SomeFeatureRouter {
    func showOtherFeature(from: UIViewController, otherID: Other.ID)
}

モジュール側はI/Fのみを公開して、APIクライアントや画面遷移などの実体はアプリケーションターゲット側からインジェクトする形です。

なぜAPIClientをインジェクトするのか?

ここまでの話を受けて、以下のような疑問を持たれた方もいるかもしれません。

なぜAPIClientをわざわざインジェクトする必要があるのか。社内で利用する機能モジュールなのであれば、そこに実体を持たせてしまった方が管理としては楽のでは?

これについては、そうしなかった理由があります。その理由はSomeエンティティに紐づく親オブジェクトが、アプリ間で異なることがあるからです。

具体的にいうと、例えばfetchSomeList()メソッドで呼ばれるAPIのエンドポイントは、それぞれのアプリでは以下のようなものになります(あくまでイメージ)。

  • アプリA -> https://aaa.andpad.jp/order/{$order_id}/somes
  • アプリB -> https://bbb.andpad.jp/chat/{$chat_id}/somes

urlのドメインだけでなく、someに紐づくオブジェクト(order, chat)がアプリによって異なることがわかると思います。

ここらへんの違いを機能モジュール側では気にしたくないなと思ったため、このようにAPIClientという粒度でインジェクトするという決断をしました (インジェクトする粒度はもっとベストな形があるのかもしれない、実際にAndroidとはここらへんの作りに微妙な差異が出てしまっている、など今後の課題としてやっていきます)。

以下は実際にそのエンドポイントを差分を埋めるためのアプリケーションターゲット側のコードになります。

アプリA

final class SomeFeatureEnvironmentImpl: SomeFeatureEnvironment {
    
    let orderID: Order.ID
    
    init(orderID: Order.ID) {
        self.orderID = Order
    }
    
    var apiClient: SomeFeatureAPIClient {
        SomeFeatureEnvironmentImpl(orderID: orderID)
    }
}


struct SomeFeatureAPIClientImpl: SomeFeatureAPIClient {
    
    let orderID: Order.ID
    
    init(orderID: Order.ID) {
        self.orderID = Order
    }
    
    func fetchSomeList() -> Single<[Some]> {
        APIManager.getSomeList(orderID: orderID)
    }
}

アプリB

final class SomeFeatureEnvironmentImpl: SomeFeatureEnvironment {
    
    let chatID: Chat.ID
    
    init(chatID: Chat.ID) {
        self.chatID = Chat
    }
    
    var apiClient: SomeFeatureAPIClient {
        SomeFeatureEnvironmentImpl(chatID: chatID)
    }
}


struct SomeFeatureAPIClientImpl: SomeFeatureAPIClient {
    
    let chatID: Chat.ID
    
    init(chatID: Chat.ID) {
        self.chatID = Chat
    }
    
    func fetchSomeList() -> Single<[Some]> {
        APIManager.getSomeList(chatID: chatID)
    }
}

UIComponentモジュール

ANDPADのiOSアプリ開発ではこれまでxibstoryboardでUIを構築することがほとんどだったのですが、コードでUIを構築したいというメンバーも増えてきたため、ここではコードでUIを書くこととしました。

コードでUIを構築する際のデメリットである「意図した通りのUIが構築できているのかを視覚的に確認できない」については、メルカリさんのブログを参考に、XcodePreviewsを用いてコードで書いたUIを随時プレビューで確認しながら開発しています。

XcodePreviewsについてはパフォーマンス(表示されるまで遅い)や高頻度でエラーが発生して使い物にならないという声を聞くのですが、まだファイル数が少ないからか(20ファイル前後)あまり個人的には気になっていないというのが現状です。1つのビューに対して各ステート毎のプレビュー並べて表示して確認できるというのは本当にパワフルだな感じました。

機能モジュールはどこまでサードパーティライブラリに依存してOK?

またコードでUIを構築するというところでチーム内で議論したことは、「AutoLayoutを書きやすくするライブラリ(具体的にはSnapKIt)」を利用するかどうかです。

ここはいまでもチーム内で意見は割れているところなのですが、自分としてはコードを少し書きやすくするメリットよりも、ライブラリを増やすことによる管理コストのデメリットのほうが大きいと判断して、そのようなライブラリは今回は入れないという判断をしました。

機能モジュール

機能モジュールでは、CoreとUIComponentを利用して、機能の実装を行っていきます。

public final class SomeFeatureListViewController: UIViewController {
    
    private let viewModel: SomeFeatureListViewModel
    private let environment: SomeFeatureEnvironment
    
    public init(environment: SomeFeatureEnvironment) {
        self.environment = environment
        self.viewModel = SomeFeatureListViewModel(
            apiClient: environment.apiClient
        )
        super.init(nibName: nil, bundle: nil)
    }
    
    private func showOtherFeature(otherID: Other.ID) {
        environment.router.showOtherFeature(from: self, otherID: otherID)
    }
}

アーキテクチャとしては、ViewControllerViewModelのみの「薄い」MVVM構成になっています。(ここでいうViewModelの責務は、データバインディング、プレゼンテーションロジック、APIClient呼び出し、など)

ANDPADの主要アプリ(今回切り出したモジュールを元々含んでいた)が、もっとも歴史があるとういこともあり「まだまだFatMVCがたくさんある」「ViewModelが存在するところもあるが役割としてはただのレポジトリのようなもの?」「なのでユニットテストもまだまだごくわずか」という状況だったため、まずはスモールステップとして

  • ビューとプレゼンテーションロジックはしっかり分けよう
  • プレゼンテーションロジックはユニットテストを書こう

のようなことを実現すべく、目的に沿った最小構成にしたつもりです。

なので、ViewModelのプレゼンテーションロジックには可能な限りユニットテストを書いたので、ViewModelのみに限定すれば比較的高いカバレッジを達成できたのではと思っています。

ViewModelだけでみればカバレッジはまあまあ高く保てた

アプリケーションターゲット

アプリケーションターゲット側で実装することは、「Environmentの実体を実装して、それを機能モジュール呼び出し時に渡す(インジェクトする)」だけになります。

Environmentの実体

(上の「なぜAPIClientをインジェクトするのか?」ものと同じ)

final class SomeFeatureEnvironmentImpl: SomeFeatureEnvironment {
    
    let orderID: Order.ID
    
    init(orderID: Order.ID) {
        self.orderID = Order
    }
    
    var apiClient: SomeFeatureAPIClient {
        SomeFeatureEnvironmentImpl(orderID: orderID)
    }
}


struct SomeFeatureAPIClientImpl: SomeFeatureAPIClient {
    
    let orderID: Order.ID
    
    init(orderID: Order.ID) {
        self.orderID = Order
    }
    
    func fetchSomeList() -> Single<[Some]> {
        APIManager.getSomeList(orderID: orderID)
    }
}

機能モジュールの呼び出し

    func showSomeFeature(orderID: Order.ID) {
        let environment = SomeFeatureEnvironmentImpl(orderID: Order.ID)
        let vc = SomeFeatureLIstViewController(environment: environment)
        self.present(vc, animation: true)
   }

ミニアプリについて

マルチモジュール化の話でよく話に出るのは「ミニアプリ」だと思います。

ミニアプリとは簡単に言ってしまえば、機能モジュール単体で動くアプリのことと認識しています。何が嬉しいのかというと、機能モジュール単体で動くので「極めて軽量」なアプリになるので、ビルド時間がめちゃくちゃ早い、よって開発体験爆上がり、ということだと思います。

ミニアプリを用意する

弊社ではまだSPMへの移行があまり進んでおらず、今回の機能モジュールもCocoapodsで現状管理しているということもあり、機能モジュール自体も$pod lib createコマンドで作成しました。 $pod lib create実行時に「デモアプリも一緒に作りますか?」と聞かれるので、それに従えばミニアプリ自体は勝手に作られるので、ミニアプリのsetupについては簡単に行うことができました。

ミニアプリを用いた開発の所感

ミニアプリを用いた開発の所感をざっくばらんに箇条書きしてみたいと思います。

  • ビルド時間はやはり早い(自分の手元で4secくらい)
  • 上述したように「APIClientをインジェクト」する形なので「スタブを用意」しなくてはいけない
  • これは手間でもある反面、スタブを自由に作って切り替えることで、「空のとき」「エラーのとき」などの動作確認を簡単に行うことができます
  • 「こういった条件のときは、こういったレスポンスが返ってくる」などバックエンドとのやりとりの仕様が複雑なときは、全てのパターンのスタブを用意するわけにもいかないので、そこは諦めるしかないかなと思った
  • デザインチェック・修正などでかなり有用だったという印象

最後に

ということで、ANDPAD iOS開発における課題と最近取り組んでいる「マルチモジュール化」について書いてみました。

今回ブログで書いた解決手法が果たして正解 or 好手だったのかはまだわかりませんが、ANDPADのアプリ開発における課題はあまり他の会社でみないようなものと認識していて、手探りでよりよい解決案を見つけようとできることは非常にやりがいがあるなと感じています。

が、正直なところメンバーがまだまだ足りておらず、もっとやりたいことがたくさんあるのにやれていない状態です。 「建築DX」という巨大な課題に対して、一緒に解決策を見つけていけるようなアプリエンジニアを絶賛募集しております!!!

engineer.andpad.co.jp