dely tech blog

クラシル・TRILLを運営するdely株式会社の開発ブログです

Clean Architectureで考えるAndroidのモジュール設計

はじめに

こんにちは。クラシルのAndroidアプリチームのテックリードのうめもりです。

皆さん、Gradleのモジュール機能は活用していますか?ソースコードの依存の方向をモジュール単位で強制出来ることでアーキテクチャーの制約を強制しやすかったり、並列ビルド・差分ビルドの局所化によるビルド高速化を期待できたり、大規模なAndroidアプリを作るにはとても役に立つ機能ですよね。

そんな役に立つ機能ですが、実際どうやって活用していけばいいか分からなくて導入に踏み切れない方や、導入してみたがいまいち恩恵が感じられない、そんな方もいらっしゃるのではないでしょうか。

ところで、Androidアプリを開発してきた皆さんなら一度は聞いたことがある言葉にClean Architectureというものがあると思います。

f:id:delyumemori:20211122150939j:plain

引用: クリーンアーキテクチャ(The Clean Architecture翻訳) | blog.tai2.net

そして多くの人はこの図と一緒にClean Architectureというものがどういうものか説明するという記事を一度は読んだことがあると思いますが、実はあの図が出てくる「Clean Architecture 達人に学ぶソフトウェアの構造と設計」であの話を取り扱っている部分はごくごく一部で、本全体を通してはどのようにプログラムを設計すべきか、どのようにプログラムのアーキテクチャを考えるとよいのか、といったことが網羅的に説明されています。素敵な本なのでもし読んだことない方がいたら是非一読をおすすめします。

www.amazon.co.jp

今回は、みんな大好きClean Architecture本の第IV部、「コンポーネントの原則」に書かれている内容がGradleモジュールの設計を考える際に非常に役に立つので、その内容をもとに、Gradleモジュールを設計する際にどう適用していったらいいのか、という話をします。

その内容はAndroidアプリにどういったアーキテクチャパターン(MVP、MVVM、VIPER...)を適用するかどうかに関わらず、共通して適用できる話です。なのでGradleのモジュール設計に困っている方には役に立つと思います。

モジュールの粒度をどうするか

Clean Architecture本の第12章、「コンポーネント」ではコンポーネントというものは次のようなものであると説明されています。

コンポーネントとは、デプロイの単位のことである。システムの一部としてデプロイできる、最小限のまとまりを指す。Javaならjarファイル、Rubyならgemファイル、.NETならDLLなどがそれにあたる。

(第12章 コンポーネントより)

Gradleでは、JARファイルをモジュール単位で生成することもできます。AndroidアプリならAARファイルですね。

複数のコンポーネントをリンクして単体の実行可能ファイルにすることもできる。あるいは、複数のコンポーネントを.warファイルのようなアーカイブにまとめることもできる。

(第12章 コンポーネントより)

そして、複数に分かれたモジュールを1つのアプリケーションとしてビルドすることも出来ます。

Androidアプリ開発においては、デプロイの最小単位はGradleモジュールと考えるのがよさそうです。そして、続く第13章ではどのクラスをどのコンポーネントに含めればいいのか、つまりどのくらいの粒度でモジュールを設計すればいいのか、という原則が3つ紹介されています。

再利用・リリース等価の原則(REP)

再利用の単位とリリースの単位は等価になる。

(第13章 コンポーネントの凝集性より)

この原則については、単一のチームでマルチモジュールのアプリを運用している場合はあまり重要ではないかもしれません。Androidアプリのリリースは原則としてモジュールを結合した状態で行われるので、お互いのモジュールの「リリース」を意識する必要はないためです。

ですが、複数チームで同じアプリケーションを開発している場合は、それぞれのチームが意味のある単位でモジュールを「リリース(アップデートを共有)」することで、お互いにお互いのチームのモジュールをうまく使うことが出来るようになりそうです。逆に、お互いのチームの修正内容が固有のモジュールに閉じておらずお互いのモジュールにまたがっていたりすると、協調したアプリ開発は困難になりそうですね。

