前言
在Android開發中,隨著項目的不斷擴展,項目會變得越來越龐大,而隨之帶來的便是項目維護成本與開發成本的增加!每次調試時,不得不運行整個項目;每當有新成員加入團隊時,需要更多的時間去了解龐大的項目。。。而為了解決這些問題,團隊通常會將項目模塊化,以此來降低項目的復雜度和耦合度,讓團隊可以并行開發與測試,讓團隊成員更加專注于自己所負責的功能模塊開發。。。
對于一些大廠如BAT或者美團等這些大型互聯網公司,都會自己造輪子,實現項目模塊化。而對于中小型公司,限于成本因素,一般都是選用這些大廠造的優秀的輪子來進行項目模塊化。本文以及后面一系列文章,將向大家分享我在項目模塊化中的實踐經驗。我的項目模塊化方案主要借鑒于餓了么
、微信
、美團
的模塊化技術文章,如有建議,歡迎提出!
推薦
個人博客:Android:項目模塊化/組件化的架構之路(一)
文章發布將先在個人博客與微信公眾號「碼途有道」發布,歡迎大家關注!
模塊化需要做什么
首先,在開始項目模塊化之前,我們必須要明確模塊化需要做些什么?這就等于寫書之前必須得有個總綱,否則越寫到后面,越是混亂。以下是我認為在模塊化時需要注意的幾個問題:
- 如何拆分項目
- 模塊之間的通信
- 模塊內的代碼隔離
- 模塊在調試與發布模式之間的切換
在明確了項目模塊化中需要解決的問題后,我們需要選定一個優秀的組件化開源框架。在本方案中,我選擇阿里的ARouter
,也是目前比較流行的組件化框架之一,大家也可以選擇其他開源框架。ARouter
的具體使用本文就不在介紹了,大家可以在網上自行搜索,下面開始設計項目模塊化的架構。
一、如何拆分項目
如上圖所示,我將項目大概劃分為五層:
- 宿主層: 不做具體的項目功能實現,只負責集成業務模塊,組裝成一個完整的APP
- 業務模塊層: 將項目的每個大功能模塊拆分成的一個一個單獨的module
-
基礎業務組件層: 此層最大的作用是為了
復用
,例如首頁模塊與新盤模塊中都有樓盤搜索這個功能,且UI顯示相似,這時在兩個模塊中都實現樓盤搜索就顯得繁瑣了,像這種與業務有關聯且需要多處使用的情況,我們完全可以將其抽離出來作為基礎業務組件 - 功能組件層: 項目中常用的功能庫,如圖片加載、網絡請求等
- 底層SDK: 從公司項目中長期積累出來的底層類庫
以上是大多數項目模塊化時的拆分方式,每個人也可以根據項目的實際情況進行調整。
二、模塊之間的通信
1. 常用的通信方式
當項目被拆分成多個模塊后,模塊之間的良好的通信是我們必須考慮的問題。ARouter本身也提供一套通信機制,但是一般很難滿足我們所有的需求,所以我們會容易想到的常用的幾種通信方式:EvenBus、協議通信、廣播或者是將通信的部分下沉到公共組件庫。對于這幾種方式,在一些大廠的技術文章中都有提到一些他們的看法,下面我簡單總結一下:
-
EventBus: 我們非常熟悉的事件總線型的通信框架,非常靈活,采用注解方式實現,但是難以追溯事件,
微信
、餓了么
認為這是個極大的缺點,不是很推薦,但是美團
覺得只要自身控制的好就行(自己設計了一套基于LiveData的簡易事件總線通信框架)。 - 協議通信: 通信雙發必須得都知曉協議,且協議需要放在一個公共部分保存。雖然解耦能力強,但是協議一旦變化,通訊雙方的同步會變的復雜,不方便。
- 廣播: 安卓的四大組件之一,常見的通信方式,但是相對EventBus來說,過重。
- 下沉到公共組件庫: 這是在模塊化中常見的做法,不斷的將各種方法、數據模型等公共部分下成到公共組件庫,這樣一來,公共組件庫會變的越來越龐大,越來越中心化,違背了項目模塊化的初衷。最后,越來越難以維護,不得不在重新拆分公共組件庫。
2. 改善通信方式
上面說了一些常用的通信方式,可以看到大廠并不是很滿意,那么大廠都是怎么解決的呢?沒錯,大廠往往都會有重造一個符合他們要求的通信框架!然而這不是我們想要的(中小型公司的實力和成本不允許啊:cry:),不過沒關系,我們可以根據大廠們的思路,自己建造一個精簡實用的通信架構,下面一起來看!
2.1 使用接口進行通信
接口通信概念并不是什么新穎的概念,微信在技術文章中著重說明過,ARouter
框架也具備這種能力。下面我們配合一個小例子形象的說明一下。
eg:在一個房產項目中,有推薦與新盤兩個模塊,這兩個模塊使用相同的接口(傳入參數不同)獲取樓盤數據。
針對這種狀況,一般我們下沉數據接口至公共組件庫,然后推薦和新盤都去調用公共組件庫中的樓盤接口獲取數據。而上面也說過了,這種情況的后果就是,當有數十上百個接口被下沉到公共組件庫后,公共組件庫已然中心化且變得很龐大,這不是我們想要的。我們需要的是去中心化,開發成員只需要專注于自己負責的模塊本身,減少模塊之間的耦合。而接口化的通信方式給我們提供了這種可能。
什么是接口化通信?我們可以理解為將一個模塊當作SDK,提供接口+數據結構給其他需要的模塊調用,從而實現通信
。我們完全可以將獲取樓盤數據的具體實現放在新盤模塊中,然后單獨提供獲取樓盤數據的接口和樓盤的數據結構給推薦模塊,推薦模塊通過調用提供的接口就可以直接獲取需要的樓盤數據,不需要關注獲取樓盤數據的具體實現。 這樣,即使獲取樓盤數據的具體實現發生變動,推薦模塊也不需要做任何更改,且維護也很簡單。
對于這種接口化的通信實現,ARouter
的IProvider
就已經實現,完全可以滿足我們的要求。具體如何使用IProvider
,這里就不在詳細介紹,大家可以網上自行搜索。
2.2 如何對外暴露接口
解決了通信手段的問題,我們就得考慮另一個問題,為其他模塊提供的接口+數據結構
我們應該放在哪里?下沉到公共模塊嗎?或者另外新建一個module用來維護這些接口+數據結構?但是這樣一來,成本就有些大了,也不方便。
在微信的模塊化文章中提出了一個解決方法,將你要暴露的接口+數據結構
甚至其他想要暴露的文件都.api
化,即將你要暴露的文件的后綴名改為api
,然后通過特定的方法將api
后綴的文件拷貝出來,在module外部重新組成一個新的module(也可稱為SDK),而想要使用的模塊只需要調用這個SDK即可。當然,拷貝文件和組件SDK是完全自動化的,并非手工,這樣才能節省成本。
由于微信的模塊化文章中沒有涉及到.api
化的具體實現,所以根據這種思路,我使用了其他方法來實現要達到的效果。具體思路如下:
- 創建一個名為
_exports
的文件夾,需要對外暴露的文件都放在里面
- 將
_exports
文件夾打包成jar
/**
* 創建 jar 包
*/
task makeExportJar(type: Jar) {
baseName = "hpauth-exports"
version = "1.0.0"
extension = "jar"
// java文件編譯后的所在位置
from('build/intermediates/classes/debug/')
// kotlin文件編譯后的所在位置
from('build/tmp/kotlin-classes/debug/')
include('com/homeprint/module/auth/_exports/**')
// jar包導出路徑
destinationDir = file('build/_exports')
}
- 將jar發布到本地maven倉庫(發布到本地僅僅針對個人開發的時候;團隊開發時,大家使用各自的電腦,無法訪問到你本地的maven倉庫,所以這時需要在局域網中建立一個maven倉庫,詳情請查看《Android:超詳細的本地搭建maven私服以及使用Nexus3.x搭建maven私服的講解》)
artifacts {
archives makeExportJar
}
uploadArchives {
repositories {
mavenDeployer {
// 本地的maven倉庫路徑
repository(url: uri("../repo"))
pom.project {
groupId 'com.homeprint.module'
artifactId 'auth-exports'
version '1.0.0'
}
}
}
}
- 在需要的模塊調用jar
compileOnly 'com.homeprint.module:auth-exports:1.0.0@jar'
注: 此處必須使用
compileOnly
來調用,compileOnly
是provided
的替代方法,provided
將被google廢棄。此處使用compileOnly
代表,jar包只在編譯時有效,不參與打包。如果使用api
或者implementation
,因為我們只是將文件拷貝出來成為一個單獨的SDK,并未修改包名和文件名,當將多個模塊集成為一個app時,會拋出異常,提示jar包中的文件已存在。
2.3 使用EventBus時,自建簡單的事件索引
以上的通信手段能滿足大多數的場景,但是也會遇到滿足不了的情況,比如在一個模塊中的線程執行任務,執行完畢后通知另一個模塊做出改變,這種模塊處于被動接收信息的模式下,一般我們使用的手段就是廣播或者EventBus,而相比廣播,EventBus更靈巧。
雖然微信不是很推薦,但是美團卻是比較看重此種EventBus這種類型的通信手段。就個人而言,我認為只要能合理控制EventBus,這也是一種比較推薦的通信手段。而EventBus目前最被詬病的一點就是無法追溯事件,所以為了能更好的控制EvenBus,我們可以自建一個事件索引。
- 首先,我們可以創建一個
Event<T>
類,作為傳遞數據的統一實體類。
public class Event<T> {
private Object index;
private T data;
public Event() {
}
public Event(Object index, T data) {
this.index = index;
this.data = data;
}
public Object getIndex() {
return index;
}
public void setIndex(Object index) {
this.index = index;
}
public T getData() {
return data;
}
public void setData(T data) {
this.data = data;
}
}
- 在我們每次傳遞
Event<T>
時,必須為事件添加index
索引標識 - 而我們可以在公共模塊類中創建一個索引類,用于記錄所有的索引標識,這樣我們就可以根據這些標識,簡單的做到事件追溯
class EventIndex {
companion object {
// 事件從修改昵稱頁面發送至用戶信息頁面
const val EVENT_FROM_MODIFYNAME_TO_USERINFO = "From_ModifyName_To_UserInfo"
// 事件從修改郵箱頁面發送至用戶信息頁面
const val EVENT_FROM_MODIFYEMAIL_TO_USERINFO = "From_ModifyEmail_To_UserInfo"
}
}
還有值得注意的一點,在公共模塊中,僅存放模塊之間的通信索引標識;模塊內使用EventBus通信,建立的索引放在對應模塊內,給公共模塊EventIndex文件降級,如下圖示例:
三、模塊內的代碼隔離
雖然在模塊化時,我們已經將業務拆分成了一個一個單獨的module,但是有的業務實在是龐大,即使單獨拆分成一個module,仍然有些臃腫。但是如果將業務module繼續拆分成單獨的子module,又會顯得過重。此時我們就要開始尋求比module更小一級的粒度,這一點《微信Android模塊化架構重構實踐》中給我們提出了Pins工程
的思路。
如上圖,我們可以在module中構建粒度更小的pins工程來拆分業務,可以更加清晰直觀的明確業務內的職責分工,示例代碼如下:
sourceSets {
def dirs = ['p_imageview','p_progressbar']
main {
dirs.each { dir ->
java.srcDir("src/${dir}/main/java")
res.srcDir("src/${dir}/main/res")
}
}
}
上述代碼做到的是僅僅是分割代碼,pins工程之間還可以相互調用。而微信提出的是工程之間沒有依賴關系存在,則不可以相互調用,類似于兩個module之間,如果沒有在gradle中注明依賴,則不可相互調用。所以,有些人就會提出,既然如此,為何不直接拆將Pins工程
完全拆分成module?
論壇中開發者們給出的最多的答案是提升編譯速度!不過快手的一位資深Android工程師說,微信內部已經放棄了Pins工程
,雖然不知道是真是假,不過美團在借鑒微信的Pins工程
時,也沒有提出代碼的完全隔離。就個人而言,我認為Pins工程
是module化的一個過渡階段,當Pins工程
的體量達到一定程度時,必須進行完全的module化,所以也沒有必要做到Pins工程
之間的代碼完全隔離,做到以上即可。如果有想實現代碼完全隔離的,可以參考《Android Pins 工程結構》
四、組件的生命周期管理
在組件化開發時,每個組件都應該有自己獨立的生命周期,這個生命周期類似于組件自己的Application,在這個生命周期中,組件可以做一些類庫的初始化等工作,否則如果每個組件都將這些工作集中到殼工程的Applicaiton中實現的話,會顯得殼工程的Application太過中心化,并且一旦需要修改,會很麻煩,且容易產生沖突。
基于上述原因,我們可以自己搭建一個簡易的組件生命周期管理器,主要分為兩步:
- 構建組件的生命周期模型,構建的模型持有整個app的Application引用,同時提供三個基礎方法:生命周期創建、生命周期停止以及生命周期的優先級設置。
public abstract class BizLifeCycle {
private Application app;
// 優先級,priority 越大,優先級越高
@IntRange(from = 0)
private int priority = 0;
public BizLifeCycle(@NonNull Application application) {
this.app = application;
}
public Application getApp() {
return app;
}
/**
* 獲取優先級,priority 越大,優先級越高
*/
public int getPriority() {
return priority;
}
public void setPriority(int priority) {
this.priority = priority;
}
public abstract void onCreate();
public abstract void onTerminate();
}
在每個組件中只要實現上述模型即可。
- 有了生命周期模型,我們還需要一個管理器,用于管理這些組件的生命周期模型,在這個管理器中,我們同樣需要實現三個基礎方法:生命周期模型的注冊,生命周期模型的反注冊以及執行已存在的生命周期模型。
public class BizLifeCycleManager {
private static ArrayList<BizLifeCycle> sPinLifeCycleList;
/**
* 注冊組件的生命周期
*/
public static <T extends BizLifeCycle> void register(@NonNull T lifeCycle) {
if (sPinLifeCycleList == null) {
sPinLifeCycleList = new ArrayList();
}
if (!sPinLifeCycleList.contains(lifeCycle)) {
sPinLifeCycleList.add(lifeCycle);
}
}
/**
* 執行組件生命周期
*/
public static void execute() {
if (sPinLifeCycleList != null && !sPinLifeCycleList.isEmpty()) {
// 冒泡算法排序,按優先級從高到低重新排列組件生命周期
BizLifeCycle temp = null;
for (int i = 0, len = sPinLifeCycleList.size() - 1; i < len; i++) {
for (int j = 0; j < len - i; j++) {
if (sPinLifeCycleList.get(j).getPriority() < sPinLifeCycleList.get(j + 1).getPriority()) {
temp = sPinLifeCycleList.get(j);
sPinLifeCycleList.set(j, temp);
sPinLifeCycleList.set(j + 1, temp);
}
}
}
for (BizLifeCycle lifeCycle : sPinLifeCycleList) {
lifeCycle.onCreate();
}
}
}
/**
* 解除組件生命周期
*/
public static <T extends BizLifeCycle> void unregister(@NonNull T lifeCycle) {
if (sPinLifeCycleList != null) {
if (sPinLifeCycleList.contains(lifeCycle)) {
lifeCycle.onTerminate();
sPinLifeCycleList.remove(lifeCycle);
}
}
}
/**
* 清除所有的組件生命周期
*/
public static void clear() {
if (sPinLifeCycleList != null) {
if (!sPinLifeCycleList.isEmpty()) {
for (BizLifeCycle lifeCycle : sPinLifeCycleList) {
lifeCycle.onTerminate();
}
}
sPinLifeCycleList.clear();
}
}
}
調用示例如下,直接在殼工程的Application中使用,因為生命周期模型中都設置有優先級,所以在設置了優先級的情況下,可以不必在意register
的順序。如此一來,將生命周期注冊后,需要更改每個組件件的一些初始化工作,可以直接在組件的生命周期中修改,而不需要變更殼工程的Application。
override fun onCreate() {
super.onCreate()
BizLifeCycleManager.register(CommonBizLifeCycle(this))
BizLifeCycleManager.register(HttpBizLifeCycle(this))
BizLifeCycleManager.register(RouterBizLifeCycle(this))
BizLifeCycleManager.register(ThirdBizLifeCycle(this))
BizLifeCycleManager.execute()
}
五、模塊在調試與發布模式之間的切換
項目開發時,一般提供調試環境與正式發布環境。在不同的環境中,app的有些功能是不需要用到的,或者是有所不同的。另外,在模塊化開發時,有些業務模塊在調試時,可以作為單獨的app運行調試,不必每次都編譯所有的模塊,極大的加快編譯速度,節省時間成本。基于,以上種種原因,我們就必須對項目的調試與正式環境做不同的部署配置,然而如果全靠每次手動修改,當模塊量達到數十時,則會非常麻煩,且容易出錯。所以我們需要盡可能的用代碼做好配置。
首先,來看如何配置module在app與library之間的切換,實現module在調試時作為app單獨運行調試。看示例代碼,這也是比較常見的方式:
- 在
gradle.properties
中,配置字段isAuthModule
,控制auth
模塊是作為一個模塊還是一個app
# true: 作為一個模塊
# false: 作為一個app
isAuthModule=true
- 在
auth
模塊的build.gradle
文件中作如下配置:
// 獲取 gradle.properties 文件中的配置字段值
def isApp = !isAuthModule.toBoolean()
// 判斷是否為 app,選擇加載不同的插件
if (isApp) {
apply plugin: 'com.android.application'
} else {
apply plugin: 'com.android.library'
}
......
android {
defaultConfig {
// library 是沒有 applicationId 的,只有為 app 時,才有
if (isApp) {
applicationId "com.homeprint.module.auth"
}
......
}
sourceSets {
main {
// 作為app與模塊時的AndroidManifest.xml會有所不同,在不同狀態時選擇不同的AndroidManifest.xml
if (isApp) {
manifest.srcFile 'src/main/AndroidManifest.xml'
} else {
// 記得在 main 文件夾下創建 module 文件夾,添加AndroidManifest.xml
manifest.srcFile 'src/main/module/AndroidManifest.xml'
}
}
}
這樣只要我們更改一下,gradle.properties
文件中的配置字段,就可以自由實現module在模塊與app之間的切換。下面我們再來看下,如何實現app在調試與發布環境時,加載不同的模塊,看以下示例:
假如有兩個模塊,lib_debug 與 lib_release,lib_debug 是只有在調試環境才需要使用,
lib_release 是只有在正式環境才需要使用,以下提供兩種方式實現
方式一:
if(mode_debug){
implementation project(':lib_debug')
}else{
implementation project(':lib_release')
}
方式二:
debugImplementation project(':lib_debug')
releaseImplementation project(':lib_release')
以上兩種方式,方式一類似于上述的模塊在 app 與 library 之間的切換,方式二是使用 gradle 提供的方法實現
參考文章
《Android:項目模塊化/組件化的架構之路(二)》
《Android:超詳細的本地搭建maven私服以及使用Nexus3.x搭建maven私服的講解》
《美團外賣Android平臺化架構演進實踐》
《微信Android模塊化架構重構實踐》
《Android工程模塊化平臺的設計》
《Android工程模塊化平臺的設計(整理優化)》
《Android Pins 工程結構》
《pins工程及自動生成文件夾》