SansanでAndroidアプリケーションエンジニアをしている山口 です。Sansan Androidではマルチモジュールへの移行を徐々に進めています。今回はappモジュールからどうマルチモジュールに移行しているかについて書きたいと思います。
マルチモジュールにする意義
なぜAndroidアプリではマルチモジュールにする流れができているのでしょうか。 弊社山本の記事があるので貼っておきます。
差分ビルドの高速化やDynamic Feature Moduleへの対応などのメリットがあります。それらに加えて個人的に一番のメリットは依存関係が強制化されることによってアプリ内の依存関係が整理され、必要なコードにのみ依存することで可読性・メンテナンス性が向上することかなと思っています。
SansanではAndroidのメンバーがここ2年で急激に増え、機能がどんどん増えてきました。自分が一切関わっていないコードもしだいに増え、コードのコンフリクトが生じることも増えてきました。モジュールを機能ごとに分け互いに依存しないようにすればより他機能へ影響することがなく開発が進められますし、自分が関わっていない機能を担当するときにモジュールのみに着目すればよいのでキャッチアップしやすくなります。
マルチモジュールのステップを考える
まずマルチモジュールのゴールを決め、どう進めていくか考えます。
最終的なゴールとしては、機能ごとの縦割りの分割およびデータアクセスのレイヤーごとの横割りの分割が行われている状態です。この状態に向けてモジュール分割を進めていく訳ですが、モジュールへの分割は機能開発ではないため直接的に大きくユーザに価値をもたらすことはできません。そのため機能やレイヤーごとにきれいに分離された最終的なゴールまでのモジュール分割を行うのではなく、まず普段の機能開発の中で大きな工数を使わず自然と分割できる状態に最短で持っていくまでが最初のゴールかなと思います。
基本的なルールとしてモジュール間で循環依存が発生するとビルドエラーになるので、依存される側からどんどんモジュールに分割していきます。
最初のモジュール分割
今回まず最初のステップとして多くのコードから依存されているドメイン部分や共通リソース、Utilityクラス群をモジュールとして分離しました。これらのクラスは循環依存がなく楽に分割できます。そのため普段の開発の中でも行えると思います。
ここでのポイントとしては「普段の機能開発の中で大きな工数を使わず自然と分割できる状態にまで持っていく」を優先的に考え、依存性を考えて更に細かくモジュール分割はしませんでした。モジュール分割は他の開発者にも影響を与えるため最短で細かくマージすることを心がけます。
データアクセス部分のモジュール分割
次に機能部分から依存されるAPIやローカルデータへのアクセス部分を分離しました。この辺りから循環依存が発生し始めます。Utilityモジュールへの移動から抜け漏れた共通クラスや拡張関数、アノテーション、共通のユーザトラッキングなどがappモジュールに残りっぱなしになっており、データアクセス部分を別モジュールにしてappモジュールから依存すると循環依存になっていました。都度Utilityモジュールへの移動や専用のモジュールを作るなどして切り出していきました。
ここまで行くと新機能からはファイルをappモジュールに置かず、新機能用のモジュールに置いて開発を進めることができます。
legacyモジュールの作成
最後のステップはappモジュールからDIやアプリケーションクラスを除くすべてのコードを別モジュールに移動する、いわゆるlegacyモジュールの作成になるかと思います。ここまでいければあとは各機能開発の中でモジュール分割できるようになります。
この作業は元のコードの依存関係次第ですが、1プロジェクトとして切り出して時間を取ったほうが良いと思います。状況によっては、機能開発のプロジェクトの中で行うと機能開発のはずがこの作業だけで数週間かかることになり、本来の機能開発で必要な工数と乖離が出てきてしまいます。
legacyモジュール作成のつらみ
SansanのAndroidアプリの場合、まず最初に困ったのがApplicationクラスのインスタンスにstaticにアクセスしてDaggerにinjectしていた大量のコードです。Applicationクラスはappモジュールに置いたままになるのでApplicationクラスに依存すると循環依存になります。この部分だけで100件近くあり、1つずつconstructor injectionに変更しました。
変更前
class HogePresenter { init { SansanApplication.instance.component.inject(this) } @Inject lateinit var fuga: Fuga }
変更後
class HogePresenter @Inject constructor( private val fuga: Fuga ) { }
次に困ったのがEpoxyや一部ViewHolderで R.layout
を固定値として参照している箇所です。R.layout
はライブラリモジュールになると可変になるためビルドが通らなくなります。
下記資料66ページ辺りに解決方法があり、参考にさせていただきました。
ファイル移動を効率的に行う
今回失敗だったなと思ったのは、細かくマージすることを意識するあまりappモジュールの中から依存される側のコードを探してlegacyモジュールに移すことを繰り返したため時間がかかってしまった点です。振り返ってみると一気にgit mvでlegacyモジュールに移してエラー解消をしたほうが1回のマージによる影響は大きいものの時間は短く済みます。
実際起きるビルドエラーとしてはパス変更によるものがほとんどです。
- R
- BuildConifg
- Databinding
今回はlegacyモジュールに移すに当たって全コードのパッケージにlegacyを付けることにしました。legacyと付けることでモジュール分割の対象であるという認識が高まりますし、間違って依存してしまったときに気づきやすいです。
これらのパス変更はコマンドで一気に変更するのが楽でした。
find . -type f -print0 | xargs -0 sed -i '' "s/com.hoge.R$/com.hoge.legacy.R/"
おわりに
Sansan Androidアプリではようやくマルチモジュールを開発の中で自然と進められる状態まで持っていくことができました。新機能開発では積極的に新モジュールでの開発を進めています。モジュール分割が進められるようになったことで既存の依存関係の見直しやモジュール分割の粒度の議論などが行われるようになってきました。引き続きより良いプロダクトを効率的に開発できるように取り組んでまいります。
Sansanではモバイルエンジニアを絶賛募集中です。興味のある方はぜひ話を聞きに来てください。お待ちしております!