閉鎖性共通の原則(CCP)

同じ理由、同じタイミングで変更されるクラスをコンポーネントにまとめること。変更の理由やタイミングが異なるクラスは、別のコンポーネントに分けること。

(第13章 コンポーネントの凝集性より)

SOLID原則を知っている方なら、単一責任の原則(SRP)のコンポーネント版という説明が一番わかりやすいでしょう。これを実際にAndroidアプリに適用することを考えると、どうやってモジュール分けするかの大きなヒントになりそうです。アプリの差分ビルドの高速化という観点からも、同じ理由、同じタイミングで修正されるコードが同じモジュールに集まっていた方が、影響がないモジュールの再ビルドの時間が抑えられることになるので、とても大事なポイントです。

例えばよくある分け方として

+ UI 
+ Repository
+ APIアクセス
+ DBアクセス

といったプログラムのレイヤー単位でモジュールを分ける、といったやり方を紹介されていたりします。

しかし、モバイルアプリ開発においてはUIだけ、データだけ変更されるということはそう多くなく、UIを変更する場合はそれに合わせてデータも変更される、データが変更される場合はUIも変更される、といったことも多いのではないでしょうか。クラシルの場合レシピ、チラシ、ネットスーパー、クラシルショートなどの機能がありますが、

+ レシピ
+ チラシ
+ ネットスーパー
+ クラシルショート

のように機能単位のモジュールに分けた方が、一度の変更が単一のモジュールに閉じるようになるかもしれません。実際のクラシル開発では、機能単位とUI/データという2つの軸でモジュールを分割しています。

もちろん、実際にどういったプロダクトを開発しているかによって、どういった単位で同じタイミング、理由で変更されうるかについては大きく変わってくるはずです。どういった形でモジュールの凝集性を管理するかについては、もっとも開発チームの実情に沿った形にするのが好ましいでしょう。

全再利用の原則(CRP)

コンポーネントのユーザーに対して、実際には使わないものへの依存を強要してはいけない。

(第13章 コンポーネントの凝集性より)

この原則は、端的に言えば参照先のモジュールに、参照元のモジュールが利用しないクラスやインターフェースが含まれていてはいけないという原則です。

クラシルであればレシピ機能しか使わない機能が含まれたモジュールにチラシ機能がアクセスすることになると、レシピ機能を修正する際にチラシ機能の再ビルドが必要になってしまうということになります。全再利用原則を極力守るようにモジュール設計を行うことで、ビルド時間の短縮や、機能間の依存関係の整理が行いやすくなるでしょう。

3つの原則のバランスを取ることが大事

もうお気づきかもしれないが、凝集性に関するこれらの原則には相反するところがある。再利用・リリース等価の原則(REP)と閉鎖性共通の原則(CCP)は、包含関係にある。どちらも、ひとつのコンポーネントを大きくする方向に働くものだ。一方の全再利用の原則(CRP)は、これらとは相反する原則で、ひとつのコンポーネントを小さくする方向に働くものだ。これら3つの原則のバランスをうまくとるのが、アーキテクトの腕の見せどころだ。

(第13章 コンポーネントの凝集性より)

Clean Architecture本にもこう書かれているように、再利用・リリース等価の原則(REP)と閉鎖性共通の原則(CCP)を考慮すればモジュールは大きく、全再利用の原則(CRP)を考慮すればモジュールは小さくなるほうが好ましいということになります。

並列ビルドや差分ビルドの効率を考えるのであれば全再利用の原則(CRP)を考慮してモジュールを小さくした方が良いですが、再利用・リリース等価の原則(REP)を考慮して、1つの変更が影響するモジュール数を減らして開発チームの連携のしやすくするためにモジュールを大きくした方が良いタイミングもあるでしょう。

