1. 程式人生 > >迴歸初心:極簡 Android 元件化方案 — AppJoint

迴歸初心:極簡 Android 元件化方案 — AppJoint

Android 元件化的概念大概從兩年前開始有人討論,到目前為止,技術已經慢慢沉澱下來,越來越多團隊開源了自己元件化框架。本人所在團隊從去年開始調研元件化框架,在瞭解社群眾多元件化方案之後,決定自研元件化方案。為什麼明明已經有很多輪子可以用了,卻還是決定要自己造個新輪子呢?

主要的原因是在調研了諸多元件化方案之後,發現儘管它們都有各自的優點,但是依然有一些地方不是令人十分滿意。而其中最重要的一個因素就是引入元件化方案成本較高,對已有專案改造過大。我想這一點應該很多人都有相同的體會,很多時候 我們對於專案的重構是需要與新需求的迭代同步進行的 ,幾乎很難停下來只做專案的元件化。

另外一點,我不太希望自己的專案和某一款元件化框架 強耦合

。 Activity 的路由方案也好,跨模組的同步或非同步方法呼叫也好,我希望能夠沿用專案已有的呼叫方式,而不是使用某款元件化框架自己特定的呼叫方式。例如某個介面已經基於 RxJava 封裝為了 Observable 的介面,我就不太希望因為元件化的關係,這個介面位於另一個模組之後,我就不得不用這個元件化框架定義的方式去呼叫,我還是希望以 RxJava 的方式去呼叫。

PS :有興趣的加入Android工程師交流QQ群:752016839 主要針對Android開發人員提升自己,突破瓶頸, 相信你來學習,會有提升和收穫。

迴歸初心

我認為目前想要進行元件化的專案應該可以分為兩類:

  • 包含有一個 application
    模組,以及一些技術元件的 library 模組(業務無關)。
  • 除了 application 模組以外,已經存在若干包含業務的 library 模組和技術的 library 模組。

無論是哪種型別的專案,面臨的問題應該都是類似的,那就是專案大起來以後,編譯實在是太慢了

除此以外,就是 跨模組的功能呼叫非常不便 ,這個問題主要體現在上面列舉的第二種型別的專案。本人所在的專案在元件化之前就是上面列舉的第二種型別的專案,application 模組最早用來承載業務邏輯程式碼,隨著業務發展,大概是某位開發人員覺得, “不行,這樣下去 application 模組程式碼數量會失控的”,於是後續新的業務模組都會新開一個 library

模組進行開發,就這樣斷斷續續目前有了大概 20+ 個 library 模組(業務相關模組,技術模組不包含在內)。

這種做法是符合軟體工程思想的,但是也帶來了一些棘手的問題,由於 application 模組裡的業務功能和 library 模組裡的業務功能在邏輯地位上是平等的,所以難免會有互相呼叫的情況,但是它們在專案依賴層次上卻不是處於相等的地位,application 呼叫 library 倒沒事,但是反過來呼叫就成了問題。另外,剩下這 20 + 個 library 模組在依賴層次中也不全是屬於同一層次的,library 模組之間互相依賴也很複雜。

所以我期望的元件化方案要求解決的問題很簡單:

  • 業務模組單獨編譯,單獨執行,而不是耗費大量時間全量編譯整個 App
  • 跨模組的呼叫應該優雅,無論兩個模組在依賴樹中處於什麼樣的位置,都可以很簡單的互相呼叫
  • 不要有太多的學習成本,沿用目前已有的開發方式,避免程式碼和具體的元件化框架繫結
  • 元件化的過程可以是漸進的,立即拆分程式碼不是元件化的前置條件
  • 輕量級,不要引入過多中間層次(例如序列化反序列化)導致不必要的效能開銷以及維護複雜度

基於上述的思想,我們開發了 AppJoint 這個框架用來幫助我們實現元件化。

AppJoint 是一個非常簡單有效的方案,引入 AppJoint 進行元件化所有的 API 只包含 3 個註解,加 1 個方法,這可能是目前最簡單的元件化方案了,我們的框架不追求功能要多麼複雜強大,只專注於框架本身實用、簡單與高效。而且整體實現也非常簡單,核心原始碼 不到500行

模組獨立執行遇到的問題

本人接觸最早的元件化方案是 DDComponentForAndroid,學習這個方案給了我很多啟發,在這個方案中,作者提出,可以在 gradle.properties 中新增一個變數 isRunAlone=true ,用來控制某個業務模組是 以 library 模組整合到 App 的全量編譯中 還是 以 application 模組獨立編譯啟動 。不知道是不是很多人也受了相同的啟發,後面很多的元件化框架都是使用類似的方案:

if (isRunAlone.toBoolean()) {    
    apply plugin: 'com.android.application'
} else {  
    apply plugin: 'com.android.library'
}
複製程式碼

根據我本人的實踐,這種方式有一些缺點。首先有一些開源框架在 library 模組中和在 application 模組中使用方法是不一樣的,例如 ButterKinfe , 在 application 中使用 R.id.xxx,在 library 模組中使用 R2.id.xxx ,如果想元件化,程式碼必須保證在兩種情況下都可用,所以基本只能拋棄 ButterKnife 了,這會給專案帶來巨大的改造成本。

