くらしのマーケット開発ブログ

「くらしのマーケット」を運営する、みんなのマーケット株式会社のテックブログ。積極採用中です。

あの日呼び出したモジュールの名前を僕達はまだ知らない

はじめに

みんなのマーケットで iOS / Android アプリエンジニアとして働いているYangです。

非常に分(意)か(味)り(不)や(明)す(!)く(!)タイトルに書いた通り、今回はデカップリングを話題として、Androidのモジュール化を紹介したいと思います。

モジュール化とは

イメージとしては:

f:id:curama-tech:20180608115351j:plain

(モザイク除去はこちらへ画像の出典)

違う!

複雑で巨大なシステムやプロセスを設計・構成・管理するとき、全体を機能的なまとまりのある“モジュール”に要素分割すること。設計・製造時の擦り合わせ作業をできるだけ少なくするために構成要素(部品)の規格化・標準化を進め、その相互依存性を小さくすることをいう。出典

「wwwの生みの親」「Webの父」などの異名を持つ、ティム・バーナーズ=リーさんの本「Principles of Design」に以下の名言があります。

Principles such as simplicity and modularity are the stuff of software engineering; decentralization and tolerance are the life and breath of Internet.

会社の事業拡大と共に、アプリの機能もどんどん増えて、複雑になっています。

巨大とまではいかなくてもビルドに時間はかかるし、ファイルツリーがだんだん長くなって、開発もしづらく、依存が多すぎて単体テストが書きづらいです。また、部分的な不具合や変更が発生したときに、その影響が全体へと波及してしまいます。

解決しないといけないこと

1. コードのデカップリング(分離原則)

 どうやって一つの巨大のプロジェクトを複数のモジュールに分けられますか。もしモジュール間で、再度お互いに直接アクセスするのであれば、デカップリングとは全く言えません。どうすれば直接の参照を避けられるでしょうか。

2. 各モジュールの独立実行

 一回のデバッグにもし一つか二つぐらいのモジュールだけ参加させたら、ビルドの時間は大幅に短縮できますから、開発の段階でどうやって最低限必要なモジュールだけをコンパイルさせますか。各モジュールが疎結合であれば,どうやって独立に実行やデバッグをさせますか。

3. 各モジュール間のデータ通信

 各モジュールは外部へのサービスを提供することができますが、メインモジュール(Host)や他のモジュールへどうやってデータを送受信すればいいでしょうか。

 実際画面遷移の本質も特殊なデータ送信ですが、Android上の画面遷移は Intent を使わないといけないので、解決の方法は少し違います。

コードのデカップリングの解決

巨大なコードを分割しやすくするために、Android Studio IDEに Multiple Module という機能をサポートしていて、これを使って最初に簡単な分離はできます(つまり参照が複雑なところは自分で整理しないといけないです)。

ここで明確したい概念があって、モジュールは二種類に分けることができます。

一つは Base Library で、これらは他のモジュールから直接参照できます。例えば、ネットワークモジュールや画像処理モジュールは二つの Base Library として考えられると思います。

もう一つは Component と似ているような感じで、完全な機能を持っています。くらしのマーケットのアプリを例にすると、予約モジュールやチャットモジュールは二つの Component と呼ばれたらいいです。

Base Library は単純に汎用的なサービスを提供するため、 モジュール化 に言った モジュール は基本的に二つ目です。

これで、コードの分離は図の通りにできます。

f:id:curama-tech:20180608115345p:plain

矢印を書いているところだけ依存関係が存在して、つまり Reservation ModuleCalendar Module はお互いに見えないです。こうすると、コードのデカップリングを実現できます。

各モジュールの独立実行の解決

ビルドツールに対して、あるモジュールがプロジェクトの入り口かどうかの判断は該当モジュールの build.gradle を参照しています。

apply plugin: 'com.android.library' の場合は部品で、 apply plugin: 'com.android.application' の場合はアプリと識別されます。

つまり一個 isRunAlone の変数を用意すれば、切り替えは可能です。

これ以外、アプリと識別された場合、 AndroidManifest.xml ファイルに最初ページとしての Activity も指定する必要があります。私の解決方法では二つのManifestファイルを用意して、リリースの際にdebug用のファイルを除外します。

最後、アプリとして実行するために必要な applicationId も指定して、独立実行の準備はできました。

if (isRunAlone.toBoolean()) {
    apply plugin: 'com.android.application'
} else {
    apply plugin: 'com.android.library'
}
// ......
    defaultConfig {
        if (isRunAlone.toBoolean()) {
            applicationId "jp.curama.shop.module1"
        }
    // ......
    }
// ......
    sourceSets {
        main {
            if (isRunAlone.toBoolean()) {
                manifest.srcFile 'src/main/debug/AndroidManifest.xml'
            } else {
                manifest.srcFile 'src/main/release/AndroidManifest.xml'
                java {
                    exclude 'debug/**'
                }
            }
        }
        // ......
    }
// ......

各モジュール間のデータ通信の解決

前書いた通り、ここは二つのシーンがあって、①画面遷移と②メソッド呼び出しです。色々調べて、一番良い解決案はAlibabaが提供している Open Source Library ARouter です。一発で二つのシーンを解決できます。

Android の標準画面遷移は下の通りです。ここでの問題は、 ActivityAActivityB をインポートしないと遷移できません。

// filePath: module1/ActivityA.kotlin
// module2への依存が発生してしまいました
import module2.ActivityB

class ActivityA: BaseActivity() {
    fun navigateToB(id: String) {
        val intent = Intent(this, ActivityB::class.java).apply { putExtra("id", id) }
        startActivity(intent)
    }
}

// filePath: module2/ActivityB.kotlin
class ActivityB: BaseActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState);
        val id = intent?.getStringExtra("id")
        // ......
    }
}

ARouterを使う場合、 ActivityA は遷移先の URL がわかったら遷移できます。私達のシステムでは管理上の考えによって、 RouterPath という URL の集約ファイルを作って、 Base Business Layer に置いて、 URL のハードコードを避けました。またどの画面がどのURLを使っていて、必要なパラメーターが何であるかは誰でもすぐに一目瞭然で、把握できます。もう一つ便利なところは ARouter 経由で渡したパラメーターがインジェクターによって自動的に遷移先へアサインされるところです。

// filePath: module1/ActivityA.kotlin
// import ActivityB は不要です
class ActivityA: BaseActivity() {
    fun navigateToB(id: String) =
        ARouter.getInstance().build("module2/b").withString("id", id).navigation()
}

// filePath: module2/ActivityB.kotlin
@Route(path = "module2/b")
class ActivityB: BaseActivity() {
    @Autowired
    lateinit var id: String

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState);
        ARouter.getInstance().inject(this);
        // ここから this.id はもう使えます
        // ......
    }
}

シーン②も似たやり方で、 URL でアノテーションをして、使用者はインタフェースに依存することで、ARouterから実現を取得して、使ったらうまく動けます。

これでモジュール化の大きい問題を全部解決できました。

最後に

我々みんなのマーケットテックチームでは「くらしのマーケット」を一緒に作る仲間を募集しています!どんな環境で開発しているかはこちらの記事にまとまっています。興味がある方はぜひ気軽に連絡ください (コーポレートサイト https://www.minma.jp/

次回は、SREチームの千代田さんの予定です。