あるいは閉鎖性共通の原則(CCP)を最大限考慮すべきタイミング(例えばアプリのリリース直後など)では、そもそもマルチモジュール構造を選ばない、というのも選択肢の1つに入ってくるはずです。

開発チームの規模や開発フェーズ、プロダクトの特性によって3つの原則のバランスを考えていくことが大事です。

モジュールの依存関係をどうするか

Gradleモジュールの特性を生かす

第14章 コンポーネントの結合ではコンポーネント間の依存関係を設計する上での指針が、3つの原則を用いて説明されています。その中の1つの原則に、Gradleモジュールを使う限り強制的に満たすことになる原則がありますので、そちらを先にご紹介します。(幸い記述的にも先の方にあります。)

非循環依存関係の原則(ADP)

コンポーネントの依存グラフに循環依存があってはいけない。

(第14章 コンポーネントの結合より)

Gradleのモジュールはモジュールの循環依存が存在するとエラーが発生するようになっています。Gradleのシステム上どこかのモジュールを先にビルドする必要があるので、循環依存が存在するとどのモジュールを先にビルドしていいか分からなくなるため、エラーになるようになっていると思うのですが、循環依存が禁止されていることは設計上のメリットもあります。

f:id:delyumemori:20211122151023p:plain

例えば A -> B -> C -> A とモジュールが循環依存していた場合、Aを修正したければB, C両方のモジュールへの影響を意識する必要があり、かつモジュールAをユニットテストしたい場合にモジュール同士を切り離すことが困難になります。そして、モジュールA, B, Cは実質密結合した状態になってしまい、モジュールを分割した意味がなくなってしまうこともあり得ます。

循環依存が禁止されていることによってこういった問題は発生しないことが保証されていますが、循環依存を解消するための方法はGradleのモジュールシステム上でどうやってクラスを設計するか考えることにも役に立つので、書籍で紹介されている2つの方法を簡単にご紹介します。

  1. 依存関係逆転の原則(DIP)を適用し、例えばC->Aで依存しているメソッドを持つインターフェースを別に用意し、Cモジュールの中ではそれを利用する。C->Aへの依存関係が逆転する(AがCのインターフェースを実装したクラスを作る形でCに依存する)ことで、循環依存が解消される。

f:id:delyumemori:20211122151100p:plain

  1. CとAが依存するDモジュールを作り、DモジュールにC->Aで依存しているクラスを移動する。

f:id:delyumemori:20211122151119p:plain

書籍ではより実例に沿った形で説明されているので、より深く理解したければ書籍を参照してください。

安定依存の原則(SDP)

安定度の高い方向に依存すること。

(第14章 コンポーネントの結合より)

モジュールからモジュールへの依存、ということを考える上で、そのモジュールがどの程度の頻度でどのくらい変更されうるか、ということは一つの重要な観点になります。そして、よく変更されることを想定したモジュールは、変更しづらいモジュールから依存されるべきではありません。何故ならば、変更しづらいモジュールから依存されてしまうと、本来よく変更されることを想定したモジュールが変更しづらい状態になってしまうからです。

例えば何らかの共通の処理を行うために切り出したモジュールが、特定の機能の処理を行うためのモジュールに依存してしまっている、といった状況がこれにあたるでしょうか。こうなってしまうと、特定の機能の処理に対して変更を加えると共通の処理に波及してしまうようになるので、その特定の機能の処理を行うためのモジュールを変更することが行いにくくなるでしょう。

安定依存の原則(SDP)の原則は、よく変更されることを想定したモジュールが、変更しづらいモジュールから依存されていないことを保証されるために、満たすべき原則と定義されています。

安定度の指標

書籍では、 モジュールの変更しにくさを安定度と定義しています。そして、安定度を計測するための指標として、そのモジュールがどのくらいの数のモジュールから依存されているかということと、どのくらいの数のモジュールに依存しているかという二つの指標に着目しています。

そして安定度(不安定度)を次のように定義しています。

