本文提出的元件化方案已經開源,參見Android徹底元件化方案開源,具體原始碼分析請參見文章參見Android徹底元件化demo釋出
(本文是上一篇文章“Android徹底元件化demo釋出”的原理解釋篇,更偏向於理論,可以更全面的理解元件化)
一、模組化、元件化與外掛化
專案發展到一定程度,隨著人員的增多,程式碼越來越臃腫,這時候就必須進行模組化的拆分。在我看來,模組化是一種指導理念,其核心思想就是分而治之、降低耦合。而在Android工程中如何實施,目前有兩種途徑,也是兩大流派,一個是元件化,一個是外掛化。 提起元件化和外掛化的區別,有一個很形象的圖:
上面的圖看上去比較清晰,其實容易導致一些誤解,有下面幾個小問題,圖中可能說的不太清楚:- 元件化是一個整體嗎?去了頭和胳膊還能存在嗎?左圖中,似乎元件化是一個有機的整體,需要所有器官都健在才可以存在。而實際上元件化的目標之一就是降低整體(app)與器官(元件)的依賴關係,缺少任何一個器官app都是可以存在並正常執行的。
- 頭和胳膊可以單獨存在嗎?左圖也沒有說明白,其實答案應該是肯定的。每個器官(元件)可以在補足一些基本功能之後都是可以獨立存活的。這個是元件化的第二個目標:元件可以單獨執行。
- 元件化和外掛化可以都用右圖來表示嗎?如果上面兩個問題的答案都是YES的話,這個問題的答案自然也是YES。每個元件都可以看成一個單獨的整體,可以按需的和其他元件(包括主專案)整合在一起,從而完成的形成一個app
- 右圖中的小機器人可以動態的新增和修改嗎?如果元件化和外掛化都用右圖來表示,那麼這個問題的答案就不一樣了。對於元件化來講,這個問題的答案是部分可以,也就是在編譯期可以動態的新增和修改,但是在執行時就沒法這麼做了。而對於外掛化,這個問題的答案很乾脆,那就是完全可以,不論實在編譯期還是執行時! 本文主要集中講的是元件化的實現思路,對於外掛化的技術細節不做討論,我們只是從上面的問答中總結出一個結論:元件化和外掛化的最大區別(應該也是唯一區別)就是元件化在執行時不具備動態新增和修改元件的功能,但是外掛化是可以的。 暫且拋棄對外掛化“道德”上的批判,我認為對於一個Android開發者來講,外掛化的確是一個福音,這將使我們具備極大的靈活性。但是苦於目前還沒有一個完全合適、完美相容的外掛化方案(RePlugin的飢餓營銷做的很好,但還沒看到療效),特別是對於已經有幾十萬程式碼量的一個成熟產品來講,套用任何一個外掛化方案都是很危險的工作。所以我們決定先從元件化做起,本著做一個最徹底的元件化方案的思路去進行程式碼的重構,下面是最近的思考結果,歡迎大家提出建議和意見。
二、如何實現元件化
要實現元件化,不論採用什麼樣的技術路徑,需要考慮的問題主要包括下面幾個:
- 程式碼解耦。如何將一個龐大的工程拆分成有機的整體?
- 元件單獨執行。上面也講到了,每個元件都是一個完整的整體,如何讓其單獨執行和除錯呢?
- 資料傳遞。因為每個元件都會給其他元件提供的服務,那麼主專案(Host)與元件、元件與元件之間如何傳遞資料?
- UI跳轉。UI跳轉可以認為是一種特殊的資料傳遞,在實現思路上有啥不同?
- 元件的生命週期。我們的目標是可以做到對元件可以按需、動態的使用,因此就會涉及到元件載入、解除安裝和降維的生命週期。
- 整合除錯。在開發階段如何做到按需的編譯元件?一次除錯中可能只有一兩個元件參與整合,這樣編譯的時間就會大大降低,提高開發效率。
- 程式碼隔離。元件之間的互動如果還是直接引用的話,那麼元件之間根本沒有做到解耦,如何從根本上避免元件之間的直接引用呢?也就是如何從根本上杜絕耦合的產生呢?只有做到這一點才是徹底的元件化。 ###2-1 程式碼解耦 把龐大的程式碼進行拆分,Androidstudio能夠提供很好的支援,使用IDE中的multiple module這個功能,我們很容易把程式碼進行初步的拆分。在這裡我們對兩種module進行區分,
- 一種是基礎庫library,這些程式碼被其他元件直接引用。比如網路庫module可以認為是一個library。
- 另一種我們稱之為Component,這種module是一個完整的功能模組。比如讀書或者分享module就是一個Component。 為了方便,我們統一把library稱之為依賴庫,而把Component稱之為元件,我們所講的元件化也主要是針對Component這種型別。而負責拼裝這些元件以形成一個完成app的module,一般我們稱之為主專案、主module或者Host,方便起見我們也統一稱為主專案。 經過簡單的思考,我們可能就可以把程式碼拆分成下面的結構: 這種拆分都是比較容易做到的,從圖上看,讀書、分享等都已經拆分元件,並共同依賴於公共的依賴庫(簡單起見只畫了一個),然後這些元件都被主專案所引用。讀書、分享等元件之間沒有直接的聯絡,我們可以認為已經做到了元件之間的解耦。但是這個圖有幾個問題需要指出: ● 從上面的圖中,我們似乎可以認為元件只有整合到主專案才可以使用,而實際上我們的希望是每個元件是個整體,可以獨立執行和除錯,那麼如何做到單獨的除錯呢? ● 主專案可以直接引用元件嗎?也就是說我們可以直接使用compile project(:reader)這種方式來引用元件嗎?如果是這樣的話,那麼主專案和元件之間的耦合就沒有消除啊。我們上面講,元件是可以動態管理的,如果我們刪掉reader(讀書)這個元件,那麼主專案就不能編譯了啊,談何動態管理呢?所以主專案對元件的直接引用是不可以的,但是我們的讀書元件最終是要打到apk裡面,不僅程式碼要和併到claases.dex裡面,資源也要經過meage操作合併到apk的資源裡面,怎麼避免這個矛盾呢? ● 元件與元件之間真的沒有相互引用或者互動嗎?讀書元件也會呼叫分享模組啊,而這在圖中根本沒有體現出來啊,那麼元件與元件之間怎麼互動呢? 這些問題我們後面一個個來解決,首先我們先看程式碼解耦要做到什麼效果,像上面的直接引用並使用其中的類肯定是不行的了。所以我們認為程式碼解耦的首要目標就是元件之間的完全隔離,我們不僅不能直接使用其他元件中的類,最好能根本不瞭解其中的實現細節。只有這種程度的解耦才是我們需要的。 ###2-2 元件的單獨除錯 其實單獨除錯比較簡單,只需要把apply plugin: 'com.android.library'切換成apply plugin: 'com.android.application'就可以,但是我們還需要修改一下AndroidManifest檔案,因為一個單獨除錯需要有一個入口的actiivity。 我們可以設定一個變數isRunAlone,標記當前是否需要單獨除錯,根據isRunAlone的取值,使用不同的gradle外掛和AndroidManifest檔案,甚至可以新增Application等Java檔案,以便可以做一下初始化的操作。 為了避免不同元件之間資源名重複,在每個元件的build.gradle中增加resourcePrefix "xxx_",從而固定每個元件的資源字首。下面是讀書元件的build.gradle的示例:
if(isRunAlone.toBoolean()){
apply plugin: 'com.android.application'
}else{
apply plugin: 'com.android.library'
}
.....
resourcePrefix "readerbook_"
sourceSets {
main {
if (isRunAlone.toBoolean()) {
manifest.srcFile 'src/main/runalone/AndroidManifest.xml'
java.srcDirs = ['src/main/java','src/main/runalone/java']
res.srcDirs = ['src/main/res','src/main/runalone/res']
} else {
manifest.srcFile 'src/main/AndroidManifest.xml'
}
}
}
複製程式碼
通過這些額外的程式碼,我們給元件搭建了一個測試Host,從而讓元件的程式碼執行在其中,所以我們可以再優化一下我們上面的框架圖。
###2-3 元件的資料傳輸 上面我們講到,主專案和元件、元件與元件之間不能直接使用類的相互引用來進行資料互動。那麼如何做到這個隔離呢?在這裡我們採用介面+實現的結構。每個元件宣告自己提供的服務Service,這些Service都是一些抽象類或者介面,元件負責將這些Service實現並註冊到一個統一的路由Router中去。如果要使用某個元件的功能,只需要向Router請求這個Service的實現,具體的實現細節我們全然不關心,只要能返回我們需要的結果就可以了。這與Binder的C/S架構很相像。 因為我們元件之間的資料傳遞都是基於介面程式設計的,介面和實現是完全分離的,所以元件之間就可以做到解耦,我們可以對元件進行替換、刪除等動態管理。這裡面有幾個小問題需要明確: ● 元件怎麼暴露自己提供的服務呢?在專案中我們簡單起見,專門建立了一個componentservice的依賴庫,裡面定義了每個元件向外提供的service和一些公共model。將所有元件的service整合在一起,是為了在拆分初期操作更為簡單,後面需要改為自動化的方式來生成。這個依賴庫需要嚴格遵循開閉原則,以避免出現版本相容等問題。 ● service的具體實現是由所屬元件註冊到Router中的,那麼是在什麼時間註冊的呢?這個就涉及到元件的載入等生命週期,我們在後面專門介紹。 ● 一個很容易犯的小錯誤就是通過持久化的方式來傳遞資料,例如file、sharedpreference等方式,這個是需要避免的。 下面就是加上資料傳輸功能之後的架構圖: ###2-4 元件之間的UI跳轉 可以說UI的跳轉也是元件提供的一種特殊的服務,可以歸屬到上面的資料傳遞中去。不過一般UI的跳轉我們會單獨處理,一般通過短鏈的方式來跳轉到具體的Activity。每個元件可以註冊自己所能處理的短鏈的schme和host,並定義傳輸資料的格式。然後註冊到統一的UIRouter中,UIRouter通過schme和host的匹配關係負責分發路由。 UI跳轉部分的具體實現是通過在每個Activity上新增註解,然後通過apt形成具體的邏輯程式碼。這個也是目前Android中UI路由的主流實現方式。 ###2-5 元件的生命週期 由於我們要動態的管理元件,所以給每個元件新增幾個生命週期狀態:載入、解除安裝和降維。為此我們給每個元件增加一個ApplicationLike類,裡面定義了onCreate和onStop兩個生命週期函式。- 載入:上面講了,每個元件負責將自己的服務實現註冊到Router中,其具體的實現程式碼就寫在onCreate方法中。那麼主專案呼叫這個onCreate方法就稱之為元件的載入,因為一旦onCreate方法執行完,元件就把自己的服務註冊到Router裡面去了,其他元件就可以直接使用這個服務了。
- 解除安裝:解除安裝與載入基本一致,所不同的就是呼叫ApplicationLike的onStop方法,在這個方法中每個元件將自己的服務實現從Router中取消註冊。不過這種使用場景可能比較少,一般適用於一些只用一次的元件。
- 降維:降維使用的場景更為少見,比如一個元件出現了問題,我們想把這個元件從本地實現改為一個wap頁。降維一般需要後臺配置才生效,可以在onCreate對線上配置進行檢查,如果需要降維,則把所有的UI跳轉到配置的wap頁上面去。 一個小的細節是,主專案負責載入元件,由於主專案和元件之間是隔離的,那麼主專案如何呼叫元件ApplicationLike的生命週期方法呢,目前我們採用的是基於編譯期位元組碼插入的方式,掃描所有的ApplicationLike類(其有一個共同的父類),然後通過javassisit在主專案的onCreate中插入呼叫ApplicationLike.onCreate的程式碼。 我們再優化一下元件化的架構圖: ###2-6 整合除錯 每個元件單獨除錯通過並不意味著整合在一起沒有問題,因此在開發後期我們需要把幾個元件機整合到一個app裡面去驗證。由於我們上面的機制保證了元件之間的隔離,所以我們可以任意選擇幾個元件參與整合。這種按需索取的載入機制可以保證在整合除錯中有很大的靈活性,並且可以加大的加快編譯速度。 我們的做法是這樣的,每個元件開發完成之後,釋出一個relaese的aar到一個公共倉庫,一般是本地的maven庫。然後主專案通過引數配置要整合的元件就可以了。所以我們再稍微改動一下元件與主專案之間的連線線,形成的最終元件化架構圖如下: ###2-7 程式碼隔離 此時在回顧我們在剛開始拆分元件化是提出的三個問題,應該說都找到了解決方式,但是還有一個隱患沒有解決,那就是我們可以使用compile project(xxx:reader.aar)來引入元件嗎?雖然我們在資料傳輸章節使用了介面+實現的架構,元件之間必須針對介面程式設計,但是一旦我們引入了reader.aar,那我們就完全可以直接使用到其中的實現類啊,這樣我們針對介面程式設計的規範就成了一紙空文。千里之堤毀於蟻穴,只要有程式碼(不論是有意還是無意)是這麼做了,我們前面的工作就白費了。 我們希望只在assembleDebug或者assembleRelease的時候把aar引入進來,而在開發階段,所有元件都是看不到的,這樣就從根本上杜絕了引用實現類的問題。我們把這個問題交給gradle來解決,我們建立一個gradle外掛,然後每個元件都apply這個外掛,外掛的配置程式碼也比較簡單:
//根據配置新增各種元件依賴,並且自動化生成元件載入程式碼
if (project.android instanceof AppExtension) {
AssembleTask assembleTask = getTaskInfo(project.gradle.startParameter.taskNames)
if (assembleTask.isAssemble
&& (assembleTask.modules.contains("all") || assembleTask.modules.contains(module))) {
//新增元件依賴
project.dependencies.add("compile","xxx:reader-release@aar")
//位元組碼插入的部分也在這裡實現
}
}
private AssembleTask getTaskInfo(List<String> taskNames) {
AssembleTask assembleTask = new AssembleTask();
for (String task : taskNames) {
if (task.toUpperCase().contains("ASSEMBLE")) {
assembleTask.isAssemble = true;
String[] strs = task.split(":")
assembleTask.modules.add(strs.length > 1 ? strs[strs.length - 2] : "all");
}
}
return assembleTask
}
複製程式碼
三、元件化的拆分步驟和動態需求
###3-1 拆分原則 元件化的拆分是個龐大的工程,特別是從幾十萬行程式碼的大工程拆分出去,所要考慮的事情千頭萬緒。為此我覺得可以分成三步:
- 從產品需求到開發階段再到運營階段都有清晰邊界的功能開始拆分,比如讀書模組、直播模組等,這些開始分批先拆分出去
- 在拆分中,造成元件依賴主專案的依賴的模組繼續拆出去,比如賬戶體系等
- 最終主專案就是一個Host,包含很小的功能模組(比如啟動圖)以及元件之間的拼接邏輯
###3-2 元件化的動態需求 最開始我們講到,理想的程式碼組織形式是外掛化的方式,屆時就具備了完備的執行時動態化。在向外掛化遷徙的過程中,我們可以通過下面的集中方式來實現編譯速度的提升和動態更新。
- 在快速編譯上,採用元件級別的增量編譯。在抽離元件之前可以使用程式碼級別的增量編譯工具如freeline(但databinding支援較差)、fastdex等
- 動態更新方面,暫時不支援新增元件等大的功能改進。可以臨時採用方法級別的熱修復或者功能級別的Tinker等工具,Tinker的接入成本較高。
四、總結
本文是筆者在設計“得到app”的元件化中總結一些想法,在設計之初參考了目前已有的元件化和外掛化方案,站在巨人的肩膀上又加了一點自己的想法,主要是元件化生命週期以及完全的程式碼隔離方面。特別是最後的程式碼隔離,不僅要有規範上的約束(針對介面程式設計),更要有機制保證開發者不犯錯,我覺得只有做到這一點才能認為是一個徹底的元件化方案。