除此以外,還有一些開源框架是隻能在 application 模組中配置的,配置完以後對整個專案的所有 library 模組都生效的,例如一些位元組碼修改的框架(比如 AOP 一類的),這是一種情況。還有一種情況,如果原先專案已經是多模組的情況下,可能多個模組的初始化都是放在 application 模組裡,因為 application 模組是 上帝模組,他可以訪問到專案中任意一塊程式碼,所以在這裡做初始化是最省事的。但是現在拆分為模組之後,因為每個模組需要獨立執行,所以模組需要負責自身的初始化,可是有時候這個模組的初始化是隻能在 application 模組裡才可以做的,我們把這段邏輯下放到 library 之後,如何初始化就成了問題。

這兩種情況,如果我們使用 gradle.properties 中的變數來切換 applicationlibrary 的話,我們勢必需要在這個模組中維護兩套邏輯,一套是在 application 模式下的啟動邏輯,一套是在 library 模式下的啟動邏輯。原先這個模組是專注自己本身的業務邏輯的,現在不得不為了能夠獨立作為 application 啟動,而加入許多其他程式碼。一方面 build.gradle 檔案中會充滿很多 if - else,另一方面 Java 原始碼中也會加入許多判斷是否獨立執行的邏輯。

最終 Release App 打包時,這些模組是作為 library 存在的,但是我們為了元件化已經在這個模組中加入了很多幫助該模組獨立執行(以 application 模式)的程式碼(例如模組需要單獨執行,需要一個屬於這個模組的 Laucher Activity),雖然這些程式碼在線上不會生效,可是從潔癖的角度來講,這些程式碼其實不應該被打包進去。其實說了這麼多無非就是想說明,如果我們希望通過某個變數來控制模組以 application 形式還是以 library 形式存在,那麼我們肯定要在這個模組中加入維護兩者的差異的程式碼,而且可能程式碼量還不少,最後程式碼呈現的狀態可能是不太優雅的。

此外模組中的 AndroidManifest.xml 也需要維護兩份:

if (isRunAlone.toBoolean()) {
    manifest.srcFile 'src/main/runalone/AndroidManifest.xml'
} else {
    manifest.srcFile 'src/main/AndroidManifest.xml'
}
複製程式碼

但是 xml 畢竟不是程式碼,沒有封裝繼承這些面向物件的特性,所以每當我們增加、修改、刪除四大元件的時候,都需要記得要在兩個 AndroidManifest.xml 都做對應的修改。除了 AndroidManifest.xml 以外,資原始檔也存在這個問題,雖然工作量不至於特別巨大,但這樣的做法其實已經違背了面向物件的設計原則。

最後還有一個問題,每當模組在 application 模式和 library 模式之間進行切換的時候,都需要重新 Gradle Sync 一次,我想既然是需要元件化的專案那肯定已經是那種編譯速度極慢的專案了,即使是 Gradle Sync 也需要等待不少時間,這點也是我們不太能接收的。

建立多個 Application 模組

我們最後是如何解決模組的單獨編譯執行這個問題的呢?答案是 為每個模組新建一個對應的 application 模組 。也許你會對此表示懷疑:如果為每個業務模組配一個用於獨立啟動的 application 模組,那模組會顯得特別多,專案看起來會非常的亂的。但是其實我們可以把所有用於獨立啟動業務模組的 application 模組收錄到一個目錄中:

projectRoot
  +--app
  +--module1
  +--module2
  +--standalone
  |  +--module1Standalone
  |  +--module2Standalone   
複製程式碼

在上面這個專案結構圖中,app 模組是全量編譯的 application 模組入口,module1module2 是兩個業務 library 模組, module1Standalonemodule2Standalone 是分別使用來獨立啟動 module1module2 的 2 個 application 模組,這兩個模組都被收錄在 standalone 資料夾下面。事實上,standalone 目錄下的模組很少需要修改,所以這個目錄大多數情況下是屬於摺疊狀態,不會影響整個專案結構的美觀。

這樣一來,在專案根目錄下的 settings.gradle 裡的程式碼是這樣的:

// main app
include ':app'
// library modules
include ':module1'
include ':module2'
// for standalone modules
include ':standalone:module1Standalone'
include ':standalone:module2Standalone'
複製程式碼

在主 App 模組(app 模組)的 build.gradle 檔案裡,我們只需要依賴 module1module2 ,兩個 standalone 模組只和各自對應的業務模組的獨立啟動有關,它們不需要被 app 模組依賴,所以 app 模組的 build.gradle 中的依賴部分程式碼如下:

dependencies {
    implementation project(':module1')
    implementation project(':module1')
}
複製程式碼

那些用於獨立執行的 application 模組裡的 build.gradle 檔案中,就只有一個依賴,那就是需要被獨立執行的 library 模組。以 standalone/module1Standalone 為例,它對應的 build.gradle 中的依賴為:

dependencies {
    implementation project(':module1')
}
複製程式碼

在 Android Studio 中建立模組,預設模組是位於專案根目錄之下的,如果希望把模組移動到某個資料夾下面,需要對模組右鍵,選擇 "Refactor -- Move" 移動到指定目錄之下。

當我們建立好這些 application 模組之後,在 Android Studio 的執行小三角按鈕旁邊,就可以選擇我們需要執行哪個模組了:

這樣一來,我們首先可以感受到的一點就是模組不再需要改 gradle.properties 檔案切換 libraryapplication 狀態了,也不再需要忍受 Gradle Sync 浪費寶貴的開發時間,想全量編譯就全量編譯,想單獨啟動就單獨啟動。

