今年6月份開始,我開始負責對“得到app”的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分鐘左右。最主要的是代碼結構清晰了很多,這位後期的並行開發和插件化奠定了堅實的基礎。
總之,如果你面前也是一個龐大的工程,建議你使用該方案,以最小的代價儘快開始實施組件化。如果你現在負責的是一個開發初期的項目,代碼量還不大,那麼也建議儘快進行組件化的規劃,不要給未來的自己增加徒勞的工作量。
【最後發個招聘廣告:】
羅輯思維“得到app”發展迅猛,我們急需各路Android和iOS大牛加入,只要你滿足下麵幾點,就是我們所需要的:
1、熱愛移動端開發(android/ios/rn),最好有2到3年的app開發經驗
2、對技術有極致追求,並能踏實的推動技術驅動,致力於實現自己更實現他人
3、如果你也喜歡知識付費類的產品,有一些自己的產品想法,那更是我們需要的人
我們的辦公地點就在長安街沿線的一座裝修精美的獨棟裡面,環境十分優美。至於待遇,絕對是業內領先的,從我們的內推獎是豪華的蘋果三件套就可見一斑。如果你感興趣,請發送簡歷到[email protected]。