組件化的實現,主要解決的就是模塊的劃分,以及劃分後的交互問題。
另外在組件化的過程中,也是一個,代碼Review的過程,比如是否使用了通用的父類,以及對業務邏輯是否進行了友好的封裝,總之,組件化可以說一面照妖鏡,讓之前代碼存在的耦合問題充分暴露出來。
這次使用的組件化樣例是一個即時通訊軟件,以下是組件化之前的樣子,其中SuperHelper是底層通用幫助類可以看成CommonBase,封裝了一些如數據庫和網絡訪問類型的第三方庫,方便程序快速開發。上層是業務實現層,業務實現層包含了程序的所有模塊,由於SuperHelper是通用模塊,所以SuperHelper中並不存在即時通訊的SDK.由於程序簡單,自然SDK就放到上層了。
我們可以通過程序的包名看到裏面實現了一個即時通訊軟件幾乎都有的模塊:
登錄 呼叫 聯繫人 歷史記錄 短信
其中登錄模塊是其他模塊可以正常運行的基礎,只有登錄成功後,且用戶在線,才能正常的進行呼叫和短信類的業務,同時登錄模塊也是需要和所有其他模塊產生交互的模塊,這主要體現在,登錄狀態和登錄信息相當於是一個常量,在各個模塊中都有可能被調用。
登錄之後,另外一個和其他模塊有較多交互的是通訊錄模塊,因爲我們進行呼叫業務和短信業務是需要通訊錄進行支持的,而且生成歷史也是需要通訊錄裏面的對象。
以上的分析,就是大致的我們在實現組件化過程中需要考慮的交互接口設計。當然這只是一個大概的設計。
當前的程序模塊圖是這樣的:
項目比較簡單,也做了一定的模塊化和內聚,也包括在思想上的代碼隔離。這裏面對於登錄,其他模塊都需要他的狀態和緩存進行自己的業務,而主要的功能模塊,在這個項目中是一個整體,基本上這幾個模塊和其他模塊或多或少的都有關係,把他們放到一起可以很方便的調用彼此的方法和數據對象,這樣的調用就會發生在一個模塊包下,對其他包的類有引用。這就產生了模塊間的耦合,在項目不斷髮展下,就會發生耦合越來越嚴重,理清思維越來越困難的問題。
此時就是引入組件化來解決這個問題的時候了。
組件化第一步:劃分組件與命名
1.組件的劃分
對於這一步我認爲是一個較爲簡單的過程。快速劃分,一般是根據效果圖首頁的葉籤進行劃分,這是一個相對想說較爲粗糙的劃分方法,不過,大多數時候他可以快速的確定一些功能模塊。當然這是一個比較偷懶的方法,其實較爲好的方法是畫出程序的流程圖,或者功能模塊圖,此時再次劃分就會是一個相對準確,且有說服力的劃分方案了。另外根據項目的大小和功能性能,我們還有一些特殊的劃分要求,比如說一個顯示組件後面包含着一整套複雜的邏輯,那麼我們也可以將這個顯示組件和其後面的邏輯進行一個組件化。又或者,大多數應用都存在我的頁面,而設置一般也會放到我的頁面中,那麼有的應用設置非常簡單,此時就可以讓他和我的在一個組件中,而有的設置十分複雜,那麼可能一個設置項就變成了一個組件,如像微信中的掃一掃,朋友圈,搖一搖,他們都至少會是一個組件。所以對於組件的劃分我們可以從功能的大小出發,如果是幾個小功能組合成一個大功能,且這個功能不是很大,那麼這個大功能就是一個組件,小功能做的很大時,也可能自己就是一個組件了。
下面給出上面項目的組件化之後的結構:
此時已經經過相對成熟的模塊劃分。其中SuperHelper是作爲通用三方出現的,裏面包含了常用的三方和一些工具類,這裏他不在以module的形式存在,爲了保證他的不可修改性,已經將其變爲aar的形式引入項目中。
這裏面重點要說的是CommonSDK這個底層通用依賴,他的內部包含了自己的通用三方(SuperHelper.aar)和接入的其他業務SDK,並且根據組件化做了部分業務下沉,主要體現在
- 對外暴露接口
- 數據模型
- 事件總線Event
- 路徑
而在其之上是現有的業務組件,將所有的功能模塊儘可能的劃分,然後暴露出組件化過程中存在的問題。和之前的結構圖相比,其功能組件層,彼此不在存在關係,此時彼此獨立。
最上層的是App殼層,這一層也不在存在具體的業務類,它主要實現Application對組件的初始化和方便打包。
2.組件之間的命名
2.1先說一下組件層的命名建議策略:
此處建議將組件類module加上前綴module,底層依賴不加前綴,以此來區分組件和底層庫。
2.2組件內類的命名
這裏面除了對外開發的接口外,都可以按照正常的方式命名了(這些類已經可以正常的實現具體業務了)。
這裏面關於接口建議如下形式:
接口聲明:組件名稱+Service,代表是這個組件對外開放暴露的方法。
public interface CallService extends IProvider {
public String path="/call/CallService";
public void call(int number);
}
組件內的實現:組件名稱+Service+Impl
@Route(path = CallService.path)
public class CallServiceImpl implements CallService {
@Override
public void call(int number) {
CallUtil.call(number);
}
}
由於組件間不可見,當我們使用別的組件的對外暴露的接口時,需要一個接口管理類:
組件名稱+Service+Manager 代表對所有接入的服務統一管理
public class CallServiceManager {
private static final String TAG = "CallServiceManager";
private static Context context;
private static LoginService loginService;
public static void init(Context c){
context=c;
loginService=ARouter.getInstance().navigation(LoginService.class);
}
public static Context getContext() {
return context;
}
public static LoginService getLoginService(){return loginService;}
}
這樣通過靜態服務管理,就可以在組件內較爲方便調用其他組件的方法了。
2.3組件內資源的命名
首先Android對於同名資源文件的和參數的打包策略是,上層資源覆蓋下層,好比我們的底層庫有一個顏色資源聲明爲
<color name="white">#ffffff</color>
依賴於他的module也聲明瞭一個同名的資源:
<color name="white">#000000</color>
那麼此時我們引用到這個white資源的地方在實際運行的時候會體現爲黑色。
根據這個特性,我們可以在App外殼層最後決定我們的應用的一些整體資源的屬性值。
一般來說,一個應用的整體風格應該是一致,這體現在交付設計圖時,會給出字體的大小,和主題顏色,以及一些通用的屬性,此時我們可以將這些資源直接放到CommonSDK中,然後供上層組件調用,以此來實現整體風格統一的效果。
不過仍然存在一些特殊情況,以美團爲例,首先美團應用整體風格目前定義爲黃色,假設他的導航裏面每個分類都是一個組件,那麼我希望打開藥店的時候整體風格是綠色,打開酒店的時候是淡金色,即進入組件後根據組件的實際業務更換整體風格。此時同樣還需要考慮如果這個組件承載的業務量膨脹,進而發展成獨立的應用,如美團外賣。顯然此時應該將這個組件內的資源獨立化即組件內有自己完整的風格不在依賴於整體,也不能被上層覆蓋,此時建議的命名方式是:
<!--組件名稱_實際名稱-->
<color name="login_white">#000000</color>
進行命名。
另外爲了更好迎接變換,可以先以組件名稱_實際名稱此方式命名,在整體風格統一的時候,指向CommonSDK層的資源,在不統一的時候,直接修改指向即可。
組件化第二部:組件依賴
這個部分先介紹關於Android Studio在3.0之前和3.0之後關於依賴關鍵字的使用和意義。
Android3.0之前:
名稱 | 功能 |
---|---|
compile | 普通依賴,無依賴限制 |
provided | 只在編譯時有效,不會參與打包 |
apk | 只在生成apk的時候參與打包,編譯時不會參與 |
test compile | 只在單元測試代碼的編譯以及最終打包測試apk時有效 |
debug compile | debugCompile 只在debug模式的編譯和最終的debug apk打包時有效 |
release compile | Release 模式的編譯和最終的Release apk打包 |
Android3.0之後
名稱 | 功能 |
---|---|
implementation | 只有當前的module可以使用此依賴 |
api | 普通依賴,無依賴限制 |
compile only | 只在編譯時有效,不會參與打包 |
runtime only | 只在生成apk的時候參與打包,編譯時不會參與 |
test implementation | 只在單元測試代碼的編譯以及最終打包測試apk時有效 |
debug implementation | debugCompile 只在debug模式的編譯和最終的debug apk打包時有效 |
release implementation | Release 模式的編譯和最終的Release apk打包 |
Compile和 Api:功能類似,這種引用的方式可能會引起,相同依賴包,由於版本不同,而導致打包的時候失敗,這個時候可以對沖突的包進行版本同以解決,也可以通過implementation解決。
Provided和Compile Only:在依賴一些定製系統或者和定製系統相關的包的時候可以使用這個策略,也可以在自己的module中使用該方式依賴一些比如com.android.support,gson這些使用者常用的庫,避免衝突。
這裏重點說下比較坑的版本衝突,導致的打包報錯:
同樣的配置下的版本衝突,會自動使用最新版;而不同配置下的版本衝突,gradle同步時會直接報錯。可使用exclude、force解決衝突。
implementation 'com.android.support:appcompat-v7:27.0.0'
implementation 'com.android.support:appcompat-v7:28.0.0'
最後會使用28.0.0版本。
但是如
implementation 'com.android.support:appcompat-v7:23.1.1'
androidTestImplementation 'com.android.support.test.espresso:espresso-core:2.1'
所依賴的
com.android.support:support-annotations
版本不同,就會導致衝突。除了可以用exclude、force解決外,也可以自己統一爲所有依賴指定support包的版本,不需要爲每個依賴單獨排除了:
configurations.all {
resolutionStrategy.eachDependency { DependencyResolveDetails details ->
def requested = details.requested
if (requested.group == 'com.android.support') {
if (!requested.name.startsWith("multidex")) {
details.useVersion '26.1.0'
}
}
}
}
組件內的依賴請,儘量使用implementation。
組件化第三步:組件間交互:
組件間交互我認爲可以分爲兩個方向,一個是通過路由進行跳轉,這種利用框架可以很好的爲我們解決問題;另外一種是方法調用,已登錄模塊爲例,我需要得知當前的登錄賬號,或者程序掉線的時候控制是否重新登錄等,這些功能性的交互。
3.1Activtiy跳轉Activity
第一步定義PATH:
public class ActivityPath {
public static final String MAIN_PATH = "/main/";
public static final String MainActivity=MAIN_PATH+"MainActivity";
}
第二步添加註解
@Route(path = ActivityPath.MainActivity)
public class MainActivity extends BaseActivity {
}
第三步跳轉:
//通過路徑傳值跳轉
ARouter.getInstance().build(ActivityPath.MainActivity)
.with(new Bundle())
.navigation();
//根據類型跳轉
ARouter.getInstance().navigation(MainActivity.class);
3.2實例化Fragment
第一步定義PATH
public class FragmentPath {
public static final String SETTINVG_PATH = "/setting/";
public static final String SettingFragment=SETTINVG_PATH+"SettingFragment";
}
第二步添加註解
@Route(path = FragmentPath.SettingFragment)
public class SettingFragment extends BaseFragment {
}
第三步實例化
Fragment fragment = (Fragment) ARouter.getInstance().build(FragmentPath.SettingFragment).navigation();
3.3IProvider暴露接口
對於方法要求度不高的可以採用接口下沉的方式,具體實現參考《2.2組件內類的命名》的實現過程。
組件化第四步:開放接口的處理
在闡述接口交付這個概念之前先說兩個例子:
1.AIDL的實現。對於在一臺手機上的兩個應用如果想使用一方的服務,那麼就需要拿到AIDL開放的接口和接口涉及的數據對象。
2.服務端和客戶端之間交互,客戶端根據服務端告知的接口和參數去訪問服務端,然後服務端返回數據,無論是xml還是json格式都是key&value的形式。
通過上面兩個例子,不難發現已經隔離的業務,要想進行交互,最小代價就是告知對方接口和數據對象。那麼在組件化設計當中,我們必須要做的也是數據和接口的抽離。
如果想讓其他組件瞭解自己組件提供那些功能有兩種解決方案,一種是將這些接口和數據對象進行下沉,下沉後,接口方法和數據對象彼此可見,在註冊服務後,組件就可以使用彼此的功能了。這種形式是比較方便簡單的,而且更新方法和數據對象之後,上面可以通過報錯的形式被通知。不過他是存在缺點的。
- 第一如果兩個組件存在同名的類和方法時,容易造成引用的錯誤。
- 第二如果組件是對安全性要求較高的,或者說這個模塊,是不希望隨便對其他部分可見的,這個時候顯然下沉不是一個好的選擇。
- 第三如果未經過協商,組件間直接調用彼此的功能,對發生問題的排查也會造成困擾。
- 第四所有組件的接口的下沉,一定程度上造成了底層的膨脹,這種顯然也不利於通用層的維護。
這裏給出的解決方案是,將組件對外開放的功能和數據對象打成jar包,哪個組件需要,那麼通過申請的形式獲取這個jar包,那麼對於開放方是知曉那些組件使用了自己的功能的,發生問題時,只需要在這幾個組件裏面排查就可以了。此時由於不在經過通用層,通用層膨脹的問題也解決了。
另外有一些組件可能是所有組件都要使用的例如登錄和設置,像這種就建議直接放到通用層中,減少不必要的工作量。
組件化第五步:組件化的一些問題
5.1切換androidX的問題
引入組件化的項目,一般是存在了一段時間,隨着版本的迭代發現現有的架構不在能很好的爲項目開發服務了,才引入組件化架構,由於項目的創建時間可能略久,而gradle中的依賴由於一般不會改動,可能還停留在創建時的版本。而組件化的過程對現有項目來說是一個很好的重構過程,那麼自然涉及到對依賴的更新,那麼這裏就說下關於切換AndroidX的步驟和一點注意事項。
其實切換AndroidX是十分方便的,切換後也不會產生太多的問題。
當前使用的Android Studio版本3.6.1
項目build.gradle版本:
classpath 'com.android.tools.build:gradle:3.6.1'
項目gradle-wrapper.properties版本:
distributionUrl=https\://services.gradle.org/distributions/gradle-5.6.4-all.zip
切換第一步:
選擇這個後,Studio會自動的將項目整體切換到AndroidX的依賴上去。切換後一般會報錯。
這裏說兩個我遇到的問題,第一個就是將項目中的moudel的:
compileSdkVersion 28
targetSdkVersion 28
進行對齊,統一到28或者以上,不修改28以下的module可能在資源上出錯。
第二記得修改以下Fragment將之前的依賴切換到:
import androidx.fragment.app.Fragment;
5.2Module使用Butterknife的問題
Butterknife作爲快速實現View初始化的工具,可以很大的提高我們的開發效率。不過在Butterknife的發展過程中,存在着一段時間,其無法很好的在module層使用的問題,如果無法在module使用只能在應用層使用的話,其在組件化的開發中就失去了意義,所以此部分特別說一下,目前的解決和使用狀態。
首先不管現在使用的是什麼版本的Butterknife,直接去Github上更新到最新的Butterknife。
然後在項目build.gradle中更換成最的新插件
classpath 'com.jakewharton:butterknife-gradle-plugin:10.2.1'
對於使用的項目先接入插件:
在使用Butterknife的組件中添加:
apply plugin: 'com.jakewharton.butterknife'
然後在依賴中接入依賴:
implementation 'com.jakewharton:butterknife:10.2.1'
annotationProcessor 'com.jakewharton:butterknife-compiler:10.2.1'
然後rebuild。
對於應用層的module不會產生影響,這裏說下組件層需要處理的事情。
首先我們的module層會出現兩個R文件的引用,一個.R一個是.R2。
簡單來說涉及到android原生部分我們使用R.的資源文件。
對於涉及到Butterknife的我們使用R2.的資源文件。
其具體體現就是原生方法如:setContentView 使用R.的資源:
setContentView(R.layout.activity);
涉及到Butterknife的
@BindView(R2.id.view)
view view;
@OnClick({R2.id.view}
public void onViewClicked(View view) {
switch (view.getId()) {
case R2.id.view;
break;
}
}
5.3Module使用GreenDao
引入此三方主要的原因是,GreenDao是一種對於原生數據庫和數據存取速度處理的折中方案。在對Android原生Sqlite的處理能力上,GreenDao無疑是非常優秀的,所以考慮兼容時,其還是項目首選。
不過GreenDao也引入了編譯時註解,所以在組件使用時,這裏還是需要稍微提及一下,避免由於切換失敗導致整體換數據庫三方。(這事發生過,是十分🥚疼的)
此處我們面臨的場景是,一般只有數據存處理組件會設計到數據庫的操作,比如聯繫人這個模塊,其就高度依賴於數據庫。
那麼我們對於聯繫人這個模塊主要修改module的build.gradle:
先引入插件:
apply plugin: 'org.greenrobot.greendao'
然後在(注意此處module間的版本需要統一!):
android{
schemaVersion 1 //當前數據庫版本,
}
最後添加依賴即可:
implementation 'org.greenrobot:greendao:3.2.2'
之後拆分數據庫按照之前的模式拆分即可,rebuild之後數據Dao和DaoSession就都有了。
5.4 數據對象
這個模塊主要是承接上面的問題,此時我們已經解決了GreenDao在module中使用的問題。不過新的問題出現了,前面說了只有數據提供方需要使用數據庫三方,而不涉及到數據庫操作的組件,顯然是不應該引入的(引入會降低編譯速度),那麼此時引入GreenDao的組件其數據對象由於要進行數據庫操作,會使用GreenDao提供的註解。而註解是不在不使用數據庫的組件中出現的!顯然,此時的數據對象是不能拿來直接給外部的。
對於這種的解決方案是,將數據處理對象DBBean去掉註解後,在生成一個不包含註解的數據Bean,此數據對象和DBBean需要完全一致,用於數據庫回存。然後由數據提供方增加工廠方法,實現DBBean<->Bean的互轉。
命名建議:
數據處理對象更改爲:
com.dbbean.DBDataBean
方便其和通用數據Bean區別。
5.5公用Adapter的問題
問題:組件A自己有一個開發完畢的Adapter,這個時候爲了用戶方便需要在組件B中引入一個一樣的Adapter。由於組件化,組件A的Adapter對組件B不可見,此時就產生了Adapter的複用問題。
解決方案有下面幾種:
-
組件A的Adapter下沉到CommonBase裏面,這樣對B可見。這在組件中有一個問題,這個Adapter如果下沉了,他應該有誰來維護?顯然CommonBase是不合適的,但是組件A的開發人員也不應該有權限去開發CommonBase。顯然這種並不是很合理。
-
將組件A的Adapter粘貼一份到組件B,這種也不太好,第一個原因是代碼冗餘,第二個原因是組件A如果更新了Adapter,就需要通知B去同步,如果增加了其他組件那麼就要都通知一遍。這個感覺也不是很合理。
-
最後是我想到的解決方案,一般對於Adapter我們都會進行封裝一個BaseAdapter做爲父類,那麼我們採用公共父類的形式聲明Adapter,這樣最大的保證了Adapter的功能,但是子類Adapter獨有的其他功能卻不可見了,此時Adapter變得不在有業務處理能力!只是單純的顯示幫助者。
另外作爲Adapter還需要處理點擊事件,因爲兩個組件中的Adapter雖然顯示一樣,但是點擊邏輯可能不同。顯然如果Adapter由一個組件維護,那麼這個組件對應的layout文件也應該在組件A中,同時對組件B不可見。這在處理點擊時使用view.getId()的形式已經不能滿足了,可能有些同學會想到,使用getTag()的形式來實現,這種實現方式的缺點是,這個Tag是你需要寫到文檔中告知對方的,Tag修改的時候同理。這樣就有點像方法2那樣的困境了,告訴一個和告訴兩個的成本其實是一樣的。
此處就產生了新的問題如何解決點擊事件?
這裏給出的解決方案是:
給出接口,回調接口,在獲取BaseAdapter的時候作爲參數傳進來。
其在和組件A獲取Adapter的時候是這樣的:
public BaseAdapter getCallAdapter(Context context,CallAdapterCallback callAdapterCallback);
關於接口的定義如下:
public interface CallAdapterCallback {
public void onTermianlClick(int position, View view,CallListBean callListBean);
public void onAcceptlick(int position, View view,CallListBean callListBean);
public void onSwitchClick(int position, View view,CallListBean callListBean);
}
5.6如何進行組件化而不產生報錯
在組件化過程中,發現一個十分難受的問題,就是做一個組件的時候,由於這部分組件的代碼其實對於之前說的App層代碼是下沉的,這就會導致,資源,以及和對其他功能的引用報錯,這個時候,不得不對另外一個功能進行組件化或者讓其設計開放接口,在這個功能組件化的時候同樣面臨相同的問題,最後很可能把整個項目都組件化了,而這段期間,項目都是無法編譯的,而當整體組件化完成的時候,就產生了大量的代碼修改,這種時候一般會產生大量問題,不利於項目維護!
這裏給出的解決方案是,首先將APP層變成Library,然後建立設計好的組件,再在組件的上層建立一個殼。這樣在我們開發組件的時候就是代碼上移,這樣就不會產生因爲引用造成的問題了,隨時都可以編譯了。
當然這種辦法可能產生組件化劃分的不完全,即可能存在遺漏的文件留在了下層。
此時的解決方案是:當我們組件化基本完成的時候,我們用App覆蓋掉殼,根據報錯,在將遺漏的文件進行組件劃分處理。
5.7MainActivity的組件化
其實一開始是沒打算將MainActivity組件化的,開始時打算將MainActivity作爲上層打包時的殼,那麼他就可以拼裝相關的Fragment了,還可以看見每個組件的方法,基本沒有太的改動。不過考慮到,其實MainActivity模塊也有可能承載很多的業務量,如自動登錄,語言設置,當前網絡狀態回調,以及賬號的登錄狀態等,你會發現其實MainActivity和很多模塊有關聯成了業務耦合的重災區。由於耦合嚴重MainActivity往往代碼量較大,不易於維護。
另外對於自動登錄這個功能來說,他應該由登錄組件進行維護,對於MainActivity來說就不應該存在他的邏輯了。其他的功能同理。
MainActivity組件化後,主要體現在需要增加了很多的接口,和修改某些回調爲生命週期回調,此時可以很好的解決MainActivity的耦合問題。同時由於去掉了接口的直接回調處理,MainActivity會變小,也很難膨脹變大。
5.8關於業務生命週期的開放接口
先舉個栗子🌰:
業務場景如下,登錄模塊除了在登錄頁使用,一般也會在首頁使用進行自動登錄業務,那麼此時很正常的就是,首頁組件調用登錄組件的登錄功能。此時有一個要求,就是首頁在登錄成功前,不希望用戶進行業務,需要加一個擋板,用於防止用戶進行操作。
顯然擋板是發起業務方的業務,而生命週期是被調用方的事情。那麼我們只需在登錄組件中暴露他的聲明週期接口就可以了:
public interface LoginCallback {
public void onLoginStart();
public void onLoginSuccess();
public void onLoginError(String errorMessage);
public void onLoginEnd();
}
其實此處只是想提醒大家,在處理這種可能涉及到請求有回調的方法時,應該記得開放對應的週期接口,方便調用方完成業務。
5.9多語言文件的處理
組件化之後的多語言文件已經被打散分佈在各個組件當中,那麼當我們在開發末期,需要整體提交需要翻譯的語言時就成爲了一個問題,需要各組件進行整理,然後彙總,這是一個易出錯且效率地下的方式。此處可以依靠插件化的方式去解決,即在編譯時自動彙總組件內的strings。