コード生成を用いたiOSアプリマルチモジュール化のための依存解決

こんにちは、モバイル基盤部の@giginetです。

iOS版のクックパッドアプリでは、2019年頃より、大規模なアプリを複数のモジュールに分割するマルチモジュールの導入を進めてきました。

今回はクックパッドアプリのマルチモジュール化の戦略について、主に依存関係の解決という点に焦点を当てて紹介します。

クックパッドアプリとマルチモジュールプロジェクト

iOS版のクックパッドアプリはコード量が多く、膨大なビルド時間が問題となっていました。また、同時に関わる開発者も多く、それぞれの機能間を疎結合にしたいという需要が大きくありました。

この問題を解決するために、2019年の初頭からiOSアプリのマルチモジュール化プロジェクト*1を開始しました。 以来、ここ2年で、モジュール分離を前提とした開発が大きく進みました。

現在では、アプリ全体のコードのうち、半分以上がモジュール分離され、アプリ全体が約25個のモジュールに分割されています。

これまでのマルチモジュールの取り組みは、2019年に開催されたCookpad Tech Conf 2019の講演「〜霞が関〜 クックパッドiOSアプリの破壊と創造、そして未来」で紹介しています。

以下は上記の講演動画の書き起こし記事です。この記事を読む前にご覧いただけると、より理解しやすくなると思います。

モジュール分離には、分割の方法や粒度、移行プロセスなど、様々なトピックがありますが、この記事ではとりわけ、大きな障害となるモジュール間の相互の依存関係解決のための仕組みについてお伝えします。

クックパッドアプリのモジュール構成

まず始めに、前提となるアプリケーション全体の構成について説明します。

クックパッドアプリのモジュール構成は概ね以下のような図で表すことができます。

f:id:gigi-net:20210615192428j:plain
クックパッドアプリのモジュール構成

下の層はCoreモジュールと呼ばれています。図中でCookpadCoreやCookpadComponentと呼ばれているモジュールです。 Coreモジュールは抽象化のためのインターフェイスや、アプリ内で共通して使うUIコンポーネントを提供しています。

その上の層はFeature Moduleと呼ばれる層です。検索、レシピ投稿、つくれぽ、買い物機能など、機能単位をひとまとまりとするモジュールに分離されています。 図中にある黄色い四角は、シーンと呼ばれる単位です。アプリ内の1画面に相当します。 各シーンは、VIPERアーキテクチャにより実装されています。Feature Moduleは、ドメイン層を共有する複数の(数個の)シーンの集まりということができます。

f:id:gigi-net:20210615192457p:plain
Feature ModuleにおけるVIPERアプリケーションの構成

最上位のCookpadはアプリケーションです。 Xcodeプロジェクトにおける、アプリケーションターゲットに相当します。アプリケーションは全てのモジュールの実装を知ることができます。

単機能ビルドを重視したモジュール構成

モジュール分離の議論でしばしば話題に挙がるのが、アプリケーション全体をどのように分割し、再構成するかという点です。

我々のモジュール分割の大目的はビルド時間の削減にありました。

そのため、アプリ全体をビルドせずとも開発できるように、モジュール1つを単体起動できる構造を重視しています。

このような構成になっていることで、Feature Module単体のみを取り出し、個別にビルドすることが可能となりました。

この構成を生かした結果が、Sandboxアプリという動作確認用のミニアプリです。Feature Aの開発を行う場合は、CoreモジュールとFeature Aのビルドのみで動作確認ができるようになりました。

f:id:gigi-net:20210615192522p:plain
Sandboxアプリの構成

これにより、単機能のみのビルドと実行を行うことができるようになり、開発効率が大きく改善されています。

詳しくは以下の記事で紹介しています。併せてご覧ください。

マルチモジュール化における依存関係解決の必要性

巨大なアプリを複数のモジュールに分割する際に問題になるのは、依存関係の解決です。

f:id:gigi-net:20210615192553p:plain
Feature Module同士の参照は制限されている

我々のアーキテクチャでは、同じレイヤー上のモジュールが、お互いに参照を持つことを制限しています。もし両方向に依存してしまうと即座に循環参照が発生してしまうからです。その代わり、循環参照を避けるための依存抽象化の仕組みを導入しています。

複数のFeature Moduleが互いに連携する例を考えてみましょう。 例えばBモジュールで実装されているシーンを、Aモジュールで表示するというのが代表的な例です。

これを実現するために、どのような抽象化を実現しているのでしょう。

