小さなチームでマルチモジュール開発をしてみた話

こんにちは、iOSチームの阿久津(@sky_83325)です。

今回も前回の記事に引き続き、マルチモジュール開発についての記事です。

タイミーでは2019年7月より、機能単位でFrameworkを分割するマルチモジュール開発に取り組んできました。 現在では全体の約8割がモジュール分割され、27個のモジュールよりアプリが構成されています。

ここ数年でiOSにおけるマルチモジュール開発に対する関心が高まり、多くのプロジェクトで導入され始めているのではないでしょうか。

マルチモジュール化することで次のような恩恵を受けることが出来ます。

  • ビルド時間の軽減
  • 影響範囲の限定
  • ミニアプリを用いた機能単体での動作確認

このような大きな恩恵がある一方で、なんとなく難しそうなイメージがあったり、大規模な開発チームでのみ採用されてる印象があり、導入を見送られているチームも多いのではないでしょうか。

タイミーでは、1~3名という小規模なチームでマルチモジュール開発を続けてきました。 この記事では、実際にそのような小さなチームで運用してみて感じたこと、学んだことを共有したいと思います。

マルチモジュール開発に興味のある方や、実際に導入を検討されている方の参考になれば幸いです。

マルチモジュール開発とは

マルチモジュール開発とは、ある機能や責務を持ったレイヤーを独立したモジュールとして実装し、それらを組み合わせて1つのiOSアプリを開発することをいいます。そして、それらのモジュールはFrameworkやLibrary、Swift Packageといった形で管理されます。

他方モノリシックな開発では、ある機能や責務を持ったレイヤー全てが1つのモジュールで実装されます。そして、それらの機能や責務は1つのディレクトリとして管理されることが多いです。

言い換えれば、マルチモジュール開発ではある責務を持ったコードをディレクトリとしてではなくSwift PackageやFramework、Libraryといった形式で管理することになります。

そして、モジュール化することでネームスペースが完全に分かれそれぞれの実装を蘇結合に保つことが出来たり、必要な部分のみビルドを実行できたり、特定の機能/役割を再利用したりすることが可能になります。

取り組んだ背景・経緯

当時のタイミーはリリースから1年が経ち、主要機能の開発が落ち着き始めていたタイミングでした。 同時に、ビルド時間の増加などいくつかの問題に悩まされており、それを解決する手段としてマルチモジュール開発に興味を持ちました。

当時考えていた、マルチモジュール化の目的は以下の2つでした。

  1. ビルド時間の短縮
  2. ミニアプリの導入による開発時のデバッグ効率の向上

目的① ビルド時間の短縮

機能の増加に比例して、ビルド時間が継続的に伸びてしまっていました。 細かなViewの調整に対しても一定のビルド時間がかかってしまうことは時間的にも、開発体験的にもかなり辛いものになっていました。そのような状況の中、必要なモジュール単位でビルドをし、動作確認を出来るマルチモジュール開発に大きな魅力を感じました。

目的② ミニアプリの導入による開発時のデバッグ効率の向上

またマルチモジュール化に伴い、機能単位でデバッグを可能にするミニアプリも導入も検討しました。 機能によっては、その画面へたどり着く条件が限られており、動作確認が難しくなってしまっている場合があります。

例えば、ウォークスルーの場合は初回起動時にしか表示されないため、毎度アプリを削除し、再インストールする必要があります。その他にも、商品購入後にしか辿り着けない画面があった場合は、デバッグの度に商品の作成&購入をする必要があります。

ミニアプリを作り、特定の機能に直接アクセス出来るようにすることでこのような障壁を取り除けることに魅力を感じました。

この2つが主なマルチモジュール化へ移行した理由ですが、それに加えて純粋に楽しそうだったからと言うのも1つの大きな理由です。

どのように移行していったのか

構成はFeature x Layer形式を採用

アプリをモジュール分割する際に、どのような構成で分割するかを決める必要があります。 機能(Feature)単位でモジュールを分割する方法や、Domain LayerやData Layer、Presentation Layerというような一定の責務を持ったレイヤー単位で分割する方法がありますが、タイミーでは「ミニアプリの導入による開発時のデバッグ効率の向上」をマルチモジュール化の1つの目的としていたため、大きな方針としては機能単位でモジュールを分けることにしました。また、既存のモノリシックなアプリ構成も機能単位でフォルダ分けをしていたため、機能単位で分割する方が適していたということも理由の1つです。

基本的には機能単位でモジュールを分割しつつ、機能を横断して共通で使いたい部分は別途モジュールとして切り出しました。 例えば、通信を担当する箇所や、アプリ全体で利用するモデル(Entity)などです。

具体的には次のような構成を目指しました。

f:id:takeshi__akutsu:20211028143105j:plain

課題① 既存の密結合な実装

既存のアプリ構成も機能単位でフォルダを分けていたため、モジュール分割も容易かと思われましたが実際は違いました。 機能間でシングルトンなオブジェクトを共有していたりなど、所々密結合な実装が存在していました。 そのため、既存のプロジェクトを一気にモジュール化することは出来ませんでした。

