Android:項目模塊化/組件化的架構之路(一)

前言

在Android開發中,隨著項目的不斷擴展,項目會變得越來越龐大,而隨之帶來的便是項目維護成本與開發成本的增加!每次調試時,不得不運行整個項目;每當有新成員加入團隊時,需要更多的時間去了解龐大的項目。。。而為了解決這些問題,團隊通常會將項目模塊化,以此來降低項目的復雜度和耦合度,讓團隊可以并行開發與測試,讓團隊成員更加專注于自己所負責的功能模塊開發。。。

對于一些大廠如BAT或者美團等這些大型互聯網公司,都會自己造輪子,實現項目模塊化。而對于中小型公司,限于成本因素,一般都是選用這些大廠造的優秀的輪子來進行項目模塊化。本文以及后面一系列文章,將向大家分享我在項目模塊化中的實踐經驗。我的項目模塊化方案主要借鑒于餓了么微信美團的模塊化技術文章,如有建議,歡迎提出!

推薦

個人博客:Android:項目模塊化/組件化的架構之路(一)

文章發布將先在個人博客與微信公眾號「碼途有道」發布,歡迎大家關注!

模塊化需要做什么

首先,在開始項目模塊化之前,我們必須要明確模塊化需要做些什么?這就等于寫書之前必須得有個總綱,否則越寫到后面,越是混亂。以下是我認為在模塊化時需要注意的幾個問題:

  • 如何拆分項目
  • 模塊之間的通信
  • 模塊內的代碼隔離
  • 模塊在調試與發布模式之間的切換

在明確了項目模塊化中需要解決的問題后,我們需要選定一個優秀的組件化開源框架。在本方案中,我選擇阿里的ARouter,也是目前比較流行的組件化框架之一,大家也可以選擇其他開源框架。ARouter的具體使用本文就不在介紹了,大家可以在網上自行搜索,下面開始設計項目模塊化的架構。

一、如何拆分項目

模塊化結構圖例

如上圖所示,我將項目大概劃分為五層:

  • 宿主層: 不做具體的項目功能實現,只負責集成業務模塊,組裝成一個完整的APP
  • 業務模塊層: 將項目的每個大功能模塊拆分成的一個一個單獨的module
  • 基礎業務組件層: 此層最大的作用是為了復用,例如首頁模塊新盤模塊中都有樓盤搜索這個功能,且UI顯示相似,這時在兩個模塊中都實現樓盤搜索就顯得繁瑣了,像這種與業務有關聯且需要多處使用的情況,我們完全可以將其抽離出來作為基礎業務組件
  • 功能組件層: 項目中常用的功能庫,如圖片加載、網絡請求等
  • 底層SDK: 從公司項目中長期積累出來的底層類庫

以上是大多數項目模塊化時的拆分方式,每個人也可以根據項目的實際情況進行調整。

二、模塊之間的通信

1. 常用的通信方式

當項目被拆分成多個模塊后,模塊之間的良好的通信是我們必須考慮的問題。ARouter本身也提供一套通信機制,但是一般很難滿足我們所有的需求,所以我們會容易想到的常用的幾種通信方式:EvenBus、協議通信、廣播或者是將通信的部分下沉到公共組件庫。對于這幾種方式,在一些大廠的技術文章中都有提到一些他們的看法,下面我簡單總結一下:

  • EventBus: 我們非常熟悉的事件總線型的通信框架,非常靈活,采用注解方式實現,但是難以追溯事件,微信餓了么認為這是個極大的缺點,不是很推薦,但是美團覺得只要自身控制的好就行(自己設計了一套基于LiveData的簡易事件總線通信框架)。
  • 協議通信: 通信雙發必須得都知曉協議,且協議需要放在一個公共部分保存。雖然解耦能力強,但是協議一旦變化,通訊雙方的同步會變的復雜,不方便。
  • 廣播: 安卓的四大組件之一,常見的通信方式,但是相對EventBus來說,過重。
  • 下沉到公共組件庫: 這是在模塊化中常見的做法,不斷的將各種方法、數據模型等公共部分下成到公共組件庫,這樣一來,公共組件庫會變的越來越龐大,越來越中心化,違背了項目模塊化的初衷。最后,越來越難以維護,不得不在重新拆分公共組件庫。