Environment 〜モジュール外への依存を簡単に注入できるためにするコンテナ〜

各シーンはEnvironmentという、依存関係を取り出すためのDIコンテナにアクセスすることができます。

Environmentは、外部とのI/Oや外部ライブラリ、モジュール間の連携といった、別のモジュールとの連携を持つ必要がある実装を抽象化する役目を果たしています。 各シーンは、他のモジュールの実装を用いたいときや、ネットワーク通信など、外部との入出力が発生するとき、Environmentを用いて依存にアクセスします。

アプリケーションターゲットは、Environmentに具体的な実装を注入する役目を持ちます。

f:id:gigi-net:20210615192615p:plain
Environmentを使った依存関係解決の仕組み

Environmentは動作環境によって差し替えることができます。例えばアプリ起動時やテスト開始時など、環境の起動時に生成して全てのシーンに渡されます。 これにより、フルビルドしたアプリケーション、Sandboxアプリ、ユニットテスト、UIテストなど、様々な環境で異なるEnvironmentを用意することで、依存の注入を簡略化しています。

ResolverとDescriptor 〜依存を抽象化して取り出すための仕組み〜

先に述べたとおり、それぞれのFeature Moduleはお互いを知ることができないため、他のモジュールの実装を取り出すためには、Environmentを経由する必要があります。 この仕組みをResolverと呼んでいます。

同時に、Resolverに特定の実装を示すためのマーカーをDescriptorと呼んでいます。このDescriptorはCoreモジュールに存在します。 そのため、あるモジュールは他のモジュールにある実装を知ることができませんが、その実装を示すDescriptorはどこからでも知ることができます。

他のモジュール上にある実装を取り出したい場合、Descriptorを用いて、Environmentに実装の取得を要求します。 アプリケーションターゲットは全てのモジュール上の実装を知っていることを利用し、アプリケーションターゲットは具体的な実装をEnvironmentに注入し、protocolで抽象化して返します。 その結果、実装を取り出したいモジュールからは、protocolを用いて抽象化された実装を取り出すことができます。

開発しやすいResolverのための課題

このResolverの仕組みを、シンプルに、ミス無く維持するためには何が必要でしょうか。大きく、以下のような点が議論に挙がりました。

型安全性

Resolverから取得した値を任意の型として型安全に使いたい

コンパイル時の網羅性の検証

あるDescriptorに対応したResolverの実装漏れを自動的に検出できるようにしたい

利用の簡便さ

開発時のグルーコードの記述量を減らしたい。また、実装に迷わないようにしたい

コード生成を用いた型安全なResolver

これらを満たすために、コード生成を用いてResolverをある程度自動生成する仕組みを実現しました。

ここからは、その仕組みを使った、モジュール間依存解決の例を見ていきましょう。

Coreモジュール

まず、開発者はDescriptor構造体をCoreモジュールに定義します。

これは、レシピIDを元に、該当のレシピ詳細シーン(RecipeDetails)のUIViewControllerを取り出すためのDescriptorです。

extension ViewDescriptor {
    public struct RecipeDetailsDescriptor: TypedDescriptor {
        public typealias Output = UIViewController
        public var recipeID: Int64

        public init(recipeID: Int64) {
            self.recipeID = recipeID
        }
    }
}

Descriptor構造体は、依存を取り出すために必要なパラメータと、取りだしたい依存の型(Output)を持ちます。

コードの自動生成

ビルドシステムは、ビルド時に、Coreモジュールに含まれるDescriptorを探索し、それらを使うResolverを実装するように促すprotocol、ConcreteViewResolverを自動生成します。 ConcreteViewResolverは、定義されている全てのDescriptorに関するresolveメソッドを持つprotocolです。

// 自動生成される
public protocol ConcreteViewResolver {
    func resolveConcrete(_ descriptor: ViewDescriptor.RecipeDetailsDescriptor) -> ViewDescriptor.RecipeDetailsDescriptor.Output
}

コード生成にはSourceryという、Swiftのコード生成を行うユーティリティを利用しています。

この仕組みにより、存在する全てのDescriptorに対してのResolverの実装が、コンパイル時に強制されるため、Resolverの実装漏れを防ぐことができます。

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

次に、開発者はアプリケーションターゲットにおいて、ConcreteViewResolverに適合したEnvironmentを実装します。

extension CookpadEnvironment: ConcreteViewResolver {
    func resolveConcrete(_ descriptor: ViewDescriptor.RecipeDetailsDescriptor) -> ViewDescriptor.RecipeDetailsDescriptor.Output {
        RecipeDetailsViewBuilder.build(with: descriptor, environment: self) // レシピ詳細シーンを生成する
    }
}
利用したいモジュール

