為什麼需要組件化#
小項目是不需要組件化的。當一個項目有數十個人開發,編譯項目要花費 10 分鐘,修改一個 bug 就可能會影響到其他業務,小小的改動就需要進行回歸測試,如果是這種項目,那麼我們需要進行組件化了。
組件化和模塊化#
在技術架構演進的過程一定是先出現模塊化後出現組件化,因為組件化就是解決了模塊化的問題。
模塊化架構#
創建一個 Project 後可以創建多個 Module,這個 Module 就是所謂的模塊。一個簡單的例子,可能在寫代碼的時候我們會把首頁、消息、我的模塊拆開,每個 tab 所包含的內容就是一個模塊,這樣可以減少 module 的代碼量,但是每個模塊之間的肯定是有頁面的跳轉,數據傳遞等,比如 A 模塊需要 B 模塊的數據,於是我們會在 A 模塊的 gradle 文件內通過 implementation project(':B')
依賴 B 模塊,但是 B 模塊又需要跳轉到 A 模塊的某個頁面,於是 B 模塊又依賴了 A 模塊。這樣的開發模式依然沒有解耦,改一個 bug 依然會改動很多模塊,並不能解決大型項目的問題。
組件化架構#
這裡先提幾個概念,我們日常業務需求開發的組件叫做業務組件,如果這個業務需求是可以被普遍復用的,那麼叫做業務基礎組件,譬如圖片加載、網絡請求等框架組件我們稱為基礎組件。搭建所有組件的 app 組件稱為殼組件 / 工程。
這裡先提幾個概念,我們日常業務需求開發的組件叫做業務組件,如果這個業務需求是可以被普遍復用的,那麼叫做業務基礎組件,譬如圖片加載、網絡請求等框架組件我們稱為基礎組件。搭建所有組件的 app 組件稱為殼組件 / 工程。接下來看一張架構圖:
實線表示直接依賴關係,虛線表示間接依賴。比如殼工程肯定是要依賴業務基礎組件、業務組件、module_common 公共庫的。業務組件依賴業務基礎組件,但並不是直接依賴,而是通過” 下沉接口 “來實現間接調用。業務組件之間的依賴也是間接依賴。最後 common 組件依賴所有需要的基礎組件,common 也屬於基礎組件,它只是統一了基礎組件的版本,同時也提供了給應用提供一些抽象基類,比如 BaseActivity、BaseFragment,基礎組件初始化等。
組件化帶來的優勢#
** 加快編譯速度:** 每個業務組件都可以單獨運行調試,速度提升好幾倍。舉個例子:video 組件單獨編譯運行時間為 3s,因為此時 AS 只會運行 video 組件以及 video 組件依賴的組件的 task,而如果集成編譯時間為 10s,app 所引用的所有的組件的 task 都會執行。可見,效率提升了 3 倍。
** 提高協作效率:** 每個組件都有專人維護,不用關心其他組件是怎麼實現的,只需要暴露對方需要的數據。測試也不需要整個回歸,只需要重點測試修改的組件即可。
** 功能重用:** 一次編碼處處復用,再也不需要複製代碼了。尤其是基礎組件和業務基礎組件,基本上調用者根據文檔就可以一鍵集成和使用。
前面有提到非大型項目一般不會進行組件化,但是就像上面提到的功能重用,這個優勢並不是只能用到大型項目。我們可以在寫需求或庫時完全可以擁有組件化思想,把它們單獨寫成一個基礎組件或業務基礎組件。當第二個項目來的時候正好也需要這個組件,那我們就省去了拆出這個組件的時間(因為寫需求的時候很可能會造成大量耦合,後續拆分要花費時間),比如登錄組件,分享組件等等都是可以在一開始就寫成組件的。
組件化需解決的問題#
業務組件如何實現單獨調試?
業務組件間沒有依賴,如何實現頁面跳轉?
業務組件間沒有依賴,如何實現數據通信?
殼工程 Application 生命週期如何下發?
獨立調試#
單工程方案#
所謂的單工程方案就是把所有組件都放到一個工程下,先看一下整體的目錄:
ps:module_ 開頭表示基礎組件,fun_ 前綴表示業務基礎組件,biz_前綴表示業務組件,export_前綴表示業務組件暴露接口。
單工程利弊分析:
- 利:一個模塊修改後只需要編譯一下,依賴它的其他模塊就能馬上感知到變化。
- 弊:沒能做到完全的職責拆分,每個模塊的開發者都有修改其他模塊的權限。
首先在 gradle.properties 文件內聲明一個變量:
// gradle.properties
isModule = true
isModule 為 true 時表示組件可以作為 apk 運行起來,false 表示組件只能作為 library。我們根據需要改變這個值後同步下 gradle 即可。
然後在某個 module 的 build.gradle 文件內用這個變量做三個地方的判斷:
// 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'
}
}
}
}
由於 library 是不需要 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>
library 的 AndroidManifest.xml 不需要這些:
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.sun.biz_home">
</manifest>
gradle 依賴 module 的方式主要有兩種:
- 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')
}
多工程方案#
多工程就是每個組件都是一個工程,例如創建一個工程後 app 作為殼組件,它依賴 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",
// our lib
"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"]
}
個人覺得多工程適合 "很大" 的工程,每個業務組件可能都需要一個組開發,類似淘寶這樣的 app。但這只是針對業務組件來說的,業務基礎組件和基礎組件修改的頻率不會很大,最好都是單工程上傳至 maven 倉庫來使用。本文的例子是為了方便所以把所有組件寫到一起了,最好的方式就是把 fun_ 和 module_ 開頭的組件都拆分成單工程獨立開發,業務組件寫到一個工程內。
頁面跳轉#
做完組件之間的隔離後,暴露出來最明顯的問題就是頁面跳轉和數據通信的問題。一般來說,頁面跳轉都是顯示 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 把所有通信的方式都用一種 api 實現,讓使用者上手非常容易。
#
Application 生命週期分發#
當 app 殼工程啟動 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")
}
}
剩下最後一步就是從 app 殼工程分發 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 生命週期的方法都可以通過這種方式傳遞。