2. 改善通信方式

上面說了一些常用的通信方式,可以看到大廠并不是很滿意,那么大廠都是怎么解決的呢?沒錯,大廠往往都會有重造一個符合他們要求的通信框架!然而這不是我們想要的(中小型公司的實力和成本不允許啊:cry:),不過沒關系,我們可以根據大廠們的思路,自己建造一個精簡實用的通信架構,下面一起來看!

2.1 使用接口進行通信

接口通信概念并不是什么新穎的概念,微信在技術文章中著重說明過,ARouter框架也具備這種能力。下面我們配合一個小例子形象的說明一下。

eg:在一個房產項目中,有推薦與新盤兩個模塊,這兩個模塊使用相同的接口(傳入參數不同)獲取樓盤數據。

針對這種狀況,一般我們下沉數據接口至公共組件庫,然后推薦和新盤都去調用公共組件庫中的樓盤接口獲取數據。而上面也說過了,這種情況的后果就是,當有數十上百個接口被下沉到公共組件庫后,公共組件庫已然中心化且變得很龐大,這不是我們想要的。我們需要的是去中心化,開發成員只需要專注于自己負責的模塊本身,減少模塊之間的耦合。而接口化的通信方式給我們提供了這種可能。

什么是接口化通信?我們可以理解為將一個模塊當作SDK,提供接口+數據結構給其他需要的模塊調用,從而實現通信。我們完全可以將獲取樓盤數據的具體實現放在新盤模塊中,然后單獨提供獲取樓盤數據的接口和樓盤的數據結構給推薦模塊,推薦模塊通過調用提供的接口就可以直接獲取需要的樓盤數據,不需要關注獲取樓盤數據的具體實現。 這樣,即使獲取樓盤數據的具體實現發生變動,推薦模塊也不需要做任何更改,且維護也很簡單。

對于這種接口化的通信實現,ARouterIProvider就已經實現,完全可以滿足我們的要求。具體如何使用IProvider,這里就不在詳細介紹,大家可以網上自行搜索。

2.2 如何對外暴露接口

解決了通信手段的問題,我們就得考慮另一個問題,為其他模塊提供的接口+數據結構我們應該放在哪里?下沉到公共模塊嗎?或者另外新建一個module用來維護這些接口+數據結構?但是這樣一來,成本就有些大了,也不方便。

在微信的模塊化文章中提出了一個解決方法,將你要暴露的接口+數據結構甚至其他想要暴露的文件都.api化,即將你要暴露的文件的后綴名改為api,然后通過特定的方法將api后綴的文件拷貝出來,在module外部重新組成一個新的module(也可稱為SDK),而想要使用的模塊只需要調用這個SDK即可。當然,拷貝文件和組件SDK是完全自動化的,并非手工,這樣才能節省成本。

由于微信的模塊化文章中沒有涉及到.api化的具體實現,所以根據這種思路,我使用了其他方法來實現要達到的效果。具體思路如下:

  1. 創建一個名為_exports的文件夾,需要對外暴露的文件都放在里面
_exports文件夾
  1. _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')
}
  1. 將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'
            }
        }
    }
}
  1. 在需要的模塊調用jar
  compileOnly 'com.homeprint.module:auth-exports:1.0.0@jar'

注: 此處必須使用compileOnly來調用,compileOnlyprovided的替代方法,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工程的思路。

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太過中心化,并且一旦需要修改,會很麻煩,且容易產生沖突。

基于上述原因,我們可以自己搭建一個簡易的組件生命周期管理器,主要分為兩步:

  1. 構建組件的生命周期模型,構建的模型持有整個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();
}

在每個組件中只要實現上述模型即可。

  1. 有了生命周期模型,我們還需要一個管理器,用于管理這些組件的生命周期模型,在這個管理器中,我們同樣需要實現三個基礎方法:生命周期模型的注冊,生命周期模型的反注冊以及執行已存在的生命周期模型。
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工程及自動生成文件夾》

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容