由於專門用於單獨啟動的 standalone 模組 的存在,業務的 library 模組只需要按自己是 library 模組這一種情況開發即可,不需要考慮自己會變成 application 模組,所以無論是新開發一個業務模組還是從一個老的業務模組改造成元件化形式的模組,所要做的工作都會比之前更輕鬆。而之前提到的,為了讓業務模組單獨啟動所需要的配置、初始化工作都可以放到 standalone 模組 裡,並且不用擔心這些程式碼被打包到最終 Release 的 App 中,前面例子中提到的用來使模組單獨啟動的 Launcher Activity,只要把它放到 standalone 模組 模組即可。

AndroidManifest.xml 和資原始檔的維護也變輕鬆了。四大元件的增刪改只需要在業務的 library 模組修改即可,不需要維護兩份 AndroidManifest.xml 了,standalone 模組 裡的 AndroidManifest.xml 只需要包含模組獨立啟動時和 library 模組中的 AndroidManifest.xml 不同的地方即可(例如 Launcher Activity 、圖示等),編譯工具會自動完成兩個檔案的 merge。

推薦在 standalone 模組 內指定一個不同於主 App 的 applicationId,即模組單獨啟動的 App 與主 App 可以在手機內共存。

我們分析一下這個方案,和原先的比,首先缺點是,引入了很多新的 standalone 模組,專案似乎變複雜了。但是優點也是明顯的,元件化的邏輯更加清晰,尤其是在老專案改造情況下,所需要付出的工作量更少,而且不需要在開發期間頻繁 Gradle Sync。 總的來說,改造後的元件化專案更符合軟體工程的設計原則,尤其是開閉原則(open for extension, but closed for modification)。

介紹到這裡為止,我們還沒有使用任何 AppJoint 的 API,我們之所以沒有藉助任何元件化框架的 API 來實現模組的獨立啟動,是因為本文一開始提出的,我們不希望專案和任何元件化框架強繫結, 包括 AppJoint 框架本身,AppJoint 框架本身的設計是與專案鬆耦合的,所以使用了 AppJoint 框架進行元件化的專案,如果今後希望可以切換到其它更優秀的元件化方案,理論上是很輕鬆的。

為每個模組準備 Application

在元件化之前,我們常常把專案中需要在啟動時完成的初始化行為,放在自定義的 Application 中,根據本人的專案經驗,初始化行為可以分為以下兩類:

  • 業務相關的初始化。例如伺服器推送長連線建立,資料庫的準備,從伺服器拉取 CMS 配置資訊等。
  • 與業務無關的技術元件的初始化。例如日誌工具、統計工具、效能監控、崩潰收集、相容性方案等。

我們在上一步中,為每個業務模組建立了獨立執行的 standalone 模組 ,但是此時還並不能把業務模組獨立啟動起來,因為模組的初始化工作並沒有完成。我們在前面介紹 AppJoint 的設計思想的時候,曾經說過我們希望元件化方案最好 『不要有太多的學習成本,沿用目前已有的開發方式』,所以這裡我們的解決方案是,在每個業務模組裡新建一個自定義的 Application 類,用來實現該業務模組的初始化邏輯,這裡以在 module1 中新建自定義 Application 為例:

@ModuleSpec
public class Module1Application extends Application {

    @Override
    public void onCreate() {
        super.onCreate();
        // do module1 initialization
        Log.i("module1", "module1 init is called");
    }
}
複製程式碼

如上面的程式碼所示,我們在 module1 中新建一個自定義的 Application 類,名為 Module1Application。那我們是不是應該把與這個模組有關的所有初始化邏輯都放在這個類裡面呢?並不完全是這樣。

首先,對於前面提到的當前模組的 業務相關的初始化 ,毫無疑問應該放在這個 Module1Application 類中,但是針對前面提到的該模組的 與業務無關的技術元件的初始化 放在這裡就不是很合適了。

首先,從邏輯上考慮,業務無關的技術元件的初始化應該放在一個統一的地方,把它們放在主 App 的自定義 Application 類中比較合適,如果每個模組為了自己可以獨立編譯執行,都要自己初始化一遍,那麼所有程式碼最後一起全量編譯的時候,這些初始化行為就會在程式碼中出現好幾次,這樣既不合理,也可能會造成潛在問題。

那麼,如果我們在 Module1Application 中做判斷,如果它自身處於獨立編譯執行狀態,就執行技術元件的初始化,反之,若它處於全量編譯執行狀態中,就不執行技術元件的初始化,由主 App 的 Application 來實現這些邏輯,這樣是否可以呢?理論上這種方案可行,但是這麼做就會遇到和前面提到的 『在 gradle.properties 中維護一個變數來控制模組是否獨立編譯』同樣的問題,我們不希望把和業務無關的邏輯(用於業務模組獨立啟動的邏輯)打包進最終 Release 的 App。

那應該如何解決這個問題呢?解決方案和前面一小節類似,我們不是為 module1 模組準備了一個 module1Standalone 模組嗎?既然技術相關的元件的初始化並不是 module1 模組的核心,只和 module1 模組的獨立啟動有關,那麼放在 module1Standalone 模組裡是最合適的,因為這個模組只會在 module1 的獨立編譯執行中使用到,它的任何程式碼都不會被打包到最終 Release 的 App 中。我們可以在 module1Standalone 中定義一個 Module1StandaloneApplication 類,它從 Module1Application 繼承下來:

public class Module1StandaloneApplication extends Module1Application {

    @Override
    public void onCreate() {
        // module1 init inside super.onCreate()
        super.onCreate();
        // initialization only used for running module1 standalone
        Log.i("module1Standalone", "module1Standalone init is called");
    }
}
複製程式碼