+ ファン・イン: そのモジュールに依存している別のモジュールの数
+ ファン・アウト: そのモジュールから依存している別のモジュールの数
+ 安定度(不安定度): I(Instability) = ファン・アウト / (ファン・イン + ファン・アウト)

Gradleでは、明示的にモジュール間の依存を記述しなければ別モジュールへのコードへアクセスすることはできないので、各モジュールのbuild.gradleファイルを見ることで各モジュール間の安定度を簡単に算出することが出来ます。(使わないモジュールにアクセスしていない限りですが)

例えば、

+ app
+ ui:recipe
+ ui:netsuper
+ data:recipe
+ data:netsuper
+ api
+ db
+ infra

というモジュールがあり、

f:id:delyumemori:20211122151140p:plain

のような形でモジュールが依存しているとします。

infraモジュールを例えば見てみると、

ファン・イン = 5
ファン・アウト = 0
I = 0 / (5 + 0) = 0

となり、不安定度が0なので非常に安定した(変更しにくい)モジュールになっていることが分かります。

一方appモジュールは

ファン・イン = 0
ファン・アウト = 8
I = 8 / (0 + 8) = 1

となり、不安定度が1なので極めて不安定な(変更しやすい)モジュールといえます。

そして、実際に矢印の数を数えてみると分かるのですが、必ずIが高い=不安定なモジュールから、安定したモジュールに依存するような形になっています。

実際にアプリを設計する際の感覚としても、アプリケーション全体が依存するinfraは安定していて変更しづらく、uiは不安定で変更しやすいモジュールというのは実感にあっているのではないでしょうか。同じuiの中でも、ui:recipeの方が複数のモジュールに依存していてよく変更がありそうですね。

安定度・抽象度透過の原則(SAP)

コンポーネントの抽象度は、その安定度と同程度でなければいけない。

(第14章 コンポーネントの結合より)

安定依存の原則(SDP)に従うと、基本的にモジュールの依存関係は安定度が低い(変更しやすい)モジュールから安定度が高い(変更しにくい)モジュールに依存することになります。そうなると、必然的に安定度の高いモジュールはあまり変更の必要がない設計にする必要があります。

プログラムコードには実装とインターフェースという二つの側面がありますが、基本的にインターフェースは変更されにくく、実装は変更されやすい傾向にあります。そのことを考えると、安定度の高いモジュールはあまり実装を含まず、インターフェースを多く含む(Kotlinであればinterfaceやabstract class)モジュールである必要があると考えられます。

そのことを書籍では 安定度・抽象度透過の原則(SAP) と表現しています。

安定度・抽象度等価の原則(SAP)は、安定度と抽象度の関係についての原則だ。安定度の高いコンポーネントは抽象度も高くあるべきで、安定度の高さが拡張の妨げになってはいけないと主張している。一方、安定度の低いコンポーネントは具体的なものであるべきだとしている。安定度が低いことによって、その内部の具体的なコードが変更しやすくなるからである。

(第14章 コンポーネントの結合より)

f:id:delyumemori:20211122151201p:plain

こちらの例でいえば、安定度が最も高いinfraモジュールにはインターフェースが最も多い割合で含まれているべきで、安定度が最も低いappモジュールには実装が最も多い割合で含まれているべきということになります。

ところで、上記の例だと、data:recipeやdata:netsuperには、uiから参照されるインターフェースと、その実装の両方が含まれていることになります。実装次第では、uiとdataで抽象度が逆転する可能性もあります。そのことを考えると、安定度・抽象度透過の原則(SAP)に従うためには下のようにモジュールの依存関係を整理したほうがいいかもしれません。

f:id:delyumemori:20211122151218p:plain

data:bridgeモジュールを導入してそこにuiから依存していたインターフェースを移動し、各dataモジュールではそのインターフェースを実装する形でdata:bridgeモジュールに依存します。

抽象度の計測

書籍ではモジュールの抽象度を次のような計算式で定義しています。

