なぜコンポーネント化が必要か#
小規模なプロジェクトではコンポーネント化は必要ありません。プロジェクトに数十人が関わり、プロジェクトのコンパイルに 10 分かかり、バグを修正することで他の業務に影響を与える可能性がある場合、小さな変更でも回帰テストが必要になります。このようなプロジェクトでは、コンポーネント化が必要です。
コンポーネント化とモジュール化#
技術アーキテクチャの進化の過程では、まずモジュール化が登場し、その後にコンポーネント化が現れます。なぜなら、コンポーネント化はモジュール化の問題を解決するものだからです。
モジュール化アーキテクチャ#
プロジェクトを作成した後、複数のモジュールを作成できます。このモジュールがいわゆるモジュールです。簡単な例として、コードを書くときにホームページ、メッセージ、マイモジュールを分けることがあります。各タブに含まれる内容が一つのモジュールとなり、モジュールのコード量を減らすことができますが、各モジュール間にはページ遷移やデータの受け渡しが必要です。例えば、A モジュールが B モジュールのデータを必要とする場合、A モジュールの gradle ファイル内でimplementation project(':B')
を使って B モジュールに依存します。しかし、B モジュールが A モジュールの特定のページに遷移する必要がある場合、B モジュールは再び A モジュールに依存します。このような開発モデルでは依然としてデカップリングが実現されず、バグを修正するために多くのモジュールを変更する必要があり、大規模プロジェクトの問題を解決することはできません。
コンポーネント化アーキテクチャ#
ここでいくつかの概念を提起します。私たちの日常業務のニーズに基づいて開発されたコンポーネントを業務コンポーネントと呼びます。この業務ニーズが一般的に再利用可能であれば、業務基盤コンポーネントと呼びます。画像の読み込みやネットワークリクエストなどのフレームワークコンポーネントは基盤コンポーネントと呼ばれます。すべてのコンポーネントを構築するアプリコンポーネントはシェルコンポーネント / プロジェクトと呼ばれます。
ここでいくつかの概念を提起します。私たちの日常業務のニーズに基づいて開発されたコンポーネントを業務コンポーネントと呼びます。この業務ニーズが一般的に再利用可能であれば、業務基盤コンポーネントと呼びます。画像の読み込みやネットワークリクエストなどのフレームワークコンポーネントは基盤コンポーネントと呼ばれます。すべてのコンポーネントを構築するアプリコンポーネントはシェルコンポーネント / プロジェクトと呼ばれます。次にアーキテクチャ図を見てみましょう:
実線は直接依存関係を示し、破線は間接依存を示します。例えば、シェルプロジェクトは業務基盤コンポーネント、業務コンポーネント、module_common 公共ライブラリに依存する必要があります。業務コンポーネントは業務基盤コンポーネントに依存しますが、直接依存するのではなく、「ダウンストリームインターフェース」を通じて間接的に呼び出します。業務コンポーネント間の依存も間接依存です。最後に、common コンポーネントは必要なすべての基盤コンポーネントに依存し、common も基盤コンポーネントに属します。これは基盤コンポーネントのバージョンを統一し、アプリケーションにいくつかの抽象基底クラス(例えば BaseActivity、BaseFragment、基盤コンポーネントの初期化など)を提供します。
コンポーネント化がもたらす利点#
** コンパイル速度の向上:** 各業務コンポーネントは個別に実行・デバッグでき、速度が数倍向上します。例えば、video コンポーネントの単独コンパイル実行時間は 3 秒です。この時、AS は video コンポーネントとその依存コンポーネントのタスクのみを実行しますが、統合コンパイル時間は 10 秒で、アプリが参照するすべてのコンポーネントのタスクが実行されます。効率が 3 倍向上したことがわかります。
** 協力効率の向上:** 各コンポーネントには専任のメンテナンス担当者がいて、他のコンポーネントの実装を気にする必要はなく、必要なデータを公開するだけで済みます。テストも全体の回帰テストを行う必要はなく、修正したコンポーネントを重点的にテストすればよいのです。
** 機能の再利用:** 一度のコーディングでどこでも再利用でき、コードのコピーが不要になります。特に基盤コンポーネントや業務基盤コンポーネントは、基本的に呼び出し元がドキュメントに基づいてワンクリックで統合・使用できます。
前述のように、非大規模プロジェクトでは一般的にコンポーネント化は行われませんが、上記で述べた機能の再利用は、大規模プロジェクトだけに限られません。私たちは要求やライブラリを書く際にコンポーネント化の考え方を持つことができ、それらを独立した基盤コンポーネントや業務基盤コンポーネントとして書くことができます。2 つ目のプロジェクトが来たときにちょうどこのコンポーネントが必要であれば、コンポーネントを分離する時間を省くことができます(要求を書くときに大量のカップリングが発生する可能性があり、後で分割するのに時間がかかります)。例えば、ログインコンポーネントや共有コンポーネントなどは、最初からコンポーネントとして書くことができます。
コンポーネント化が解決すべき問題#
業務コンポーネントはどのように独立してデバッグを実現するか?
業務コンポーネント間に依存がない場合、どのようにページ遷移を実現するか?
業務コンポーネント間に依存がない場合、どのようにデータ通信を実現するか?
シェルプロジェクトの Application ライフサイクルはどのように配信するか?
独立デバッグ#
単一プロジェクト案#
単一プロジェクト案とは、すべてのコンポーネントを 1 つのプロジェクトに配置することです。まず全体のディレクトリを見てみましょう:
ps:module_で始まるものは基盤コンポーネント、fun_で始まるものは業務基盤コンポーネント、biz_で始まるものは業務コンポーネント、export_で始まるものは業務コンポーネントが公開するインターフェースを示します。
単一プロジェクトの利点と欠点を分析します:
- 利点:モジュールを修正した後、1 回コンパイルするだけで、そのモジュールに依存する他のモジュールがすぐに変化を感知できます。
- 欠点:完全な責任の分担ができず、各モジュールの開発者が他のモジュールを修正する権限を持っています。
まず、gradle.properties ファイル内に変数を宣言します:
// gradle.properties
isModule = true
isModule が true の場合、コンポーネントは apk として実行可能で、false の場合はライブラリとしてのみ実行可能です。この値を必要に応じて変更し、gradle を同期させます。
次に、あるモジュールの build.gradle ファイル内でこの変数を使って 3 つの場所で判断します:
// build.gradle
// アプリケーションかライブラリかを区別
if(isModule.toBoolean()) {
apply plugin: 'com.android.application'
}else {
apply plugin: 'com.android.library'
}
android {
defaultConfig {
// アプリケーションの場合はapplicationを指定する必要があります
if(isModule.toBoolean()) {
applicationId "com.xxx.xxx"
}
}
sourceSets {
main {
// アプリケーションとライブラリのAndroidManifestファイルを区別
if(isModule.toBoolean()) {
manifest.srcFile 'src/main/debug/AndroidManifest.xml'
}else {
manifest.srcFile 'src/main/AndroidManifest.xml'
}
}
}
}
ライブラリは Application や起動 Activity ページを必要としないため、このファイルを区別する必要があります。アプリケーション manifest の指定されたパスには特定のものはなく、任意のパスを作成できます。アプリケーションの AndroidManifest.xml では、起動ページを設定する必要があります:
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.sun.biz_home">
<application
android:allowBackup="true"
android:label="@string/home_app_name"
android:supportsRtl="true"
android:theme="@style/home_AppTheme">
<activity android:name=".debug.HomeActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
ライブラリの AndroidManifest.xml にはこれらは必要ありません:
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.sun.biz_home">
</manifest>
gradle でモジュールを依存させる方法は主に 2 つあります:
- implementation: A implementation B、B implementation C、しかし A は C のものにアクセスできません。
- api:A api B、B api C、A は C のものにアクセスできます。
一般的には implementation を使用するだけで十分です。api はプロジェクトのコンパイル時間を長くし、必要のない機能を引き入れることになり、コード間のカップリングが深刻になります。しかし、module_common は基盤コンポーネントのバージョンを統一した公共ライブラリであり、すべてのコンポーネントはこれに依存し、基盤コンポーネントの能力を持つ必要があります。したがって、基本的にすべての業務コンポーネントと業務基盤コンポーネントは公共ライブラリに依存する必要があります:
dependencies {
implementation project(':module_common')
}
common コンポーネントが基盤コンポーネントに依存する場合は api を使用するべきです。なぜなら、基盤コンポーネントの能力を上位の業務コンポーネントに伝えるからです:
dependencies {
api project(':module_base')
api project(':module_util')
}
複数プロジェクト案#
複数プロジェクトとは、各コンポーネントが 1 つのプロジェクトであることを意味します。例えば、プロジェクトを作成した後、アプリがシェルコンポーネントとして機能し、biz_home に依存して実行されるため、isModule を使用して独立デバッグを制御する必要はありません。プロジェクト自体が独立してデバッグ可能です。
複数プロジェクトの利点と欠点は単一プロジェクトとは逆です:
- 利点:責任を完全に分担でき、他のプロジェクトでの再利用がより便利で、依存を一行で引き入れることができます。
- 欠点:修正後は maven リポジトリにアップロードする必要があり、他のプロジェクトが再度コンパイルするまで変化を感知できず、アップロードとコンパイルの時間が増えます。
複数プロジェクトのコンポーネント依存には maven リポジトリを使用する必要があります。各コンポーネントの aar を社内の maven リポジトリにアップロードし、次のように依存します:
implementation 'com.xxx.xxx:module_common:1.0.0'
私たちはサードパーティライブラリを config.gradle に統一して管理します:
ext {
dependencies = [
"glide": "com.github.bumptech.glide:glide:4.12.0",
"glide-compiler": "com.github.bumptech.glide:compiler:4.12.0",
"okhttp3": "com.squareup.okhttp3:okhttp:4.9.0",
"retrofit": "com.squareup.retrofit2:retrofit:2.9.0",
"retrofit-converter-gson" : "com.squareup.retrofit2:converter-gson:2.9.0",
"retrofit-adapter-rxjava2" : "com.squareup.retrofit2:adapter-rxjava2:2.9.0",
"rxjava2": "io.reactivex.rxjava2:rxjava:2.2.21",
"arouter": "com.alibaba:arouter-api:1.5.1",
"arouter-compiler": "com.alibaba:arouter-compiler:1.5.1",
// 私たちのライブラリ
"module_util": "com.sun.module:module_util:1.0.0",
"module_common": "com.sun.module:module_common:1.0.0",
"module_base": "com.sun.module:module_base:1.0.0",
"fun_splash": "com.sun.fun:fun_splash:1.0.0",
"fun_share": "com.sun.fun:fun_share:1.0.0",
"export_biz_home": "com.sun.export:export_biz_home:1.0.0",
"export_biz_me": "com.sun.export:export_biz_me:1.0.0",
"export_biz_msg": "com.sun.export:export_biz_msg:1.0.0",
"biz_home": "com.sun.biz:biz_home:1.0.0",
"biz_me": "com.sun.biz:biz_me:1.0.0",
"biz_msg": "com.sun.biz:biz_msg:1.0.0"
]
}
これによりバージョンの統一管理が容易になり、次にルートディレクトリの build.gradle にインポートします:
apply from: 'config.gradle'
最後に各モジュールで依存関係を引き入れます。例えば module_common では次のように依存関係を引き入れます。
dependencies {
api rootProject.ext.dependencies["arouter"]
kapt rootProject.ext.dependencies["arouter-compiler"]
api rootProject.ext.dependencies["glide"]
api rootProject.ext.dependencies["okhttp3"]
api rootProject.ext.dependencies["retrofit"]
api rootProject.ext.dependencies["retrofit-converter-gson"]
api rootProject.ext.dependencies["retrofit-adapter-rxjava2"]
api rootProject.ext.dependencies["rxjava2"]
api rootProject.ext.dependencies["module_util"]
api rootProject.ext.dependencies["module_base"]
}
個人的には、複数プロジェクトは「非常に大きな」プロジェクトに適していると考えています。各業務コンポーネントは開発チームが必要で、タオバオのようなアプリに似ています。しかし、これは業務コンポーネントに関してのみであり、業務基盤コンポーネントや基盤コンポーネントの修正頻度はそれほど高くないため、できるだけ単一プロジェクトを maven リポジトリにアップロードして使用するのが最良です。この記事の例は便利さのためにすべてのコンポーネントを一緒に書いたもので、最良の方法は fun_と module_で始まるコンポーネントをすべて単一プロジェクトとして独立して開発し、業務コンポーネントを 1 つのプロジェクトに書くことです。
ページ遷移#
コンポーネント間の隔離が完了した後、最も明らかな問題はページ遷移とデータ通信の問題です。一般的に、ページ遷移は startActivity で行いますが、コンポーネント化プロジェクト内では適用できません。暗黙的な遷移は使用できますが、各 Activity に intent-filter を書く必要があるため、少し面倒です。したがって、最良の方法はルーティングフレームワークを使用することです。
実際、市場にはコンポーネント化のために特別に設計された成熟したルーティングフレームワークがいくつかあります。例えば、メイチュアンのWMRouterやアリババのARouterなどがあります。本例では ARouter フレームワークを使用し、ARouter によるページ遷移の基本操作を見てみましょう。
まず、依存関係を引き入れる必要があります。module_common で ARouter を引き入れる例として、build.gradle に次のように追加します:
android {
defaultConfig {
javaCompileOptions {
annotationProcessorOptions {
arguments = [AROUTER_MODULE_NAME: project.getName()]
}
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
}
dependencies {
api rootProject.ext.dependencies["arouter"]
kapt rootProject.ext.dependencies["arouter-compiler"]
}
kapt 注釈依存は伝播できないため、これらの設定を各モジュールで宣言する必要があります。api rootProject.ext.dependencies["arouter"]
の行を除いて。次に、ARouter を全体で登録する必要があります。私は module_common で統一して登録しています。
class AppCommon: BaseApp{
override fun onCreate(application: Application) {
MLog.d(TAG, "BaseApp AppCommon init")
initARouter(application)
}
private fun initARouter(application: Application) {
if(BuildConfig.DEBUG) {
ARouter.openLog()
ARouter.openDebug()
}
ARouter.init(application)
}
}
次に、module_common モジュール内にルーティングテーブルを宣言して、パスを統一管理します。
// RouterPath.kt
class RouterPath {
companion object {
const val APP_MAIN = "/app/MainActivity"
const val HOME_FRAGMENT = "/home/HomeFragment"
const val MSG_FRAGMENT = "/msg/MsgFragment"
const val ME_FRAGMENT = "/me/MeFragment"
const val MSG_PROVIDER = "/msg/MsgProviderImpl"
}
}
次に、MainActivity クラスファイルに注釈を付けます:
@Route(path = RouterPath.APP_MAIN)
class MainActivity : AppCompatActivity() {
}
任意のモジュールはARouter.getInstance().build(RouterPath.APP_MAIN).navigation()
を呼び出すだけで遷移を実現できます。データを渡す必要がある場合も簡単です:
ARouter.getInstance().build(RouterPath.APP_MAIN)
.withString("key", "value")
.withObject("key1", obj)
.navigation()
次に、MainActivity で依存性注入を使用してデータを受け取ります:
class MainActivity : AppCompatActivity() {
@Autowired
String key = ""
}
Arouter 案#
export_biz_msg コンポーネント内で IMsgProvider を宣言します。このインターフェースは必ず IProvider インターフェースを実装する必要があります:
interface IMsgProvider: IProvider {
fun onCountFromHome(count: Int = 1)
}
次に、biz_msg コンポーネントでこのインターフェースを実装します:
@Route(path = RouterPath.MSG_PROVIDER)
class MsgProviderImpl: IMsgProvider {
override fun onCountFromHome(count: Int) {
// ここではデータを配信するだけで、リスニングカウントのオブジェクトが受け取ります
MsgCount.instance.addCount(count)
}
override fun init(context: Context?) {
// オブジェクトが初期化されるときに呼び出されます
}
}
biz_home ホームコンポーネントでカウントを送信します:
val provider = ARouter.getInstance().build(RouterPath.MSG_PROVIDER).navigation() as IMsgProvider
provider.onCountFromHome(count)
実際、ページ遷移の方法と基本的に同じであり、Fragment インスタンスを取得する方法もこのようになります。ARouter はすべての通信方法を 1 つの API で実現し、使用者が非常に簡単に扱えるようにしています。
#
Application ライフサイクルの配信#
アプリのシェルプロジェクトが Application を初期化する際、他のコンポーネントに機能を初期化するよう通知する必要があります。ここでは簡単な方法を提供します。
まず、module_common 公共ライブラリ内に BaseApp インターフェースを宣言します:
interface BaseApp {
fun onCreate(application: Application)
}
次に、各コンポーネントはこのインターフェースを実装する App クラスを作成する必要があります。例えば、biz_home コンポーネント:
class HomeApp: BaseApp {
override fun onCreate(application: Application) {
// 初期化はここにまとめます
MLog.d(TAG, "BaseApp HomeApp init")
}
}
最後のステップは、アプリのシェルプロジェクトから application のライフサイクルを配信することです。ここではリフレクション技術を使用します:
val moduleInitArr = arrayOf(
"com.sun.module_common.AppCommon",
"com.sun.biz_home.HomeApp",
"com.sun.biz_msg.MsgApp",
"com.sun.biz_me.MeApp"
)
class App: Application() {
override fun onCreate() {
super.onCreate()
initModuleApp(this)
}
private fun initModuleApp(application: Application) {
try {
for(appName in moduleInitArr) {
val clazz = Class.forName(appName)
val module = clazz.getConstructor().newInstance() as BaseApp
module.onCreate(application)
}
}catch (e: Exception) {
e.printStackTrace()
}
}
}
私たちは、BaseApp インターフェースを実装する各クラスの完全修飾名を moduleInitArr 配列に書き込み、リフレクションを通じて Class オブジェクトを取得し、コンストラクタを取得して実体オブジェクトを作成し、最後に BaseApp の onCreate メソッドを呼び出して application を渡します。この方法で各 Application ライフサイクルメソッドを配信できます。