並且我們在 module1Standalone 模組的 AndroidManifest.xml 中把 Module1StandaloneApplication 設定為 Standalone App 使用的自定義 Application 類:

    <application
        android:icon="@mipmap/module1_launcher"
        android:label="@string/module1_app_name"
        android:theme="@style/AppTheme"
        android:name=".Module1StandaloneApplication">
        <activity android:name=".Module1MainActivity">
            <intent-filter>
                <action android:name="android.intent.action.MAIN"/>
                <category android:name="android.intent.category.LAUNCHER"/>
            </intent-filter>
        </activity>
    </application>
複製程式碼

在上面的程式碼中,我們除了設定了自定義的 Application 以外,還設定了一個 Launcher Activity (Module1MainActivity),這個 Activity 即為模組的啟動 Activity,由於它只存在於模組的獨立編譯執行期間,App 全量打包時是不包含這個 Module1MainActivity 的,所以我們可以在裡面定義一些方便模組獨立除錯的功能,例如快速前往某個頁面以及建立 Mock 資料。

這樣,只要我們單獨執行 module1Standalone 這個模組的時候,使用的 Application 類就是 Module1StandaloneApplication。在開發時,我們需要單獨除錯 module1 時,我們只需要啟動 module1Standalone 這個模組進行除錯即可;而在 App 需要全量編譯時,我們則正常啟動原來的主 App 。無論是哪種情況, module1 這個模組始終是以 library 形式存在的,這意味著,如果我們希望把原先的業務模組改造成元件化模組,需要的改造量縮小很多,我們改造的過程主要是在 增加程式碼,而不是 修改程式碼,這點符合軟體工程中的『開閉原則』。

寫到這裡,我們其實還有一個問題沒有解決。Module1Application 目前除了被 Module1StandaloneApplication 繼承以外,沒有被任何其它地方引用到。您可能會有疑問:那我們如何保證 App 全量編譯執行時,Module1Application 裡的初始化邏輯會被呼叫到呢?細心的您可能早就已經發現了:我們在上面定義 Module1Application 時,同時標記了一個註解 @ModuleSpec:

@ModuleSpec
public class Module1Application extends Application {
    ...
}
複製程式碼

這個註解的作用是告知 AppJoint 框架,我們需要確保當前模組該 Application 中的初始化行為,能夠在最終全量編譯時,被主 App 的 Application 類呼叫到。所以對應的,我們的主 App 模組(app 模組)的自定義 Application 類也需要被一個註解 -- AppSpec 標記,程式碼如下所示:

@AppSpec
public class App extends Application {
    ...
}
複製程式碼

上面程式碼中的 App 為主 App 對應的自定義 Application 類,我們給這個類上方標記了 @AppSpec 註解,這樣系統在執行 App 自身初始化的同時會一併執行這些子模組的 Application 裡對應宣告週期的初始化。即:

  • App 執行 onCreate 方法時,保證也同時執行 Module1ApplicationModule2ApplicationonCreate 方法 。
  • App 執行 attachBaseContext 方法時,保證也同時執行 Module1ApplicationModule2ApplicationattachBaseContext 方法。
  • 依次類推,當 App 執行某個生命週期方法時,保證子模組的 Application 的對應的生命週期方法也會被執行。

這樣,我們通過 AppJoint@ModuleSpec@AppSpec 兩個註解,在主 App 的 Application 和子模組的 Application 之間建立了聯絡,保證了在全量編譯執行時,所有業務模組的初始化行為都能被保證執行。

到這裡為止,我們已經處理好了業務模組在 獨立編譯執行模式全量編譯執行模式 這兩種情況下的初始化問題,目前關於 Application 還有一個潛在問題,我們的專案在元件化之前,我們經常會在 Applictaion 類的 onCreate 週期儲存當前 Appliction 的引用,然後在應用的任何地方都可以使用這個 Application 物件,例如下面這樣:

public class App extends Application {

    public static App INSTANCE;

    @Override
    public void onCreate() {
        super.onCreate();
        INSTANCE = this;
    }
}
複製程式碼

這麼處理之後,我們可以在專案任意位置通過 App.INSTANCE 使用 Application Context 物件。但是,現在元件化改造以後,以 module1 為例,在獨立執行模式時,應用的 Application 物件是 Module1StandaloneApplication 的例項,而在全量編譯執行模式時,應用的 Application 物件是主 App 模組的 App 的例項,我們如何能像之前一樣,做到在專案中任何一個地方都能獲取到當前使用的 Application 例項呢?

我們可以把專案中所有自定義 Application 內部儲存的自身的 Application 例項的型別,從具體的自定義類,改為標準的 Application 型別,以 Module1Application 為例:

@ModuleSpec
public class Module1Application extends Application {
    
    public static Application INSTANCE;

    @Override
    public void onCreate() {
        super.onCreate();
        INSTANCE = (Application)getApplicationContext()
        // do module1 initialization
        Log.i("module1", "module1 init is called");
    }
}
複製程式碼

我們可以看到,如果按原來的寫法, INSTANCE 的型別一般是具體的自定義型別 Module1Application,現在我們改成了 Application。同時 onCreate 方法裡為 INSTANCE 賦值的語句不再是 INSTANCE = this,而是 INSTANCE = (Application)getApplicationContext()。這樣處理以後,就可以保證 module1 裡面的程式碼,無論是在 App 全量編譯模式下,還是獨立編譯除錯模式下,都可以通過 Module1Application.INSTANCE 訪問當前的 Application 例項。這是由於 AppJoint 框架 保證了當主 App 的 App 物件被呼叫 attachBaseContext 回撥時,所有元件化業務模組的 Application 也會被呼叫 attachBaseContext 這個回撥