+ Nc: モジュール内のクラス(抽象クラスとインターフェースも含む)の総数
+ Na: モジュール内の抽象クラスとインターフェースの総数
+ A: 抽象度。A=Na / Nc

Aが0であれば一切抽象クラスやインターフェースが含まれない、最も抽象度が低いモジュールであることを表していて、Aが1であれば抽象クラスやインターフェースしか含まれない最も抽象度が高いモジュールであることを表します。

もっともKotlinではインターフェースに実装を持つことが出来る(デフォルト実装ですが)ことや、抽象クラスにも実装を持つことが出来ることも考えると、あくまで目安として参考にするのがよさそうです。

安定度と抽象度の関係

前述したやり方でモジュールの安定度と抽象度を計算することで、安定度と抽象度の関係を定量的に見ることが出来るようになりました。書籍では次のような図を使って安定度と抽象度の関係を整理しています。

f:id:delyumemori:20211122151236p:plain

横軸が安定度、右に行けば行くほどそのモジュールは安定しており、縦軸が抽象度、上に行けば行くほどそのモジュールの抽象度は高いものとなります。

この図にモジュールをプロットしていくとして、書籍では二つの「モジュールがプロットされるすべきではない」ゾーンと、「主系列」を定義しています。

苦痛ゾーン

図の左下のゾーンは抽象度が低く、かつ安定度が低いモジュールが含まれるゾーンです。

変更がしにくい、かつ抽象度が低いために実装の拡張もしにくい、そういったモジュールがここに含まれるので、そういったモジュールが多いマルチモジュール設計は変更に苦痛を伴いやすいでしょう。

スマートフォンアプリ開発ではどのようなモジュールがこのゾーンに含まれやすいかというと、例えば書籍でも例として上げられている具象ユーティリティライブラリーです。ほにゃららUtilクラスが作られ、アプリケーションの色々なところから呼び出される処理がそのほにゃららUtilクラスに追加されていく、というのは皆さんも心当たりがあるのではないでしょうか。こういったクラスはあまり変更されないのであれば苦痛を伴いませんが、良く変更される処理がその中に含まれているとその変更が予期しない形で他のモジュールに波及したり、あるいはどういった利用のされ方をされているか分からないので変更できなくなってしまうようなことが想定されます。

基本的には一度作ったら変更されないようなモジュール(ロガー実装や基本的なデータ構造に対する処理)以外はここに含まれないのが理想的でしょう。

無駄ゾーン

図の右上のゾーンは、抽象度が高く、かつ安定度が高いモジュールが含まれるゾーンです。

抽象度が高い、つまりインターフェースや抽象クラスが多く含まれているのにあまり参照されていない、このようなモジュールはそもそも必要性がない可能性が高いです。

もしこういったモジュールがプロジェクトに含まれているのを発見したら、整理を検討した方が良いでしょう。

主系列

変動性の高いコンポーネントをこれらのゾーンからできるだけ遠ざけておくべきなのは明らかだ。両方のゾーンから最も離れた点を結ぶ軌跡は(1,0)と(0,1)をつなぐ直線になる。この直線を主系列と呼ぶことにする。

(第14章 コンポーネントの結合より)

苦痛ゾーンや無駄ゾーンから遠いところにある直線、(1, 0)と(0, 1)の間をつなぐ直線を書籍では主系列と呼んでおり、主系列からの距離が近ければ近いほど、モジュールの安定度と抽象度が比例した関係にあることになります。

主系列からの距離

モジュールがどの程度主系列から離れているかの指標として、書籍では以下の指標を定義しています。

D(Distance): 距離。D = | A(抽象度)+I(安定度)-1 |

Dが0の場合、モジュールは主系列上にあることになり、Dが1の場合は最も主系列から遠い(苦痛ゾーンか無駄ゾーン)にあることになります。

マルチモジュール開発をする際は、全てのモジュールが極力主系列上に近いところに配置されるように開発を進めていくべきでしょう。

