(本文提出的元件化方案已經開源,參見Android徹底元件化開源專案)
今年6月份開始,我開始負責對“得到app”的android程式碼進行元件化拆分,在動手之前我查閱了很多元件化或者模組化的文章,雖然有一些收穫,但是很少有文章能夠給出一個整體且有效的方案,大部分文章都只停留在元件單獨除錯的層面上,涉及元件之間的互動就很少了,更不用說元件生命週期、整合除錯和程式碼邊界這些最棘手的問題了。有感於此,我覺得很有必要設計一套完整的元件化方案,經過幾周的思考,反覆的推倒重建,終於形成了一個完整的思路,整理在我的第一篇文章中Android徹底元件化方案實踐。這兩個月以來,得到的Android團隊按照這個方案開始了元件化的拆分,經過兩期的努力,目前已經拆分兩個大的業務元件以及數個底層lib庫,並對之前的方案進行了一些完善。從使用效果上來看,這套方案完全可以達到了我們之前對元件化的預期,並且架構簡單,學習成本低,對於一個急需快速元件化拆分的專案是很適合的。現在將這套方案開源出來,歡迎大家共同完善。程式碼地址:https://github.com/luojilab/DDComponentForAndroid
雖說開源的是一個整體的方案,程式碼量其實很少,簡單起見demo中做了一些簡化,請大家在實際應用中注意一下幾點: (1)目前元件化的編譯指令碼是通過一個gradle plugin提供的,現在這個外掛釋出在本地的repo資料夾中,真正使用的使用請釋出到自己公司的maven庫 (2)元件開發完成後釋出aar到公共倉庫,在demo中這個倉庫用componentrelease的資料夾代替,這裡同樣需要換成本地的maven庫 (3)方案更側重的是單獨除錯、整合編譯、生命週期和程式碼邊界等方面,我認為這幾部分是已發表的元件化方案所缺乏的或者比較模糊的。元件之間的互動採用介面+實現的方式,UI之間的跳轉用的是一箇中央路由的方式,在這兩方面目前已有一些更完善的方案,例如通過註解來暴露服務以及自動生成UI跳轉程式碼等,這也是該方案後面需要著力優化的地方。如果你已經有更好的方案,可以替換,更歡迎推薦給我。
一、AndroidComponent使用指南
首先我們看一下demo的程式碼結構,然後根據這個結構圖再次從單獨除錯(釋出)、元件互動、UI跳轉、整合除錯、程式碼邊界和生命週期等六個方面深入分析,之所以說“再次”,是因為上一篇文章我們已經講了這六個方面的原理,這篇文章更側重其具體實現。
程式碼中的各個module基本和圖中對應,從上到下依次是:
- app是主專案,負責整合眾多元件,控制元件的生命週期
- reader和share是我們拆分的兩個元件
- componentservice中定義了所有的元件提供的服務
- basicres定義了全域性通用的theme和color等公共資源
- basiclib中是公共的基礎庫,一些第三方的庫(okhttp等)也統一交給basiclib來引入
圖中沒有體現的module有兩個,一個是componentlib,這個是我們元件化的基礎庫,像Router/UIRouter等都定義在這裡;另一個是build-gradle,這個是我們元件化編譯的gradle外掛,也是整個元件化方案的核心。
我們在demo中要實現的場景是:主專案app整合reader和share兩個元件,其中reader提供一個讀書的fragment給app呼叫(元件互動),share提供一個activity來給reader來呼叫(UI跳轉)。主專案app可以動態的新增和解除安裝share元件(生命週期)。而整合除錯和程式碼邊界是通過build-gradle外掛來實現的。 ###1 單獨除錯和釋出 單獨除錯的配置與上篇文章基本一致,通過在元件工程下的gradle.properties檔案中設定一個isRunAlone的變數來區分不同的場景,唯一的不同點是在元件的build.gradle中不需要寫下面的樣板程式碼:
if(isRunAlone.toBoolean()){
apply plugin: 'com.android.application'
}else{
apply plugin: 'com.android.library'
}
複製程式碼
而只需要引入一個外掛com.dd.comgradle(原始碼就在build-gradle),在這個外掛中會自動判斷apply com.android.library還是com.android.application。實際上這個外掛還能做更“智慧”的事情,這個在整合除錯章節中會詳細闡述。
單獨除錯所必須的AndroidManifest.xml、application、入口activity等類定義在src/main/runalone下面,這個比較簡單就不贅述了。
如果元件開發並測試完成,需要釋出一個release版本的aar檔案到中央倉庫,只需要把isRunAlone修改為false,然後執行assembleRelease命令就可以了。這裡簡單起見沒有進行版本管理,大家如果需要自己加上就好了。值得注意的是,釋出元件是唯一需要修改isRunAlone=false的情況,即使後面將元件整合到app中,也不需要修改isRunAlone的值,既保持isRunAlone=true即可。所以實際上在Androidstudio中,是可以看到三個application工程的,隨便點選一個都是可以獨立執行的,並且可以根據配置引入其他需要依賴的元件。這背後的工作都由com.dd.comgradle外掛來默默完成。
###2 元件互動 在這裡元件的互動專指元件之間的資料傳輸,在我們的方案中使用的是介面+實現的方式,元件之間完全面向介面程式設計。在demo中我們讓reader提供一個fragment給app使用來說明。首先reader元件在componentservice中定義自己的服務
public interface ReadBookService {
Fragment getReadBookFragment();
}
複製程式碼
然後在自己的元件工程中,提供具體的實現類ReadBookServiceImpl:
public class ReadBookServiceImpl implements ReadBookService {
@Override
public Fragment getReadBookFragment() {
return new ReaderFragment();
}
}
複製程式碼
提供了具體的實現類之後,需要在元件載入的時候把實現類註冊到Router中,具體的程式碼在ReaderAppLike中,ReaderAppLike相當於元件的application類,這裡定義了onCreate和onStop兩個生命週期方法,對應元件的載入和解除安裝。
public class ReaderAppLike implements IApplicationLike {
Router router = Router.getInstance();
@Override
public void onCreate() {
router.addService(ReadBookService.class.getSimpleName(), new ReadBookServiceImpl());
}
@Override
public void onStop() {
router.removeService(ReadBookService.class.getSimpleName());
}
}
複製程式碼
在app中如何使用如reader元件提供的ReaderFragment呢?注意此處app是看不到元件的任何實現類的,它只能看到componentservice中定義的ReadBookService,所以只能面向ReadBookService來程式設計。具體的例項程式碼如下:
Router router = Router.getInstance();
if (router.getService(ReadBookService.class.getSimpleName()) != null) {
ReadBookService service = (ReadBookService) router.getService(ReadBookService.class.getSimpleName());
fragment = service.getReadBookFragment();
ft = getSupportFragmentManager().beginTransaction();
ft.add(R.id.tab_content, fragment).commitAllowingStateLoss();
}
複製程式碼
這裡需要注意的是由於元件是可以動態載入和解除安裝的,因此在使用ReadBookService的需要進行判空處理。我們看到資料的傳輸是通過一箇中央路由Router來實現的,這個Router的實現其實很簡單,其本質就是一個HashMap,具體程式碼大家參見原始碼。
通過上面幾個步驟就可以輕鬆實現元件之間的互動,由於是面向介面,所以元件之間是完全解耦的。至於如何讓元件之間在編譯階段不不可見,是通過上文所說的com.dd.comgradle實現的,這個在第一篇文章中已經講到,後面會貼出具體的程式碼。 ###3 UI跳轉 頁面(activity)的跳轉也是通過一箇中央路由UIRouter來實現,不同的是這裡增加了一個優先順序的概念。具體的實現就不在這裡贅述了,程式碼還是很清晰的。
頁面的跳轉通過短鏈的方式,例如我們要跳轉到share頁面,只需要呼叫
UIRouter.getInstance().openUri(getActivity(), "componentdemo://share", null);
複製程式碼
具體是哪個元件響應componentdemo://share這個短鏈呢?這就要看是哪個元件處理了這個schme和host,在demo中share元件在自己實現的ShareUIRouter中宣告瞭自己處理這個短鏈,具體程式碼如下:
private static final String SCHME = "componentdemo";
private static final String SHAREHOST = "share";
public boolean openUri(Context context, Uri uri, Bundle bundle) {
if (uri == null || context == null) {
return true;
}
String host = uri.getHost();
if (SHAREHOST.equals(host)) {
Intent intent = new Intent(context, ShareActivity.class);
intent.putExtras(bundle == null ? new Bundle() : bundle);
context.startActivity(intent);
return true;
}
return false;
}
複製程式碼
在這裡如果已經元件已經響應了這個短鏈,就返回true,這樣更低優先順序的元件就不會接收到這個短鏈。
目前根據schme和host跳轉的邏輯是開發人員自己編寫的,這塊後面要修改成根據註解生成。這部分已經有一些優秀的開源專案可以參考,如ARouter等。 ###4 整合除錯 整合除錯可以認為由app或者其他元件充當host的角色,引入其他相關的元件一起參與編譯,從而測試整個互動流程。在demo中app和reader都可以充當host的角色。在這裡我們以app為例。
首先我們需要在根專案的gradle.properties中增加一個變數mainmodulename,其值就是工程中的主專案,這裡是app。設定為mainmodulename的module,其isRunAlone永遠是true。
然後在app專案的gradle.properties檔案中增加兩個變數:
debugComponent=readercomponent,com.mrzhang.share:sharecomponent
compileComponent=readercomponent,sharecomponent
複製程式碼
其中debugComponent是執行debug的時候引入的元件,compileComponent是release模式下引入的元件。我們可以看到debugComponent引入的兩個元件寫法是不同的,這是因為元件引入支援兩種語法,module或者modulePackage:module,前者直接引用module工程,後者使用componentrelease中已經發布的aar。
注意在整合除錯中,要引入的reader和share元件是不需要把自己的isRunAlone修改為false的。我們知道一個application工程是不能直接引用(compile)另一個application工程的,所以如果app和元件都是isRunAlone=true的話在正常情況下是編譯不過的。祕密就在於com.dd.comgradle會自動識別當前要除錯的具體是哪個元件,然後把其他元件默默的修改為library工程,這個修改只在當次編譯生效。
如何判斷當前要執行的是app還是哪個元件呢?這個是通過task來判斷的,判斷的規則如下:
- assembleRelease → app
- app:assembleRelease或者 :app:assembleRelease → app
- sharecomponent:assembleRelease 或者:sharecomponent:assembleRelease→ sharecomponent
上面的內容要實現的目的就是每個元件可以直接在Androidstudio中run,也可以使用命令進行打包,這期間不需要修改任何配置,卻可以自動引入依賴的元件。這在開發中可以極大加快工作效率。 ###5 程式碼邊界 至於依賴的元件是如何整合到host中的,其本質還是直接使用compile project(...)或者compile modulePackage:module@aar。那麼為啥不直接在build.gradle中直接引入呢,而要經過com.dd.comgradle這個外掛來進行諸多複雜的操作?原因在第一篇文章中也講到了,那就是元件之間的完全隔離,也可以稱之為程式碼邊界。如果我們直接compile元件,那麼元件的所有實現類就完全暴露出來了,使用方就可以直接引入實現類來程式設計,從而繞過了面向介面程式設計的約束。這樣就完全失去了解耦的效果了,可謂前功盡棄。
那麼如何解決這個問題呢?我們的解決方式還是從分析task入手,只有在assemble任務的時候才進行compile引入。這樣在程式碼的開發期間,元件是完全不可見的,因此就杜絕了犯錯誤的機會。具體的程式碼如下:
/**
* 自動新增依賴,只在執行assemble任務的才會新增依賴,因此在開發期間元件之間是完全感知不到的,這是做到完全隔離的關鍵
* 支援兩種語法:module或者modulePackage:module,前者之間引用module工程,後者使用componentrelease中已經發布的aar
* @param assembleTask
* @param project
*/
private void compileComponents(AssembleTask assembleTask, Project project) {
String components;
if (assembleTask.isDebug) {
components = (String) project.properties.get("debugComponent")
} else {
components = (String) project.properties.get("compileComponent")
}
if (components == null || components.length() == 0) {
return;
}
String[] compileComponents = components.split(",")
if (compileComponents == null || compileComponents.length == 0) {
return;
}
for (String str : compileComponents) {
if (str.contains(":")) {
File file = project.file("../componentrelease/" + str.split(":")[1] + "-release.aar")
if (file.exists()) {
project.dependencies.add("compile", str + "-release@aar")
} else {
throw new RuntimeException(str + " not found ! maybe you should generate a new one ")
}
} else {
project.dependencies.add("compile", project.project(':' + str))
}
}
}
複製程式碼
###6 生命週期 在上一篇文章中我們就講過,元件化和外掛化的唯一區別是元件化不能動態的新增和修改元件,但是對於已經參與編譯的元件是可以動態的載入和解除安裝的,甚至是降維的。
首先我們看元件的載入,使用章節5中的整合除錯,可以在打包的時候把依賴的元件參與編譯,此時你反編譯apk的程式碼會看到各個元件的程式碼和資源都已經包含在包裡面。但是由於每個元件的唯一入口ApplicationLike還沒有執行oncreate()方法,所以元件並沒有把自己的服務註冊到中央路由,因此元件實際上是不可達的。
在什麼時機載入元件以及如何載入元件?目前com.dd.comgradle提供了兩種方式,位元組碼插入和反射呼叫。
- 位元組碼插入模式是在dex生成之前,掃描所有的ApplicationLike類(其有一個共同的父類),然後通過javassisit在主專案的Application.onCreate()中插入呼叫ApplicationLike.onCreate()的程式碼。這樣就相當於每個元件在application啟動的時候就載入起來了。
- 反射呼叫的方式是手動在Application.onCreate()中或者在其他合適的時機手動通過反射的方式來呼叫ApplicationLike.onCreate()。之所以提供這種方式原因有兩個:對程式碼進行掃描和插入會增加編譯的時間,特別在debug的時候會影響效率,並且這種模式對Instant Run支援不好;另一個原因是可以更靈活的控制載入或者解除安裝時機。
這兩種模式的配置是通過配置com.dd.comgradle的Extension來實現的,下面是位元組碼插入的模式下的配置格式,新增applicatonName的目的是加快定位Application的速度。
combuild {
applicatonName = 'com.mrzhang.component.application.AppApplication'
isRegisterCompoAuto = true
}
複製程式碼
demo中也給出了通過反射來載入和解除安裝元件的例項,在APP的首頁有兩個按鈕,一個是載入分享元件,另一個是解除安裝分享元件,在執行時可以任意的點選按鈕從而載入或解除安裝元件,具體效果大家可以執行demo檢視。
二、元件化拆分的感悟
在最近兩個月的元件化拆分中,終於體會到了做到剝絲抽繭是多麼艱難的事情。確定一個方案固然重要,更重要的是克服重重困難堅定的實施下去。在拆分中,元件化方案也不斷的微調,到現在終於可以欣慰的說,這個方案是經歷過考驗的,第一它學習成本比較低,組內同事可以快速的入手,第二它效果明顯,得到本來run一次需要8到10分鐘時間(不過後面換了頂配mac,速度提升了很多),現在單個元件可以做到1分鐘左右。最主要的是程式碼結構清晰了很多,這位後期的並行開發和外掛化奠定了堅實的基礎。
總之,如果你面前也是一個龐大的工程,建議你使用該方案,以最小的代價儘快開始實施元件化。如果你現在負責的是一個開發初期的專案,程式碼量還不大,那麼也建議儘快進行元件化的規劃,不要給未來的自己增加徒勞的工作量。