這樣,我們在 module1 這個模組裡的任何位置使用 Module1Application.INSTANCE 總能正確地獲得 Application 的例項。對應的,我們使用相同的方法在 module2 這個模組裡,也可以在任何位置使用 Module2Application.INSTANCE 正確地獲得 Application 的例項,而不需要知道當前處於獨立編譯執行狀態還是全量編譯執行狀態。

一定不要 依賴某個業務模組自身定義的 Application 類的例項(例如 Module1Application 的例項),因為在執行時真正使用的 Application 例項可能不是它。

我們已經解決業務模組在 單獨編譯執行模式 下和在 App 全量編譯模式 下,初始化邏輯應該如何組織的問題。我們沿用了我們熟悉的自定義 Application 方案,來承載各個模組的初始化行為,同時利用 AppJoint 這個膠水,把每個模組的初始化邏輯整合到最終全量編譯的 App 中。而這一切和 AppJoint 有關的 API 僅僅是兩個註解,這裡很好的說明了 AppJoint 是個學習成本低的工具,我們可以沿用我們已有的開發方式而不是改造我們原有的程式碼邏輯導致專案和元件化框架造成過度耦合。

跨模組方法的呼叫

雖然目前每個模組已經有獨立編譯執行的可能了,但是開發一個成熟的 App 我們還有一個重要的問題沒有解決,那就是跨模組的方法呼叫。因為我們的業務模組無論是從業務邏輯上考慮還是從在依賴樹上的位置考慮,都應該是具有同等的地位的,體現在依賴層次上,這些業務模組應該是平級的,且互相之間沒有依賴:

上圖是我們比較理想情況下的元件化的最終狀態,App 模組不承載任何業務邏輯,它的作用僅僅是作為一個 application 殼把 Module1 ~ Module(n) 這個 n 個模組的功能都整合在一起成為一個完整的 App。Module1 ~ Module(n) 這 n 個模組互相之間不存在任何交叉依賴,它們各自僅包含各自的業務邏輯。這種方式雖然完成了業務模組之間的解耦,但是給我們帶來的新的挑戰:業務模組之間互相呼叫彼此的功能是非常常見且合理的需求,但是由於這些模組在依賴層次上位於同一層次,所以顯然是無法直接呼叫的。

此外,上圖的這種形態是元件化的最終的理想狀態,如果我們要將專案改造以達到這種狀態,毫無疑問需要付出巨大的時間成本。在業務快速迭代期間,這是我們無法承擔的成本,我們只能逐漸地改造專案,也就是說,App 模組內的業務程式碼是被逐漸拆解出來形成新的獨立模組的,這意味著在元件化過程的相當長一段時間內,App 內還是存在業務程式碼的,而被拆解出來的模組內的業務邏輯程式碼,是有可能呼叫到 App 模組內的程式碼的。這是一種很尷尬的狀態,在依賴層次中,位於依賴層次較低位置的程式碼反而要去呼叫依賴層次較高位置的程式碼。

針對這種情況,我們比較容易想到,我們再新建一個模組,例如 router 模組,我們在這個模組內定義 所有業務模組希望暴露給其它模組呼叫的方法,如下圖:

projectRoot
  +--app
  +--module1
  +--module2
  +--standalone
  +--router
  |  +--main
  |  |  +--java
  |  |  |  +--com.yourPackage
  |  |  |  |  +--AppRouter.java
  |  |  |  |  +--Module1Router.java
  |  |  |  |  +--Module2Router.java
複製程式碼

在上面的專案結構層次中,我們在新建的 router 模組下定義了 3 個 介面

  • AppRouter 介面聲明瞭 app 模組暴露給 module1module2 的方法的定義。
  • Module1Router 介面聲明瞭 module1 模組暴露給 appmodule2 的方法的定義。
  • Module2Router 介面聲明瞭 module2 模組暴露給 module1app 的方法的定義。

AppRouter 介面檔案為例,這個介面的定義如下:

public interface AppRouter {

    /**
     * 普通的同步方法呼叫
     */
    String syncMethodOfApp();

    /**
     * 以 RxJava 形式封裝的非同步方法
     */
    Observable<String> asyncMethod1OfApp();

    /**
     * 以 Callback 形式封裝的非同步方法
     */
    void asyncMethod2OfApp(Callback<String> callback);
}
複製程式碼

我們在 AppRouter 這個介面內定義了 1 個同步方法,2 個非同步方法,這些方法是 app 模組需要暴露給 module1module2 的方法,同時 app 模組自身也需要提供這個介面的實現,所以首先我們需要在 appmodule1module2 這三個模組的 build.gradle 檔案中依賴 router 這個模組:

dependencies {
    // Other dependencies
    ...
    api project(":router")
}
複製程式碼

這裡依賴 router 模組的方式是使用 api 而不是 implementation 是為了把 router 模組的資訊暴露給依賴了這些業務模組的 standalone 模組app 模組由於沒有別的模組依賴它,不受上面所說的限制,可以寫成 implementation 依賴。

然後我們回到 app 模組,為剛剛在 router 定義的 AppRouter 介面提供一個實現:

@ServiceProvider
public class AppRouterImpl implements AppRouter {