以下のような手順で徐々にモジュール分割を進めていく方針を取りました。

  1. 各機能で共通して使う部分をモジュールに切り出す
  2. どこか1つの機能をモジュールに切り出す
  3. 以降は新規実装やリファクタリングのタイミングで継続的にモジュール分離する

はじめに、機能横断で利用したい共通部分をモジュール化し、その後1つの機能を実際にモジュール化してみました。 ここまでを最初の段階とし、それ以降はリファクタリングのタイミングであったり、新規開発の際に徐々にモジュール移行するようにしました。

そのように順番に移行することでモジュール化された部分には、既存の負債が混ざらないようにしました。

課題② 各機能間の画面遷移をどうするか

徐々に機能をモジュール分割する中で、各機能間の画面遷移をどう実装するかが問題になりました。

一番シンプルな解決策としては、遷移先のモジュールをimportし、遷移先のViewControllerを直接呼び出すことです。機能Aから機能Bへ一方向にしか遷移しないのであればこのような実装方法でも大丈夫かもしれませんが、必ずしもそれが保証されるとは限りません。また、各機能を疎結合に保つためにも、機能モジュール間で依存することは望ましくありません。

そのため、全てのモジュールを知っているAppに画面遷移を委ねる必要があります。 画面遷移のロジックを抽象化したインターフェースを定義し、それをAppから各機能モジュールへ注入することで、機能間の画面遷移を実現しました。

具体的には次のように行いました。

  1. Routerのインターフェースを全ての機能モジュールが依存している箇所へ定義する
  2. 各機能モジュールではそのインターフェースを呼び出して画面遷移を実行する
  3. Appでそのインターフェースの実装をする。
  4. 各機能モジュールへそれを注入する。

f:id:takeshi__akutsu:20211028201116j:plain

このようにマルチモジュール開発における依存関係は、うまく抽象に依存させながら解決していくことになります。

実際に運用してみてどうだったのか

マルチモジュール化を運用してみて、当初想像していた以上に様々な恩恵を受けることが出来たのでそれらを紹介していきます。

ビルド時間が短縮された

ある機能を動かすために必要最低限のモジュールのみをリンクしたミニアプリを用意することで、開発時に都度アプリ全体をビルドする必要がなくなりました。その結果、開発時のビルド時間が大幅に削減されました。

また、テストの実行もモジュール単位で行えるため、時間が大幅に短縮されました。

開発時の動作確認が容易になった

ミニアプリを用いることでウォークスルーなどの特定の条件下でしか表示されない画面などにも直接アクセスできるようになったため、簡単に動作確認を行えるようになりました。

f:id:takeshi__akutsu:20211029031941g:plain

さらに、コンポーネント化されたViewを確認する開発時専用のアプリを作ることで、より開発効率が向上されました。

実装が矯正された

モジュール化することで、より一層レイヤーや依存を意識した開発をする必要が出てきます。 全ての機能を単一モジュールで実装している場合、意図せず、もしくは妥協の末、密結合な実装をすることが出来てしまいます。例えば、シングルトンなインスタンスを作成し機能を跨いで共有することなどです。 しかし、モジュール分割することでそのようなスコープを超えた実装を仕組みとして防ぐことが出来ます。

さらに、そのように「実装の自由」を制限することで、自ずと各モジュールの責務を深く検討するように(検討せざるを得なく)なりました。結果として、プロジェクト全体のコード品質が上がりました。

仕組みとして疎結合な実装が保証された

前述したように、モジュール分割することで疎結合な実装が仕組みとして強制されます。その結果、変更の影響範囲ががそのモジュール内に限定されます。それによりいくつかの恩恵を得ることが出来ました。

1. 動作確認(QA)のコストが減った

基本的に、あるモジュールを変更した場合は、そこを部分的に動作確認するのみでよくなりました。毎回のリリース時の動作確認コストが減った結果、より継続的に高頻度でリリースすることが可能になりました。

2. 実装のキャッチアップが容易になった

新しくメンバーを採用した場合、そのメンバーが初めての開発の際にキャッチアップすべき領域も限られるため、効率的にオンボーディングを行うことが出来るようになりました。

3. 負債が限定された

また、一部の機能や、実装に負債があったとしても、影響範囲がそのモジュール内に限られているため、ほかに負債が伝播することもありません。また、1つ1つのモジュールは小さく出来ているため、最悪そのモジュールを切り捨て、再実装することも可能です。

このように疎結合な実装が仕組みとして求められるようになり、様々な恩恵を得られるようになりました。

終わりに

今まではEmbeded Frameworkを利用したマルチモジュール化が主流でしたが、最近だとSwift Package Managerを利用しSwift Packageとしてモジュールを管理することが可能になっています。Swift Packageとしてモジュールを管理する場合、 Package.swift に各モジュールの依存関係を記載していくことになりますが、それ以外は通常のモノリシックなアプリ開発でフォルダを分割するのとほぼ似たような感覚でモジュール分割を行えるため、今まで以上にマルチモジュール化のコストが下がりました。

Swift Packageを利用したマルチモジュール開発に関しては以下の記事にもまとめてありますので、是非参考にしてみてください。

tech.timee.co.jp