Gunosy Tech Blog

Gunosy Tech Blogは株式会社Gunosyのエンジニアが知見を共有する技術ブログです。

Gradleモジュール分割とレイヤードアーキテクチャ

こんにちは、グノシー事業部の山本です。 この記事はGunosy Advent Calendar 2018の15日目の記事です。 昨日の記事はQAチームのTeiiさんとakinkさんの人気のテスト管理ツール「qTest」と「PractiTest」を触ってみたよでした。

はじめに

2018年ももう終わりですね。今年はモジュール分割しましたか?

ここでいうモジュール分割はAndroidアプリにおけるコードのGradle Projectへの分割のことです。

2016年のGoogle IOで発表されたInstant Appによって突然現れた印象のモジュール分割ですが、今年はGradle 3.0(もう5.0だけど)、 Dynamic Feature Modulesとモジュール分割の有用性/必要性の高まる年だったと思います。

Gunosy社内でいうと一部の既存のアプリではモジュール分割を進めているものの、既存コードベースの大きさもあり、新規機能の分割から進めているのが現状です。

その中で先日リリースしたグノシースポーツのAndroid版では新規開発ということもあり、他プロダクトよりも細かなモジュール分割を行いました。
今回はモジュール分割の利点、今回開発したアプリの分割方針等を共有できればと思います。

モジュール分割の利点

モジュール分割の利点は個人的には大きく以下の4つにあると考えています。

  1. Android App Bundles周りで使える機能が増える
    • Google Play Instant
    • Dynamic feature module
      • 一部機能をインストール時ではなくオンデマンドで配信するためapkサイズが小さくなる
  2. ビルド時間高速化
  3. モジュールをアプリ間で共有できる
    • 認証周りやDBアクセスといった共通コードをアプリ間で共有できる
  4. アーキテクチャをモジュールで表現できる

4に関して 最近ではClean ArchitectureのようなLayered Architectureを採用することが多くなってきていると思います。
Layerd Architectureの場合隣り合ったレイヤーにのみ依存をもたせたいですが、パッケージによるレイヤー分けではimportさえすればレイヤーをまたいで参照することができてしまいます。マルチモジュールにすると隣り合ったレイヤーに対応したモジュールにのみ依存をさせれば、隣り合わないレイヤーのコードはimportすらできなくなり、ルール違反のコードが書きづらくなります。
このような利点はLayered Architectureに不慣れなエンジニアが多い職場では特に大きなメリットになると思います。

以上様々な利点ありましたが、特にビルド時間の高速化はAndroidエンジニアであれば泣いちゃうやつだと思います。
よって個人的には1.のPlay Instant等の要件がなくても新規アプリであればモジュール分割を行うのがいいかなと思っています。

グノスポでのモジュール分割

ということでグノシースポーツでは以下のモジュール分割とLayered Architecture風のアーキテクチャを採用したので、例として説明したいと思います。
今回の大まかなレイヤー構成は以下のようになっています。

f:id:shunyy:20181215172430p:plain

登場人物の説明は以下になります。

  • Fragment
    • ViewModelの内容を描画
  • ViewModel
    • UseCaseからデータを受け取ってステートを変化させる
  • UseCase
    • 複数のRepositoryからデータ取得処理 + ビジネスロジック
  • Repository Interface
    • UsecaseがRepositoryに求めるインターフェースを規定
  • Reposiroty Impl
    • API通信やDB処理を記述

この登場人物達の依存関係は図の矢印の方向になります、これをGradle Moduleで表現していきます。
全部書くと長くなってしまうので、usecaseとrepository interface, repository implementationに絞ってbuild.gradleの例を書いてみます。

// usecase
apply plugin: 'com.android.library'
dependencies {
    def modules = rootProject.ext.modules
    implementation modules.repositoryInterface
}
// repository interface
apply plugin: 'com.android.library'
dependencies {
    // 依存なし
}
// repository implementation
apply plugin: 'com.android.library'
dependencies {
    def modules = rootProject.ext.modules
    implementation modules.repositoryInterface
}

このように書くと、当然といえば当然なんですが、usecaseのmoduleからはrepository implementationのコードは見れなくなりインターフェースのみ知っている状態になるため、モックさえ書けば簡単にユニットテストをかけるようになります。
実際のアプリ動作時にはDIを用いて実装クラスのインスタンスを渡しています。

最終的なモジュール/ファイル構成は以下のようにしました。(モジュールが入っているところには * をつけています)

├── app*
├── data
│   ├── api*
│   ├── aws*
│   ├── google*
│   ├── device*
│   └── sdk*
├── domain
│   ├── repository*
│   └── usecase*
├── feature
│   ├── articledetail*
│   ├── home*
│   ├── login*
│   ├── ....

FragmentとViewModelのセットをfeatureと呼んでまとめています。この2つのファイルは画面追加時にペアで追加するものなので開発をしやすくするためにモジュールを分割していません。 またそもそも、View + ViewModelというレイヤーでモジュールを切らずに、feature毎にモジュールを切っているのは、今後一部の画面をInstant Appに対応させるときに画面間に依存関係があると切り出しにくくなるためです。dataレイヤーの分割も同様の理由になります。 画面間の依存を無くすと他の画面への遷移時にFragmentやActivityのインスタンスを作成することが出きないのですが、これは全画面遷移をURLベースにし、すべてのfeatureへの参照を持つappモジュール内でインスタンス作成、遷移を行っています。

良かった点

  • ビルドが速い(気がする
    • コードベース量によって大きく変わるので気持ちの問題ですが、各依存の末端のファイル変更であれば数秒でビルドできます
    • 少なくとも並列ビルドでは動いているのでCircle CI等でも一番コア数の多いものを指定するとかなり早くなります
  • 複数アプリに持っていくのが容易
    • 基本的にfeatureは分離されているのでUseCaseさえそれぞれのアプリで実装すればUIは簡単に移植可能です
    • 同様にRepositoryさえあればUseCaseも持っていける

微妙な点

  • 画面追加時の追加ファイルが多い
    • 実装としてはViewModelとFragmentの2ファイルですが、build.gradleやAndroidManifestも追加も必要
    • res/drawable等も各featureで作り直し
    • テンプレートfeatureを作って作成時はそれをコピーするようにしています
  • マルチモジュールの場合外部モジュールのimmutableなフィールドのsmart castが動きません
    • f:id:shunyy:20181215172538p:plain
    • 何となく分かるけどなんとかしてほしいやつ

まとめ

今回はモジュール分割の利点と、レイヤードアーキテクチャの場合の分割例について書きました。 既存アプリでの分割はなかなか大変ですが、新規アプリであれば基本的にはやらない手はないとおもいます。 ネット上に大規模なアプリでの実装例がほとんどないので今後もアンテナを張りつつより良い構成にしていきたいと思っています。

では、引き続きGunosy Advent Calendar 2018をお楽しみください。