クラシルのモジュール構成はどうか?

さて、最後に簡単ですが、ここまでの内容を使って、クラシルのモジュール構成を分析してみたいと思います。以下は現行のクラシルのモジュール構成を簡略化した図になります。

f:id:delyumemori:20211122151300p:plain

クラシルではUIとデータの2層にアプリケーションをモジュール分割した上で、それぞれ機能ごとにモジュールを分割しています。

ui:baseとdata:baseはそれぞれインターフェース用のモジュールであり、UI層同士のアクセス、データ層同士のアクセス、UI層からデータ層へのアクセスはそれぞれui:baseとdata:baseを経由して行うようになっています。一方で、共通で利用する具象クラスもここに含まれています。

そしてui:xxxとdata:xxxに、それぞれui:baseとdata:baseに定義されたインターフェースの実装が含まれているという構造になっています。

図ではそれを矢印として表示すると煩雑になってしまうため省略していますが、common_modulesは基本的にどのモジュールからもアクセスできるようになっています。(なので図では依存しているモジュールの数は少ないが実は安定度MAX)なので、これらのモジュールが頻繁に変更されるようだと、設計を見直す必要がありそうです。

安定度に着目してみると、

app

ファン・イン = 0
ファン・アウト = 14
I = 14 / (0 + 14) = 1

となり、極めて不安定なモジュールから依存グラフが始まり、

ui:recipe

ファン・イン = 1
ファン・アウト = 3

I = 3 / (1 + 3) = 3/4

ui:base

ファン・イン = 6
ファン・アウト = 3

I = 3 / (6 + 3) = 1/3

data:recipe

ファン・イン = 1
ファン・アウト = 3

I = 3 / (1 + 3) = 3/4

data:base

ファン・イン = 12
ファン・アウト = 2

I = 2 / (12 + 2) = 1/7

と安定度が推移しており、基本的に不安定なモジュールから安定したモジュールへ依存関係があることが分かります。

そして、ui:baseやdata:baseといった共通インターフェースを配置するモジュールが依存グラフの先にあることで、ある程度安定度と抽象度が比例していることが分かりますが、今後の改修でui:baseやdata:baseモジュールに具象クラスを多く含むようになってきてしまうと、プロジェクト全体の改修しにくさに繋がっていきそうです。実際、どうしても共通モジュールは肥大化しがちなので、適宜リファクタリングをすることでなるべく抽象度を下げていきたいものです。

今回は、クラシルのモジュール図の一部を使って安定度と抽象度の分析を行ってみました。皆さんのAndroidプロジェクトでも、是非やってみてはいかがでしょうか。

まとめ

  • 全AndroidエンジニアはClean Architectureの第IV部を読んだ方がいいよ
  • Gradleのモジュールは第IV部のコンポーネントを表現するのにうってつけの単位だよ
  • モジュールは同じタイミング、同じ理由で変更されるものをまとめるといいよ
  • どのくらいの粒度で分割すべきかはアーキテクトの判断次第だよ、バランスだよ
  • モジュールは循環依存できないことを生かした設計を考えるといいよ
  • モジュールの依存関係は変更されやすいものから変更されにくいものへ依存する方向で書くといいよ
  • 依存するモジュールが多いモジュールは不安定で、依存するモジュールが少ないモジュールは安定していると考えると楽だよ
  • モジュールの安定度とコードの抽象度が一致しているといいよ
  • モジュールの安定度・抽象度を計測する便利な指標があるよ、簡単だから今日から使えるよ

おわりに

クラシルのAndroidチームでは、生産性の高いアーキテクチャを維持・アップデートしつつも楽しくプロダクト開発できる人、したい人を求めています。 今回の記事を読んで興味が出てきた方、是非お話ししましょう! 下記の採用リンクから応募いただいてもOKですし、Twitterから気軽にDM頂けてもOKです!

dely.jp

twitter.com