Android 模組化探索與實踐
前言
全球資訊網發明人 Tim Berners-Lee 談到設計原理時說過:“簡單性和模組化是軟體工程的基石;分散式和容錯性是網際網路的生命。” 由此可見模組化之於軟體工程領域的重要性。
從 2016 年開始,模組化在 Android 社群越來越多的被提及。隨著移動平臺的不斷髮展,移動平臺上的軟體慢慢走向複雜化,體積也變得臃腫龐大;為了降低大型軟體複雜性和耦合度,同時也為了適應模組重用、多團隊並行開發測試等等需求,模組化在 Android 平臺上變得勢在必行。阿里 Android 團隊在年初開源了他們的容器化框架 Atlas 就很大程度說明了當前 Android 平臺開發大型商業專案所面臨的問題。
什麼是模組化
那麼什麼是模組化呢?《 Java 應用架構設計:模組化模式與 OSGi 》一書中對它的定義是:模組化是一種處理複雜系統分解為更好的可管理模組的方式。
上面這種描述太過生澀難懂,不夠直觀。下面這種類比的方式則可能加容易理解。
我們可以把軟體看做是一輛汽車,開發一款軟體的過程就是生產一輛汽車的過程。一輛汽車由車架、發動機、變數箱、車輪等一系列模組組成;同樣,一款大型商業軟體也是由各個不同的模組組成的。
汽車的這些模組是由不同的工廠生產的,一輛 BMW 的發動機可能是由位於德國的工廠生產的,它的自動變數箱可能是 Jatco(世界三大變速箱廠商之一)位於日本的工廠生產的,車輪可能是中國的工廠生產的,最後交給華晨寶馬的工廠統一組裝成一輛完整的汽車。這就類似於我們在軟體工程領域裡說的多團隊並行開發,最後將各個團隊開發的模組統一打包成我們可使用的 App 。
一款發動機、一款變數箱都不可能只應用於一個車型,比如同一款 Jatco 的 6AT 自動變速箱既可能被安裝在 BMW 的車型上,也可能被安裝在 Mazda 的車型上。這就如同軟體開發領域裡的模組重用。
到了冬天,特別是在北方我們可能需要開著車走雪路,為了安全起見往往我們會將汽車的公路胎升級為雪地胎;輪胎可以很輕易的更換,這就是我們在軟體開發領域談到的低耦合。一個模組的升級替換不會影響到其它模組,也不會受其它模組的限制;同時這也類似於我們在軟體開發領域提到的可插拔。
模組化分層設計
上面的類比很清晰的說明的模組化帶來的好處:
- 多團隊並行開發測試;
- 模組間解耦、重用;
- 可單獨編譯打包某一模組,提升開發效率。
在《安居客 Android 專案架構演進》這篇文章中,我介紹了安居客 Android 端的模組化設計方案,這裡我還是拿它來舉例。但首先要對本文中的元件和模組做個區別定義
- 元件:指的是單一的功能元件,如地圖元件(MapSDK)、支付元件(AnjukePay)、路由元件(Router)等等;
- 模組:指的是獨立的業務模組,如新房模組(NewHouseModule)、二手房模組(SecondHouseModule)、即時通訊模組(InstantMessagingModule)等等;模組相對於元件來說粒度更大。
具體設計方案如下圖:
整個專案分為三層,從下至上分別是:
- Basic Component Layer: 基礎元件層,顧名思義就是一些基礎元件,包含了各種開源庫以及和業務無關的各種自研工具庫;
- Business Component Layer: 業務元件層,這一層的所有元件都是業務相關的,例如上圖中的支付元件 AnjukePay、資料模擬元件 DataSimulator 等等;
- Business Module Layer: 業務 Module 層,在 Android Studio 中每塊業務對應一個單獨的 Module。例如安居客使用者 App 我們就可以拆分成新房 Module、二手房 Module、IM Module 等等,每個單獨的 Business Module 都必須準遵守我們自己的 MVP 架構。
我們在談模組化的時候,其實就是將業務模組層的各個功能業務拆分層獨立的業務模組。所以我們進行模組化的第一步就是業務模組劃分,但是模組劃分並沒有一個業界通用的標準,因此劃分的粒度需要根據專案情況進行合理把控,這就需要對業務和專案有較為透徹的理解。拿安居客來舉例,我們會將專案劃分為新房模組、二手房模組、IM 模組等等。
每個業務模組在 Android Studio 中的都是一個 Module ,因此在命名方面我們要求每個業務模組都以 Module 為字尾。如下圖所示:
對於模組化專案,每個單獨的 Business Module 都可以單獨編譯成 APK。在開發階段需要單獨打包編譯,專案釋出的時候又需要它作為專案的一個 Module 來整體編譯打包。簡單的說就是開發時是 Application,釋出時是 Library。因此需要在 Business Module 的 build.gradle 中加入如下程式碼:
if(isBuildModule.toBoolean()){ apply plugin: 'com.android.application' }else{ apply plugin: 'com.android.library' }
isBuildModule 在專案根目錄的 gradle.properties 中定義:
> isBuildModule=false >
同樣 Manifest.xml 也需要有兩套:
sourceSets { main { if (isBuildModule.toBoolean()) { manifest.srcFile 'src/main/debug/AndroidManifest.xml' } else { manifest.srcFile 'src/main/release/AndroidManifest.xml' } } }
如圖:
debug 模式下的 AndroidManifest.xml :
<application ... > <activity android:name="com.baronzhang.android.newhouse.NewHouseMainActivity" android:label="@string/new_house_label_home_page"> <intent-filter> <action android:name="android.intent.action.MAIN" /> <category android:name="android.intent.category.LAUNCHER" /> </intent-filter> </activity> </application>
realease 模式下的 AndroidManifest.xml :
<application ... > <activity android:name="com.baronzhang.android.newhouse.NewHouseMainActivity" android:label="@string/new_house_label_home_page"> <intent-filter> <category android:name="android.intent.category.DEFAULT" /> <category android:name="android.intent.category.BROWSABLE" /> <action android:name="android.intent.action.VIEW" /> <data android:host="com.baronzhang.android.newhouse" android:scheme="router" /> </intent-filter> </activity> </application>
同時針對模組化我們也定義了一些自己的遊戲規則:
- 對於 Business Module Layer,各業務模組之間不允許存在相互依賴關係,它們之間的跳轉通訊採用路由框架 Router 來實現(後面會介紹 Router 框架的實現);
- 對於 Business Component Layer,單一業務元件只能對應某一項具體的業務,個性化需求對外部提供介面讓呼叫方定製;
- 合理控制各元件和各業務模組的拆分粒度,太小的公有模組不足以構成單獨元件或者模組的,我們先放到類似於 CommonBusiness 的元件中,在後期不斷的重構迭代中視情況進行進一步的拆分;
- 上層的公有業務或者功能模組可以逐步下放到下層,合理把握好度就好;
- 各 Layer 間嚴禁反向依賴,橫向依賴關係由各業務 Leader 和技術小組商討決定。
模組間跳轉通訊(Router)
對業務進行模組化拆分後,為了使各業務模組間解耦,因此各個 Bussiness Module 都是獨立的模組,它們之間是沒有依賴關係。那麼各個模組間的跳轉通訊如何實現呢?
比如業務上要求從新房的列表頁跳轉到二手房的列表頁,那麼由於是 NewHouseModule 和 SecondHouseModule 之間並不相互依賴,我們通過想如下這種顯式跳轉的方式來實現 Activity 跳轉顯然是不可能的實現的。
Intent intent = new Intent(NewHouseListActivity.this, SecondHouseListActivity.class); startActivity(intent);
有的同學可能會想到用隱式跳轉,通過 Intent 匹配規則來實現:
Intent intent = new Intent(Intent.ACTION_VIEW, "<scheme>://<host>:<port>/<path>"); startActivity(intent);
但是這種程式碼寫起來比較繁瑣,且容易出錯,出錯也不太容易定位問題。因此一個簡單易用、解放開發的路由框架是必須的了。
我自己實現的路由框架分為路由(Router) 和引數注入器(Injector) 兩部分:
Router 提供 Activity 跳轉傳參的功能;Injector 提供引數注入功能,通過編譯時生成程式碼的方式在 Activity 獲取獲取傳遞過來的引數,簡化開發。
Router
路由(Router)部分通過 Java 註解結合動態代理來實現,這一點和 Retrofit 的實現原理是一樣的。
首先需要定義我們自己的註解(篇幅有限,這裡只列出少部分原始碼)。
用於定義跳轉 URI 的註解 FullUri:
@Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface FullUri { String value(); }
用於定義跳轉傳參的 UriParam( UriParam 註解的引數用於拼接到 URI 後面):
@Target(ElementType.PARAMETER) @Retention(RetentionPolicy.RUNTIME) public @interface UriParam { String value(); }
用於定義跳轉傳參的 IntentExtrasParam( IntentExtrasParam 註解的引數最終通過 Intent 來傳遞):
@Target(ElementType.PARAMETER) @Retention(RetentionPolicy.RUNTIME) public @interface IntentExtrasParam { String value(); }
然後實現 Router ,內部通過動態代理的方式來實現 Activity 跳轉:
public final class Router { ... public <T> T create(final Class<T> service) { return (T) Proxy.newProxyInstance(service.getClassLoader(), new Class[]{service}, new InvocationHandler() { @Override public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { FullUri fullUri = method.getAnnotation(FullUri.class); StringBuilder urlBuilder = new StringBuilder(); urlBuilder.append(fullUri.value()); //獲取註解引數 Annotation[][] parameterAnnotations = method.getParameterAnnotations(); HashMap<String, Object> serializedParams = new HashMap<>(); //拼接跳轉 URI int position = 0; for (int i = 0; i < parameterAnnotations.length; i++) { Annotation[] annotations = parameterAnnotations[i]; if (annotations == null || annotations.length == 0) break; Annotation annotation = annotations[0]; if (annotation instanceof UriParam) { //拼接 URI 後的引數 ... } else if (annotation instanceof IntentExtrasParam) { //Intent 傳參處理 ... } } //執行Activity跳轉操作 performJump(urlBuilder.toString(), serializedParams); return null; } }); } ... }
上面是 Router 實現的部分程式碼,在使用 Router 來跳轉的時候,首先需要定義一個 Interface(類似於 Retrofit 的使用方式):
public interface RouterService { @FullUri("router://com.baronzhang.android.router.FourthActivity") void startUserActivity(@UriParam("cityName") String cityName, @IntentExtrasParam("user") User user); }
接下來我們就可以通過如下方式實現 Activity 的跳轉傳參了:
RouterService routerService = new Router(this).create(RouterService.class); User user = new User("張三", 17, 165, 88); routerService.startUserActivity("上海", user);
Injector
通過 Router 跳轉到目標 Activity 後,我們需要在目標 Activity 中獲取通過 Intent 傳過來的引數:
getIntent().getIntExtra("intParam", 0); getIntent().getData().getQueryParameter("preActivity");
為了簡化這部分工作,路由框架 Router 中提供了 Injector 模組在編譯時生成上述程式碼。引數注入器(Injector)部分通過 Java 編譯時註解來實現,實現思路和 ButterKnife 這類編譯時註解框架類似。
首先定義我們的引數註解 InjectUriParam :
@Target(ElementType.FIELD) @Retention(RetentionPolicy.CLASS) public @interface InjectUriParam { String value() default ""; }
然後實現一個註解處理器 InjectProcessor ,在編譯階段生成獲取引數的程式碼:
@AutoService(Processor.class) public class InjectProcessor extends AbstractProcessor { ... @Override public boolean process(Set<? extends TypeElement> set, RoundEnvironment roundEnvironment) { //解析註解 Map<TypeElement, TargetClass> targetClassMap = findAndParseTargets(roundEnvironment); //解析完成後,生成的程式碼的結構已經有了,它們存在InjectingClass中 for (Map.Entry<TypeElement, TargetClass> entry : targetClassMap.entrySet()) { ... } return false; } ... }
使用方式類似於 ButterKnife ,在 Activity 中我們使用 Inject 來註解一個全域性變數:
@Inject User user;
然後 onCreate 方法中需要呼叫 inject(Activity activity) 方法實現注入:
RouterInjector.inject(this);
這樣我們就可以獲取到前面通過 Router 跳轉的傳參了。
由於篇幅限制,加上為了便於理解,這裡只貼出了極少部分 Router 框架的原始碼。希望進一步瞭解 Router 實現原理的可以到 GiuHub 去翻閱原始碼,Router 的實現還比較簡陋,後面會進一步完善功能和文件,之後也會有單獨的文章詳細介紹。原始碼地址:https://github.com/BaronZ88/Router
問題及建議
資源名衝突
對於多個 Bussines Module 中資源名衝突的問題,可以通過在 build.gradle 定義字首的方式解決:
defaultConfig { ... resourcePrefix "new_house_" ... }
而對於 Module 中有些資源不想被外部訪問的,我們可以建立 res/values/public.xml,新增到 public.xml 中的 resource 則可被外部訪問,未新增的則視為私有:
<resources> <public name="new_house_settings" type="string"/> </resources>
重複依賴
模組化的過程中我們常常會遇到重複依賴的問題,如果是通過 aar 依賴, gradle 會自動幫我們找出新版本,而拋棄老版本的重複依賴。如果是以 project 的方式依賴,則在打包的時候會出現重複類。對於這種情況我們可以在 build.gradle 中將 compile 改為 provided,只在最終的專案中 compile 對應的 library ;
其實從前面的安居客模組化設計圖上能看出來,我們的設計方案能一定程度上規避重複依賴的問題。比如我們所有的第三方庫的依賴都會放到 OpenSoureLibraries 中,其他需要用到相關類庫的專案,只需要依賴 OpenSoureLibraries 就好了。
模組化過程中的建議
對於大型的商業專案,在重構過程中可能會遇到業務耦合嚴重,難以拆分的問題。我們需要先理清業務,再動手拆分業務模組。比如可以先在原先的專案中根據業務分包,在一定程度上將各業務解耦後拆分到不同的 package 中。比如之前新房和二手房由於同屬於 app module,因此他們之前是通過隱式的 intent 跳轉的,現在可以先將他們改為通過 Router 來實現跳轉。又比如新房和二手房中公用的模組可以先下放到 Business Component Layer 或者 Basic Component Layer 中。在這一系列工作完成後再將各個業務拆分成多個 module 。
模組化重構需要漸進式的展開,不可一觸而就,不要想著將整個專案推翻重寫。線上成熟穩定的業務程式碼,是經過了時間和大量使用者考驗的;全部推翻重寫往往費時費力,實際的效果通常也很不理想,各種問題層出不窮得不償失。對於這種專案的模組化重構,我們需要一點點的改進重構,可以分散到每次的業務迭代中去,逐步淘汰掉陳舊的程式碼。
各業務模組間肯定會有公用的部分,按照我前面的設計圖,公用的部分我們會根據業務相關性下放到業務元件層(Business Component Layer)或者基礎元件層(Common Component Layer)。對於太小的公有模組不足以構成單獨元件或者模組的,我們先放到類似於 CommonBusiness 的元件中,在後期不斷的重構迭代中視情況進行進一步的拆分。過程中完美主義可以有,切記不可過度。
以上就是我在模組化探索實踐方面的一些經驗,不住之處還望大家指出。
- 模組化示例專案 ModularizationProject 原始碼地址:https://github.com/BaronZ88/ModularizationProject
- 路由框架 Router 原始碼地址:https://github.com/BaronZ88/Router
相關文章
- Android元件化探索與實踐Android元件化
- iOS模組化探索實踐iOS
- Android 模組化探索和實踐(1):基本思路Android
- Android工程化實踐:模組化Android
- Android對so體積優化的探索與實踐Android優化
- Android模組化開發實踐Android
- FlutterWeb效能優化探索與實踐FlutterWeb優化
- Flutter探索與實踐Flutter
- Vue 探索與實踐Vue
- 醫動力Android基於CC元件化框架的探索與實踐Android元件化框架
- ChatGPT的探索與實踐ChatGPT
- 彈性探索與實踐
- 美圖個性化推薦的實踐與探索
- 快取框架 Caffeine 的視覺化探索與實踐快取框架視覺化
- Android 元件化探索與思考Android元件化
- Redis 記憶體優化在 vivo 的探索與實踐Redis記憶體優化
- Flink 在 B 站的多元化探索與實踐
- 運維數智化轉型的深入實踐與探索運維
- Laravel-Modules 模組化開發實踐與填坑Laravel
- Android 基於註解IOC元件化/模組化的架構實踐Android元件化架構
- Android 元件化方案探索與思考Android元件化
- 與你探索classnames模組內部實現
- 小紅書社群反作弊探索與實踐
- Presto在滴滴的探索與實踐REST
- 阿里云云效智慧化程式碼平臺的探索與實踐阿里
- Redis 記憶體最佳化在 vivo 的探索與實踐Redis記憶體
- Android模組化與元件化–多模組區分編譯Android元件化編譯
- Android模組化與元件化--多模組區分編譯Android元件化編譯
- 廣告平臺化的探索與實踐 | 美團外賣廣告工程實踐專題連載
- Android實現模組 api 化AndroidAPI
- 前端多專案模組化實踐前端
- 人崗匹配排序的探索與實踐排序
- 資料庫治理的探索與實踐資料庫
- 探索 JS 中的模組化JS
- 開源實踐 | 攜程在OceanBase的探索與實踐
- 開源實踐 | 攜程在 OceanBase 的探索與實踐
- 企業成本專案化管理模式的探索與實踐(轉)模式
- 純原生元件化-模組化的探索元件化