最後に利用したいモジュールからResolver経由で実装を取り出します。

let destinationViewController: UIViewController = environment.resolve(ViewDescriptor.RecipeDetailsDescriptor(recipeID: 42))
currentViewController.present(destinationViewController, animated: true, completion: nil)

このとき、取り出した型はDescriptor.Outputに指定した型にダウンキャストされます。これにより、利用者は型安全に他のモジュールの実装を取り出すことができます。

型安全に実装を取り出すための仕組み

ところで、上記のようなダウンキャストはどのように動作するのでしょうか。これも自動生成されたextensionで実現されています。

ConcreteViewResolver protocolを生成するタイミングで、Environmentの方には、任意のDescriptorをダウンキャストするresolveメソッドが自動生成されます。

このメソッドは、先ほど実装したresolveConcreteをそれぞれのDescriptorごとに呼び出し、Descriptor.Outputにダウンキャストします。

アプリケーションターゲット(Cookpad)
// このコードも自動生成される
extension CookpadEnvironment {
    func resolve<Descriptor: TypedDescriptor>(_ descriptor: Descriptor) -> Descriptor.Output {
        switch descriptor {
        case let recipeDetailsDescriptor as ViewDescriptor.RecipeDetailsDescriptor:
            return resolveConcrete(recipeDetailsDescriptor) as! Descriptor.Output
        case let anotherDescriptor as ViewDescriptor.AnotherDescriptor:
            return resolveConcrete(anotherDescriptor) as! Descriptor.Output
        // 以下省略...
        default:
            // 全てのケースをコード生成で網羅しているので、ここには到達し得ないはず
            fatalError("Unknown descriptor!")
        }
    }
}

この巨大なswitch文をコード生成することで、存在する全てのDescriptorがケースとして網羅されます。 これにより、コンパイル時に、全てのDescriptorに対するResolverの実装が保証されるのです。

型安全なインターフェイスの取得

この際、実際にResolverから返されるViewControllerは RecipeDetailsViewController ですが、これを取得した他のモジュールからは単なるUIViewControllerとして見えます。

これは、RecipeDetailsViewControllerを含むモジュール内でしか、この具体的な型を知ることはできないためです。

各ViewControllerに機能を持たせたい場合、この仕組みにより、任意のインターフェイスを公開し、取得することもできます。

Coreモジュール(CookpadCore)

Coreモジュール内に、ViewControllerのうち、公開したいインターフェイスのみを含むprotocolを追加しましょう。

public protocol RecipeDetailsViewControllerProtocol {
    var recipeID: Int64 { get }
}

このとき、具体的な実装はこのViewControllerが実装されているFeature Module内に存在します。Coreモジュールは実装を持っていません。

その後、先ほどのようにDescriptorを実装します。このとき、Outputの型としてRecipeDetailsViewControllerProtocolを返すように指定しています。

extension ViewDescriptor {
    public struct RecipeDetailsDescriptor: TypedDescriptor {
        public typealias Output = UIViewController & RecipeDetailsViewControllerProtocol

