Android 模塊化探索與實踐

AngAndrews 7年前發布 | 15K 次閱讀 安卓開發 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'
       }
   }
}

如圖:<div align="left"><img src="manifest.png" width = "45%" alt="圖片名稱" align=center /></div>

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 獲取獲取傳遞過來的參數,簡化開發。

4.1 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);

4.2 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

五、問題及建議

5.1 資源名沖突

對于多個 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>

5.2 重復依賴

模塊化的過程中我們常常會遇到重復依賴的問題,如果是通過 aar 依賴, gradle 會自動幫我們找出新版本,而拋棄老版本的重復依賴。如果是以 project 的方式依賴,則在打包的時候會出現重復類。對于這種情況我們可以在 build.gradle 中將 compile 改為 provided,只在最終的項目中 compile 對應的 library ;

其實從前面的安居客模塊化設計圖上能看出來,我們的設計方案能一定程度上規避重復依賴的問題。比如我們所有的第三方庫的依賴都會放到 OpenSoureLibraries 中,其他需要用到相關類庫的項目,只需要依賴 OpenSoureLibraries 就好了。

5.3 模塊化過程中的建議

對于大型的商業項目,在重構過程中可能會遇到業務耦合嚴重,難以拆分的問題。 我們需要先理清業務,再動手拆分業務模塊 。比如可以先在原先的項目中根據業務分包,在一定程度上將各業務解耦后拆分到不同的 package 中。比如之前新房和二手房由于同屬于 app module,因此他們之前是通過隱式的 intent 跳轉的,現在可以先將他們改為通過 Router 來實現跳轉。又比如新房和二手房中公用的模塊可以先下放到 Business Component Layer 或者 Basic Component Layer 中。在這一系列工作完成后再將各個業務拆分成多個 module 。

模塊化重構需要漸進式的展開,不可一觸而就,不要想著將整個項目推翻重寫。線上成熟穩定的業務代碼,是經過了時間和大量用戶考驗的;全部推翻重寫往往費時費力,實際的效果通常也很不理想,各種問題層出不窮得不償失。對于這種項目的模塊化重構,我們需要一點點的改進重構,可以分散到每次的業務迭代中去,逐步淘汰掉陳舊的代碼。

各業務模塊間肯定會有公用的部分,按照我前面的設計圖,公用的部分我們會根據業務相關性下放到業務組件層(Business Component Layer)或者基礎組件層(Common Component Layer)。對于太小的公有模塊不足以構成單獨組件或者模塊的,我們先放到類似于 CommonBusiness 的組件中,在后期不斷的重構迭代中視情況進行進一步的拆分。過程中完美主義可以有,切記不可過度。

以上就是我在模塊化探索實踐方面的一些經驗,不住之處還望大家指出。

如果你喜歡我的文章,就關注下我的 知乎專欄 或者在 GitHub 上添個 Star 吧!

 

來自:https://segmentfault.com/a/1190000009447670

 

 本文由用戶 AngAndrews 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。
 轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。
 本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!