    @Override
    public String syncMethodOfApp() {
        return "syncMethodResult";
    }

    @Override
    public Observable<String> asyncMethod1OfApp() {
        return Observable.just("asyncMethod1Result");
    }

    @Override
    public void asyncMethod2OfApp(final Callback<String> callback) {
        new Thread(new Runnable() {
            @Override
            public void run() {
                callback.onResult("asyncMethod2Result");
            }
        }).start();
    }
}
複製程式碼

我們可以發現,我們把 app 模組內的方法暴露給其它模組的方式和我們平時寫程式碼並沒有什麼不同,就是宣告一個介面提供給其它模組,同時在自己內部編寫一個這個介面的實現類。無論是同步還是非同步,無論是 Callback 的方式,還是 RxJava 的方式,都可以使用我們原有的開發方式。唯一的區別就是,我們在 AppRouterImpl 實現類上方標記了一個 @ServiceProvider 註解,這個註解的作用是用來通知 AppJoint 框架在 AppRouterAppRouterImpl 之間建立聯絡,這樣其它模組就可以通過 AppJoint 找到一個 AppRouter 的例項並呼叫裡面的方法了。

假設現在 module1 中需要呼叫 app 模組中的 asyncMethod1OfApp 方法,由於 app 模組已經把這個方法宣告在了 router 模組的 AppRouter 介面中了,module1 由於也依賴了 router 模組,所以 module1 內可以訪問到 AppRouter 這個介面,但是卻訪問不到 AppRouterImpl 這個實現類,因為這個類定義在 app 模組內,這時候我們可以使用 AppJoint 來幫助 module1 獲取 AppRouter 的例項:

AppRouter appRouter = AppJoint.service(AppRouter.class);

// 獲得同步呼叫的結果        
String syncResult = appRouter.syncMethodOfApp();
// 發起非同步呼叫
appRouter.asyncMethod1OfApp()
        .subscribe((result) -> {
            // handle asyncResult
        });
// 發起非同步呼叫
appRouter.asyncMethod2OfApp(new Callback<String>() {
    @Override
    public void onResult(String data) {
        // handle asyncResult
    }
});
複製程式碼

在上面的程式碼中,我們可以看到,除了第一步獲取 AppRouter 介面的例項我們用到了 AppJoint 的 API AppJoint.service 以外,剩下的程式碼,module1 呼叫 app 模組內的方法的方式,和我們原來的開發方式沒有任何區別。AppJoint.service 就是 AppJoint 所有 API 裡唯一的那個方法。

也就是說,如果一個模組需要提供方法供其他模組呼叫,需要做以下步驟:

  • 把介面宣告在 router 模組中
  • 在自己模組內部實現上一步中宣告的介面,同時在實現類上標記 @ServiceProvider 註解

完成這兩步以後就可以在其它模組中使用以下方式獲取該模組宣告的介面的例項,並呼叫裡面的方法:

AppRouter appRouter = AppJoint.service(AppRouter.class);
Module1Router module1Router = AppJoint.service(Module1Router.class);
Module2Router module2Router = AppJoint.service(Module2Router.class);
複製程式碼

這種方法不僅僅可以保證處於相同依賴層次的業務模組可以互相呼叫彼此的方法,還可以支援從業務模組中呼叫 app 模組內的方法。這樣就可以 保證我們元件化的過程可以是漸進的 ,我們不需要一口氣把 app 模組中的所有功能全部拆分到各個業務模組中,我們可以逐漸地把功能拆分出來,以保證我們的業務迭代和元件化改造同時進行。當我們的 AppRouter 裡面的方法越來越少直到最後可以把這個類從專案中安全刪除的時候,我們的元件化改造就完成了。

模組獨立編譯執行模式下跨模組方法的呼叫

上面一個小結中我們已經介紹了使用 AppJoint 在 App 全量編譯執行期間,業務模組之間跨模組方法呼叫的解決方案。在全量編譯期間,我們可以通過 AppJoint.service 這個方法找到指定模組提供的介面的例項,但是在模組單獨編譯執行期間,其它的模組是不參與編譯的,它們的程式碼也不會打包進用於模組獨立執行的 standalaone 模組,我們如何解決在模組單獨編譯執行模式下,跨模組呼叫的程式碼依然有效呢?

module1 為例,首先為了便於在 module1 內部任何地方都可以呼叫其它模組的方法,我們建立一個 RouterServices 類用於存放其它模組的介面的例項:

public class RouterServices {
    // app 模組對外暴露的介面
    public static AppRouter sAppRouter = AppJoint.service(AppRouter.class);
    // module2 模組對外暴露的介面
    public static Module2Router sModule2Router = AppJoint.service(Module2Router.class);
}
複製程式碼

有了這個類以後,我們在 module1 內部如果需要呼叫其它模組的功能,我們只需要使用 RouterServices.sAppRouterRouterServices.sModule2Router 這兩個物件就可以了。但是如果是剛剛提到的 module1 獨立編譯執行的情況,即啟動的 application 模組是 module1Standalone, 那麼 RouterServices.sAppRouterRouterServices.sModule2Router 這兩個物件的值均為 null ,這是因為 appmodule2 這兩個模組此時是沒有被編譯進來的。