        // ...
}
利用したいモジュール

同様に利用したいモジュールからはresolverを用いて実装にアクセスできます。このとき、返却される型は Descriptor.Output に指定した型として扱われます。

let destinationViewController: UIViewController & RecipeDetailsViewControllerProtocol = environment.resolve(ViewDescriptor.RecipeDetailsDescriptor(recipeID: 42))
destinationViewController.recipeID // 42

これにより、他のモジュールはResolver経由で実装を任意のprotocolに適合した形として取り出すことができました。


このように、Resolverの実装にコード生成を用いることで、型安全性を保ちながら、コンパイル時に網羅性を担保し、実装ミスが起こらないようにすることができました。

また、開発者はDescriptorと、それに対応するResolverを用意するだけでよいので、Resolverの実装コストを減らすこともできました。

ドメイン層へのDescriptorの拡張

今回の例では、簡単のため、画面遷移にResolverを使う例をご紹介しました。

我々のアプリでは、DataStoreやUseCaseといった、ドメイン層の依存解決のためにもResolverを解放しています。

例えばAモジュールで実装されているUseCaseをBモジュール内の実装で扱いたいという例です。 Viewの例と同様に、公開したいインターフェイスのみをprotocolとして抽象化し、DataStoreDescriptorとして同様の仕組みを実現しています。

この仕組みにより、モジュール間での任意の依存解決を簡単に実現できるようになりました。

さらにマルチモジュールについて知りたい方へ

今回の記事では一部しか紹介することができませんでしたが、いくつかの記事やイベントで、クックパッドアプリのマルチモジュール戦略についてご紹介しています。

冒頭に紹介した「Cookpad TechConf 2019 〜霞が関〜 クックパッドiOSアプリの破壊と創造、そして未来」では、今回紹介したマルチモジュール戦略の全体像を説明しています。

先日、5/26には、「Cookpad Lounge #3 クックパッド iOS アプリを爆速で開発できるようにする話」というイベントで、座談会形式でマルチモジュールの話をしました。 視聴者の方から多くの質問を頂き、ありがとうございました。アーカイブが以下で公開されています。

来る6/16 19:30より、メルペイさんが主催の「iOS Tech Talk 〜 Multi module 戦略座談会 〜」というイベントで、各社のマルチモジュール戦略について座談会を行います。 この中でもクックパッドアプリのマルチモジュール戦略についてご紹介するつもりです。ご興味のある方はぜひ遊びに来てください。

まとめ

今回は、クックパッドアプリのマルチモジュール化のうち、特に依存関係解決の仕組みにフォーカスしてご紹介しました。

マルチモジュールは、iOS界隈でも関心度の高いトピックなため、今後も発信の機会を作っていきます。

また、ご質問がある場合は@giginet までお気軽にお寄せください😄

クックパッドでは、大規模なアプリのリアーキテクチャを行いたいエンジニアを募集しています。

*1:霞が関プロジェクトと呼んでいます

/* */ @import "/css/theme/report/report.css"; /* */ /* */ body{ background-image: url('https://cdn-ak.f.st-hatena.com/images/fotolife/c/cookpadtech/20140527/20140527163350.png'); background-repeat: repeat-x; background-color:transparent; background-attachment: scroll; background-position: left top;} /* */ body{ border-top: 3px solid orange; color: #3c3c3c; font-family: 'Helvetica Neue', Helvetica, 'ヒラギノ角ゴ Pro W3', 'Hiragino Kaku Gothic Pro', Meiryo, Osaka, 'MS Pゴシック', sans-serif; line-height: 1.8; font-size: 16px; } a { text-decoration: underline; color: #693e1c; } a:hover { color: #80400e; text-decoration: underline; } .entry-title a{ color: rgb(176, 108, 28); cursor: auto; display: inline; font-family: 'Helvetica Neue', Helvetica, 'ヒラギノ角ゴ Pro W3', 'Hiragino Kaku Gothic Pro', Meiryo, Osaka, 'MS Pゴシック', sans-serif; font-size: 30px; font-weight: bold; height: auto; line-height: 40.5px; text-decoration: underline solid rgb(176, 108, 28); width: auto; line-height: 1.35; } .date a { color: #9b8b6c; font-size: 14px; text-decoration: none; font-weight: normal; } .urllist-title-link { font-size: 14px; } /* Recent Entries */ .recent-entries a{ color: #693e1c; } .recent-entries a:visited { color: #4d2200; text-decoration: none; } .hatena-module-recent-entries li { padding-bottom: 8px; border-bottom-width: 0px; } /*Widget*/ .hatena-module-body li { list-style-type: circle; } .hatena-module-body a{ text-decoration: none; } .hatena-module-body a:hover{ text-decoration: underline; } /* Widget name */ .hatena-module-title, .hatena-module-title a{ color: #b06c1c; margin-top: 20px; margin-bottom: 7px; } /* work frame*/ #container { width: 970px; text-align: center; margin: 0 auto; background: transparent; padding: 0 30px; } #wrapper { float: left; overflow: hidden; width: 660px; } #box2 { width: 240px; float: right; font-size: 14px; word-wrap: break-word; } /*#blog-title-inner{*/ /*margin-top: 3px;*/ /*height: 125px;*/ /*background-position: left 0px;*/ /*}*/ /*.header-image-only #blog-title-inner {*/ /*background-repeat: no-repeat;*/ /*position: relative;*/ /*height: 200px;*/ /*display: none;*/ /*}*/ /*#blog-title {*/ /*margin-top: 3px;*/ /*height: 125px;*/ /*background-image: url('https://cdn-ak.f.st-hatena.com/images/fotolife/c/cookpadtech/20140527/20140527172848.png');*/ /*background-repeat: no-repeat;*/ /*background-position: left 0px;*/ /*}*/