如果我們需要在這種情況下保證已有的 module1 內部的通過 RouterServices.sAppRouterRouterServices.sModule2Router 進行跨模組方法呼叫的程式碼依然能工作,我們就需要對這兩個引用手動賦值,即我們需要建立 Mock 了 AppRouterModule2Router 功能的類。這些類由於只對 module1 的獨立編譯執行有意義,所以這些類最合適的位置是放在 module1Standalone 這個模組內,以 AppRouter 的 Mock 類 AppRouterMock 為例:

public class AppRouterMock implements AppRouter {
    @Override
    public String syncMethodOfApp() {
        return "mockSyncMethodOfApp";
    }

    @Override
    public Observable<String> asyncMethod1OfApp() {
        return Observable.just("mockAsyncMethod1OfApp");
    }

    @Override
    public void asyncMethod2OfApp(final Callback<String> callback) {
        new Thread(new Runnable() {
            @Override
            public void run() {
                callback.onResult("mockAsyncMethod2Result");
            }
        }).start();
    }
}
複製程式碼

已經建立好了 Mock 類,接下來我們要做的是,在 module1 獨立編譯執行的模式下,用 Mock 類的物件,去替換 RouterServices 裡面的對應的引用,由於這些邏輯只和 module1 的獨立編譯執行有關,我們不希望這些邏輯被打包進真正 Release 的 App 中,那麼最合適的地方就是 Module1StandaloneApplication裡了:

public class Module1StandaloneApplication extends Module1Application {

    @Override
    public void onCreate() {
        // module1 init inside super.onCreate()
        super.onCreate();
        // initialization only used for running module1 standalone
        Log.i("module1Standalone", "module1Standalone init is called");

        // Replace instances inside RouterServices
        RouterServices.sAppRouter = new AppRouterMock();
        RouterServices.sModule2Router = new Module2RouterMock();
    }
}
複製程式碼

有了上面的初始化動作以後,我們就可以在 module1 內部安全地使用 RouterServices.sAppRouterRouterServices.sModule2Router 這兩個物件進行跨模組的方法呼叫了,無論當前是處於 App 全量編譯模式還是 modul1Standalone 獨立編譯執行模式。

跨模組啟動 Activity 和 Fragment

在元件化改造過程中,除了跨模組的方法呼叫之外,跨模組啟動 Activity 和跨模組引用 Fragment 也是我們經常遇到的需求。目前社群中大多陣列件化方案都是使用自定義私有協議,使用 URL-Scheme 的方式來實現跨模組 Activity 的啟動,這一塊已經有很多成熟的方案了,有的元件化方案直接推薦使用 ARouter 來實現這塊功能。但是 AppJoint 沒有使用這類方案

本文開頭曾經介紹過,AppJoint 所有的 API 只包含 3 個註解加 1 個方法,而這些 API 我們在前文中已經都介紹完了,也就是說,我們沒有提供專門的 API 來實現跨模組的 Activity / Fragment 呼叫

我們回想一下,在沒有實現元件化時,我們啟動 Activity 的推薦寫法如下,首先在被啟動的 Activity 內實現一個靜態 start 方法:

public class MyActivity extends AppCompatActivity {

    public static void start(Context context, String param1, Integer param2) {
        Intent intent = new Intent(context, MyActivity.class);  
        intent.putExtra("param1", param1);  
        intent.putExtra("param2", param2);  
        context.startActivity(intent);
    }

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        ...
    }
}
複製程式碼

然後我們如果在其它 Activity 中啟動這個 MyActivity 的話,寫法如下:

MyActivity.start(param1, param2);
複製程式碼

這裡的思想是,服務的提供者應該把複雜的邏輯放在自己這裡,而只提供給呼叫者一個簡單的介面,用這個簡單的介面隔離具體實現的複雜性,這是符合軟體工程思想的。

那麼如果目前 module1 模組中有一個 Module1Activity,現在這個 Activity 希望能夠從 module2 啟動,應該如何寫呢?首先,在 router 模組的 Module1Router 內宣告啟動 Module1Activity 的方法:

public interface Module1Router {
    ...
    // 啟動 Module1Activity
    void startModule1Activity(Context context);
}
複製程式碼

然後在 module1 模組裡 Module1Router 對應的實現類 Module1RouterImpl 中實現剛剛定義的方法:

@ServiceProvider
public class Module1RouterImpl implements Module1Router {

    ...

    @Override
    public void startModule1Activity(Context context) {
        Intent intent = new Intent(context, Module1Activity.class);
        context.startActivity(intent);
    }
}
複製程式碼

這樣, module2 中就可以通過下面的方式啟動 module1 中的 Module1Activity 了。

RouterServices.sModule1Router.startModule1Activity(context);
複製程式碼

跨模組獲取 Fragment 例項也是類似的方法,我們在 Module1Router 裡繼續宣告方法:

public interface Module1Router {
    ...
    // 啟動 Module1Activity
    void startModule1Activity(Context context);

    // 獲取 Module1Fragment
    Fragment obtainModule1Fragment();
}
複製程式碼

差不多的寫法,我們只要在 Module1RouterImpl 裡接著實現方法即可:

@ServiceProvider
public class Module1RouterImpl implements Module1Router {
    @Override
    public void startModule1Activity(Context context) {
        Intent intent = new Intent(context, Module1Activity.class);
        context.startActivity(intent);
    }

    @Override
    public Fragment obtainModule1Fragment() {
        Fragment fragment = new Module1Fragment();
        Bundle bundle = new Bundle();
        bundle.putString("param1", "value1");
        bundle.putString("param2", "value2");
        fragment.setArguments(bundle);
        return fragment;
    }
}
複製程式碼

前面提到過,目前社群大多陣列件化方案都是使用 自定義私有協議,利用 URL-Scheme 的方式來實現跨模組頁面跳轉 的,即類似 ARouter 的那種方案,為什麼 AppJoint 不採用這種方案呢?

原因其實很簡單,假設專案中沒有元件化的需求,我們在同一個模組內進行 Activity 的跳轉,肯定不會採用 URL-Scheme 方式進行跳轉,我們肯定是自己建立 Intent 進行跳轉的。其實說到底,使用 URL-Scheme 進行跳轉是 不得已而為之,它只是手段,不是目的,因為在元件化之後,模組之間彼此的 Activity 變得不可見了,所以我們轉而使用 URL-Scheme 的方式進行跳轉。

現在 AppJoint 重新支援了使用程式碼進行跳轉,只需要把跳轉的邏輯抽象為介面中的方法暴露給其它模組,其它模組就可以呼叫這個方法實現跳轉邏輯。除此以外,使用介面提供跳轉邏輯相比 URL-Scheme 方式還有什麼優勢呢?

  1. 型別安全。充分利用 Java 這種靜態型別語言的編譯器檢查功能,通過介面暴露的跳轉方法,無論是傳參還是返回值,如果型別錯誤,在編譯期間就能發現錯誤,而使用 URL-Scheme 進行跳轉,如果發生型別上的錯誤,只能在執行期間才能發現錯誤。

  2. 效率高。即使是使用 URL-Scheme 進行跳轉,底層仍然是構造 Intent 進行跳轉,但是卻額外引入了對跳轉 URL 進行構造和解析的過程,涉及到額外的序列化和反序列化邏輯,降低了程式碼的執行效率。而使用介面提供的跳轉邏輯,我們直接構造 Intent 進行跳轉,不涉及到任何額外的序列化和反序列化操作,和我們日常的 Activity 跳轉邏輯執行效率相同。

  3. IDE 友好。使用 URL-Scheme 進行跳轉,IDE 無法提供任何智慧提示,只能依靠完善的文件或者開發者自身檢查來確保跳轉邏輯的正確性,而通過介面提供跳轉邏輯可以最大限度發揮 IDE 的智慧提示功能,確保我們的跳轉邏輯是正確的。

  4. 易於重構。使用 URL-Scheme 進行跳轉,如果遇到跳轉邏輯需要重構的情況,例如 Activity 名字的修改,引數名稱的修改,引數數量的增刪,只能依靠開發者對使用到跳轉邏輯的地方一個一個修改,而且無法確保全部都修改正確了,因為編譯器無法幫我們檢查。而通過介面提供的跳轉邏輯程式碼需要重構時,編譯器可以自動幫助我們檢查,一旦有地方沒有改對,直接在編譯期報錯,而且 IDE 都提供了智慧重構的功能,我們可以方便地對介面中定義的方法進行重構。

  5. 學習成本低。我們可以沿用我們熟悉的開發方式,不需要去學習 URL-Scheme 跳轉框架的 API。這樣還可以保證我們的跳轉邏輯不與具體的框架強繫結,我們通過介面隔離了跳轉邏輯的真正實現,即使使用 AppJoint 進行跳轉,我們也可以在隨時把跳轉邏輯切換到其他方案,包括 URL-Scheme 方式。

我個人的實踐,目前專案中同一程序內的頁面跳轉已經全部由 AppJoint 的方式實現,目前只有跨程序的頁面啟動交給了 URL-Scheme 這種方式(例如從瀏覽器喚醒 App 某個頁面)。

最後再提一點,由於跨模組啟動 Activity 沿用了跨模組方法呼叫的開發方式,在業務模組單獨編譯執行模式下,我們也需要 Mock 這些啟動方法。既然我們是在獨立除錯某個業務模組,我們肯定不是真的希望跳轉到那些頁面,我們在 Mock 方法裡直接輸出 Log 或者 Toast 即可。

現在就開始元件化

到這裡為止,使用 AppJoint 進行元件化的介紹就已經結束了。AppJoint 的 Github 地址為:github.com/PrototypeZ/… 。核心程式碼不超過 500 行,您完全可以快速掌握這個工具加速您的元件化開發,只要 Fork 一份程式碼即可。如果您不想自己引入工程,我們也提供了一個開箱即用的版本,您可以直接通過 Gradle 引入。

  1. 在專案根目錄的 build.gradle 檔案中新增 AppJoint外掛 依賴:
buildscript {
    ...
    dependencies {
        ...
        classpath 'io.github.prototypez:app-joint:{latest_version}'
    }
}
複製程式碼
  1. 在主 App 模組和每個元件化的模組新增 AppJoint 依賴:
dependencies {
    ...
    implementation "io.github.prototypez:app-joint-core:{latest_version}"
}
複製程式碼
  1. 在主 App 模組應用 AppJoint外掛
apply plugin: 'com.android.application'
apply plugin: 'app-joint'
複製程式碼

寫在最後

通過本文的介紹,我們其實可以發現 AppJoint 是個思想很簡單的元件化方案。雖然簡單,但是卻直接而且夠用,儘管沒有像其它的元件化方案那樣提供了各種各樣強大的 API,但是卻足以勝任大多數中小型專案,這是我們一以貫之的設計理念。

如果您感覺這個專案對您有幫助,希望可以點一個 Star ,謝謝 : ) 。文章很長,感謝您耐心讀完。由於本人能力有限,文章可能存在紕漏的地方,歡迎各位指正,再次謝謝大家!