Architecture(5)電商APP元件化探索

wustor發表於2018-01-22

概述

元件化緣由

記得剛開始接觸Android開發的時候,只知道MVC分層架構,而且感覺Model,View以及Controller太簡單了,也能稱之為分層架構,隨便寫就是MVC。就像在接觸設計模式之前,你可能已經寫了無數個單例模式,只是那個時候你可能並不知道,你已經在用設計模式了,你不會去想是用DCL還是使用內部類實現的單例優雅。

後來當一個類中的程式碼上千行之後,就開始想著抽取公共方法作為工具類,使用封裝、繼承以及多型來優化自己的程式碼,直到隨著業務的發展,在View層的邏輯越來越多,無法抽取時,發現MVC的天花板其實很低,Activity跟Fragment作為View層經常會跟Model層糾纏不清,及時進行抽取之後,也還是很臃腫。MVP的出現,徹底解決了這個問題,解耦Model層跟View層,使得整個專案的程式碼顯得更加簡潔。

在專案初期的的時候,感覺MVP還是很不錯的,當專案逐漸變大的時候,每次你改動了很小的一部分,你也需要重新編譯整個APP,舉個例子,就拿購物車來說,我修改了數量框的樣式,我需要重新編譯整個APP,為了加快速度,我可能要開啟InstantRun,可能要使用Freeline來加速編譯,這並不是我想要的,而且在使用InstantRun之後,output目錄下生成的apk是差量包,只能供開發除錯,給測試是無法安裝的,我要是想通過指令碼上傳到fir給測試人員,那又得打一個全量的包,並且InstantRun也不是很穩定。

元件化效果

毫無懸念,元件化勢在必行,在網上看了很多相關的資料,對元件化有一個初步的瞭解,然後就開始元件化了,下面1以我自己的專案為例,放兩張元件化之前跟之後的圖對比一下。

模組化VS元件化

可以明顯的發現我們的Module變多了,就像MVC切換到MVP之後,需要寫很多的Presenter元件化最大的好處就是可以模組可以單獨開發除錯,這樣效率一下子就上來了,還是拿購物車舉例,購物車實際上就只有一個介面,也就是一個Fragment,加上啟動頁跟Fragment的父Activity,也就兩個介面,可以說想慢都慢不下來,下面就我在元件化過程中遇到的一些問題進行總結一下。

正文

指導思想

元件拆分

元件化的目的在於將一個project劃分成業務元件、基礎元件、路由元件。其中業務元件是相互隔離的,可以單獨除錯,基礎元件提供業務元件所公用的功能,路由元件為業務元件之間通訊提供支援。

一般來講,一個APP可以由一個app殼,然後整合多個Module,這是理想的情況,但是從運營的需求到產品的設計到UI出圖,可能你就會對元件化很絕望,並不是那麼的理想,很多時候我們程式入口所在的Module實際上跟其它很多Module是關聯的,實際上沒法拆分,本文將會以這種比較複雜的情況進行元件化分析。

元件隔離

元件化的一個很大的特性在於可以單獨除錯,但是由於業務元件之間的隔離,所以導致了多個元件之間無法進行通訊,其實我覺得是很正常的,既然是單獨除錯,就必然不應該跟其它的Module間進行依賴,不管是編譯期還是執行期都應如此,不然元件化就沒有任何意義了,但是由於我們的業務元件都是相互關聯的,如果不依賴其他的元件的話,作為一個單獨的APP執行有時候是需要引數的,鑑於此,我們可以在Application初始化的時候,新增一個頁面作為引數配置,或者直接在Application中固定寫死。

核心法則

不管我們如何劃分,如何依賴,元件間的關係都要嚴格遵守一個準則:編譯器隔離,執行期按需依賴

整體架構

Component

通過元件化將專案按照業務進行化分成GoodsModuleCartModuleUserModuleOrderModule四個模組,模組間通過RouterModule進行通訊,也就是說業務元件依賴於路由元件,RouterModule依賴於Base,也就是BaseModuleLibraryModule,基礎庫跟第三方庫,然後MainModule實際上相當於程式的入口跟容器,通過MainModule依賴上述四個Module,完成整個APP的打包。

當然在單獨除錯的時候,GoodsModuleCartModuleUserModuleOrderModule又各自成為一個APP,可以單獨進行除錯,這樣就實現了APP的元件化,下面就元件化過程中遇到的一些問題總結一下。

元件化分析

在元件化的過程中,由於Module之間是隔離的,所以就產生了一系列問題,現在就元件化前後的遇到的問題總結如下:

  • 元件劃分:如何根據業務對專案進行Module劃分
  • 模式切換:如何使得APP在單獨除錯跟整體除錯自由切換
  • 資源衝突:當我們建立了多個Module的時候,如何解決相同資原始檔名合併的衝突
  • 依賴關係:多個Module之間如何引用一些共同的library以及工具類
  • 元件通訊:元件化之後,Module之間是相互隔離的,如何進行UI跳轉以及方法呼叫
  • 入口引數:我們知道元件之間是有聯絡的,所以在單獨除錯的時候如何拿到其它的Module傳遞過來的引數

接下來會根據這幾個問題,提出對應的解決方法

元件劃分

業務劃分

由於我們做的是一個電商專案,網上也查詢了很多資料,感覺他們舉的例子都有些過於簡單,因為模組間基本上沒有什麼耦合,所以很好拆分,不過還是很感謝他們提供了一種解決思路。玩過京東,淘寶都知道,大致分為幾個大的模組:商品模組,購物車模組,訂單模組,使用者模組。沒錯,我也是這麼拆分我們APP的。但是拆著拆著就發現問題了,模組間耦合性太高,我們過了SplashActivity之後就是MainActivity,看圖說話

home

所以網上的一些一進來就是一個空的APP殼的方法並不適用,從一開始就遇到了這個棘手的問題,有點尷尬,按照之前的模組劃分,在使用者登陸的情況下MainModule一進來就必須拿到GoodsModuleCartModule以及UserModule中的三個Fragment。所以首先必須得解決這個問題,很顯然之前的使用一個APP殼來合併多個Module的情況並不適用,起初我直接定義了一個MainModule,然後讓他直接引用多個Module,那麼MainModule就承擔了APP殼的功能,這樣一來,就可以解決MainModule對其它Module的引用問題,但是違背了元件化的業務元件隔離的原則。

所以不能讓MainModule依賴另外三個Module,但是如果我不引用其他的Module,那麼很顯然我無法拿到這四個Fragment的引用,有一點可以很明確,那就是編譯期業務Module之間必須不可見,這點是毫無疑問的。但是執行期是可見的,因為所有的Module在執行期間肯定都是通過直接或者間接依賴,不然有些Module就沒用了,在執行時獲取例項,那麼很自然地就會想到反射了,沒錯就是反射。

依賴劃分

除了業務模組之外,我們還會有一些公用的工具類以及資原始檔,也就是Base類,比如說多個Module共同使用的資原始檔,我們都可以放在一個Module裡面,另外就是還有第三方依賴,這裡我新建了兩個Module一個是BaseModule,一個是LibraryModule。整體關係如下

業務元件——>路由元件——>基礎元件
複製程式碼

模式切換

定義開關

切換的時候需要一個開關,來表示是單個Module間執行還是多個Module間執行,很容易想到是一個布boolean型別的標誌,可能你也想到了,在gradle.properties中來定義,網上好像都是這麼做的,實際上我們還可以在BaseModule以及LibraryModule定義,原因很簡單,只需要所有的Module中都能夠訪問就行了,只要遵循這個原則都是OK的,只是在gradle.properties中定義跟使用都比較方便。

isDebug=false//Debug還是Release
isModuleRun=true//是否單Module執行
複製程式碼

這裡我不僅僅定義了isModuleRun,還定義了isDebug,是不是感覺有些奇怪,不是可以通過BuildConfig.Debug來判斷當前是否是Debug模式麼,因為我們的url配置資訊都是寫在BaseModule中以便於所有的Module呼叫,他是一個Library,關於Library這裡還有一個問題注意下,由於Library的Module打包方式是使用release模式打包的,所以BuildConfig.Debug永遠是false,所以我們需要額外定義一個變數isDebug,然後手動在Debug跟Release中進行切換,然後在BaseModule的gradle中進行判斷

if (isDebug.toBoolean()) {
    //debug模式
    buildConfigField "String", "AlphaUrl", "\"${url["debug"]}\""

} else {
    //release模式
    buildConfigField "String", "AlphaUrl", "\"${url["release"]}\""

}
複製程式碼
使用開關
Application

isModuleRun為false的時候,Application跟AndroidManifest都是以Library的形式參與編譯,不需要啟動的Activity以及自定義的Application反之則需要。

isModuleRun=false

無序修改

<application
    android:allowBackup="true"
    android:supportsRtl="true"
    android:theme="@style/AppTheme">
 </application>

複製程式碼

isModuleRun=false

在main/debug目錄下新建一個AndroidManifest.xml檔案

<application
    android:name=".debug.GoodsApplication"
    android:allowBackup="true"
    android:label="@string/goods_name"
    android:supportsRtl="true"
    tools:replace="android:label"
    android:theme="@style/AppTheme">
    <activity android:name=".GoodsActivity">
        <intent-filter>
            <action android:name="android.intent.action.MAIN" />

            <category android:name="android.intent.category.LAUNCHER" />
        </intent-filter>
    </activity>
 </application>
複製程式碼

引用方式

在Module的gradle目錄下進行引用

修改外掛

if (isModuleRun.toBoolean()) {
    apply plugin: 'com.android.application'
} else {
    apply plugin: 'com.android.library'
}
複製程式碼

新增applicationId

if (isModuleRun.toBoolean()) {
    applicationId "com.wustor.cartmoudle"
}
複製程式碼

切換AndroidManifest檔案

sourceSets {
    main {
        if (isModuleRun.toBoolean()) {
            manifest.srcFile 'src/main/debug/AndroidManifest.xml'
        } else {
            manifest.srcFile 'src/main/AndroidManifest.xml'
            java {
                //全部Module一起編譯的時候剔除debug目錄
                exclude '**/debug/**'
            }
        }
    }
}
複製程式碼

資源衝突

假如我們在CartModule中定義了一個Application,然後在當前Module中的strings.xml中定義了app_name,同時在OrderModule中的strings.xml中也定義了這個app_name,那麼合併你的時候就會出現衝突,我們只可以通過將上述欄位分別改成cart_name跟order_name來解決這個問題,在嚴格的開發規範下,可以通過這種差異化命名來解決,因為不同的Module基本上資原始檔的名稱基本都不一樣,即時衝突也是少量的衝突,很容易解決。

當然除了這種方式之外可以在build.gradle中給資原始檔名新增字首

resourcePrefix "cart_"
複製程式碼

可以強行檢查,命名都需要價格字首,這樣反而違背了元件化的初衷,使得操作變麻煩了,不過感覺這種方式不是很有必要,當然有時候還可能出現圖片名字相同,這個其實可以還原到元件化之前的專案中分析,是不可能發生的事情,所以歸根到底還是沒有良好的開發規範跟開發習慣造成,沒必要為這種去做一些修改,畢竟約定大於配置

依賴配置

通過最開始的整體架構圖可以看出來,凡是能夠在Library跟Application之間進行切換的Module毫無疑問是需要依賴我們Base的兩個Module的,其實可以合併成一個Module,我這裡分了兩個,一個是BaseModule,一個是LibraryModule。下面通過build.gradle中的配置來梳理一下他們的依賴關係:

MainModule
dependencies {
    compile fileTree(include: ['*.jar'], dir: 'libs')
    compile project(':routermodule')
}
複製程式碼

編譯期間元件進行隔離,所以MainModule只依賴了RouterModule,剛才說的還有在執行期按需依賴,這裡是通過gradle的指令碼實現控制的

//編譯期元件隔離,執行期元件按需依賴
//mainModule需要跟cartModule,goodsModule,usersModule進行互動,所以在執行期新增了依賴
def tasks = project.gradle.startParameter.taskNames
for (String task : tasks) {
    def upperName = task.toUpperCase()
    if (upperName.contains("ASSEMBLE") || upperName.contains("INSTALL")) {
        dependencies.add("compile", project.project(':' + 'cartmodule'))
        dependencies.add("compile", project.project(':' + 'goodsmodule'))
        dependencies.add("compile", project.project(':' + 'usermodule'))
        dependencies.add("compile", project.project(':' + 'ordermodule'))
    }
}
複製程式碼
BusinessModule

這裡指的是Goods/Cart/User/OrderModule,其實是平行的

dependencies {
    compile fileTree(include: ['*.jar'], dir: 'libs')
    compile project(':routermodule')
}
複製程式碼

業務Module依賴於RouterModule

RouterModule
dependencies {
    compile fileTree(include: ['*.jar'], dir: 'libs')
    compile project(':modulelib')
    compile 'com.alibaba:arouter-api:1.2.1.1'
}
複製程式碼

RouterModule依賴了LibraryModule

BaseModule
dependencies {
    compile fileTree(include: ['*.jar'], dir: 'libs')
    testCompile 'junit:junit:4.12'
    compile project(':librarymodule')
}
複製程式碼

BaseModule作為一個基礎庫,依賴了LibraryModule

LibraryModule

這個作為最底層的勞苦大眾,實際上就是提供了一個依賴,所以就沒有什麼好依賴,只能自己跟自己玩兒。

所以到這裡的話,基本的依賴關係已經很清楚了,知道了整個架構圖,接下來進行施工也就很簡單了

元件通訊

其實在當初進行模組劃分的時候,是根據業務來的,所以當我們進入到一個模組之後,大部分邏輯應該還是在這個模組內進行處理的,但是偶爾還是會跟別的Module進行打交道,看一個介面

router

就拿GoodsModuleCartModule來說,這兩個Module是可以進行相互跳轉的,在GoodsModule的列表頁面點選購物車圖示可以進入到CartModule的購物車列表,購物車列表點選商品也可以進入GoodsModule的商品詳情頁。除了這個跳轉實際上還有變數的獲取,比如在首頁,我需要同時獲取到GoodsModule中的HomeFragment、SortFragment,CartModule中的CartFragment,UserModule中的MineFragment。我是在MainModule中直接依賴了四個業務Module,實際上可以不這樣,我們也可以使用Arouter來進行獲取Fragment的例項。

獲取例項

其實這裡的例項大多數情況下指的就是Fragment,下面以Fragment為例,別的例項如法炮製即可

  • 反射獲取

由於模組間是隔離的,所以我們沒辦法直接建立Fragment的例項,那麼這個時候其實很容易想到的就是反射,發射可謂無所不能,下面貼一下程式碼。

//獲取Fragment例項
public static Fragment getFragment(String className) {
    Fragment fragment;
    try {
        Class fragmentClass = Class.forName(className);
        fragment = (Fragment) fragmentClass.newInstance();
    } catch (Exception e) {
        throw new RuntimeException(e);
    }
    return fragment;
}
複製程式碼
  • Arouter

Arouter是阿里巴巴退出的一款路由框架,在元件中進行路由操作表方便,下面舉例說明

目標Fragment中加入註解

@Route(path = "cart/fragment")
public class CartFragement extends BaseFragment{
}
複製程式碼

在任何地方獲取例項

Fragmetn fragment = (Fragment) ARouter.getInstance().build("/cart/fragment").navigation();
複製程式碼
方法呼叫

在不同的Module之間都存在方法的呼叫,我們可以在每個Module裡面定義一個介面,並且實現這個介面,然後在需要呼叫的地方獲取到這個介面,然後進行方法呼叫即可。為了統一管理,我們把每個Module的介面都定義在RouterModule裡面,然後由於各個業務Module都依賴於這個RouteModule,然後只需要通過反射獲取到這個介面,進行方法呼叫就可以了。

ModuleService

ModuleCall

Module之間回撥的介面

public interface ModuleCall {
   //呼叫init方法可以傳遞Context引數
    void initContext(Context context);
}
複製程式碼

Service介面繼承自ModuleCall可以定義一些回撥方法供本身之外的其他Module進行呼叫

public interface AppService extends ModuleCall {
    //TODO 呼叫方法自定義
    void showHome();
    void finish();

}
複製程式碼

Impl實現類則是對應在每個Module中的具體回撥,是實現Service介面的直接子類

public class AppServiceImpl implements AppService {
    @Override
    public void showHome() {
    }
    @Override
    public void finish() {
    }
    @Override
    public void initContext(Context context) {
    }
}
複製程式碼

下面還是通過反射跟Arouter兩種方式進行說明

  • 反射呼叫

    public static Object getModuleCall(String name) {
        T t;
        try {
            Class aClass = Class.forName(name);
            t = (T) aClass.newInstance();
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
        return t;
    }
    複製程式碼

獲取介面

AppService appService = (AppService) ReflectUtils.getModuleCall(Path.APP_SERVICE);
複製程式碼

其實跟獲取Fragment例項一樣,通過類名來獲取對應的介面,然後呼叫對應的方法就行,有一點需要注意的就是,如果獲取的介面之後呼叫的方法需要傳入Context引數,那麼在呼叫介面方法之前必須先呼叫initContext方法才能使用傳入的Context,不然會報空指標異常。

  • Arouter

Arouter中有一個IProvider介面,如下

public interface IProvider {
    void init(Context var1);
}
複製程式碼

其實IProvider跟上面的ModuleCall是一樣的,只不過他在獲取到介面例項之後,就會呼叫initContext方法,其中的Context來自ARouter.init(this)中傳入的引數,不需要我們再手動呼叫initContext。

目標類中注入路徑

@Route(path = Path.APP_SERVICE)
public class AppServiceImpl implements AppService {
    private Context mContext;
    @Override
    public void showHome() {
        Log.d("go--->", "home--->");
    }

    @Override
    public void finish() {
    }

    @Override
    public void init(Context context) {
        mContext = context;
    }
}
複製程式碼

任意地方獲取目標類

AppService appService = (AppService) RouterUtils.navigation(Path.APP_SERVICE);
複製程式碼

然後呼叫方法即可

UI跳轉

跳轉基本上指的就是Activity之間的跳轉,廢話不多說,依舊是Arouter跟反射

  • 反射

    //將類名轉化為目標類
    public static void startActivityWithName(Context context, String name) {
        try {
            Class clazz = Class.forName(name);
            startActivity(context, clazz);
        } catch (ClassNotFoundException e) {
            throw new RuntimeException(e);
        }
    }
    //獲取Intent
    public static Intent getIntent(Context context, Class clazz) {
        return new Intent(context, clazz);
    }
    //啟動Activity
    public static void startActivity(Context context, Class clazz) {
        context.startActivity(getIntent(context, clazz));
    }
    複製程式碼
  • Arouter

    將目標Activity註冊到Arouter

    @Route(path = Path.CART_MOUDLE_CART)
    public class CartActivity extends BaseActivity<UselessPresenter, UselessBean> {
    }
    複製程式碼

    啟動目標Activity

     ARouter.getInstance().build(Path.CART_MOUDLE_CART).navigation()
    複製程式碼

入口引數

Application

當元件單獨執行的時候,每個Module自成一個APK,那麼就意味著會有多個Application,很顯然我們不願意重複寫這麼多程式碼,所以我們只需要定義一個ModuleApplication即可,其它的Application直接繼承此ModuleApplication就OK了,看一下結構圖:

ModuleApplication

實際上所有的邏輯都是在ModuleApplication中,業務Module分別有自己的子類,通過子類可以對Application做一些自己的定製化操作。

無參原因

之前在網上看到過攜程以及得到的元件化,他們從MainModule進入到別的Module貌似都是不需要傳引數的,所以不管是元件單獨除錯還是所有的Module一起遠行對於從ModuleA跳轉到ModuleB都是不需要傳參的。但是很多時候不同的Module間跳轉是需要傳參的,就拿購物車來說,我單獨除錯的時候是需要知道使用者的加密的userId,才能向伺服器請求資料,如果是多個Module一起執行,訪問購物車的時候,是可以從別的Module取到userId的,單獨除錯的時候就沒法獲取到,也就是入口的時候沒有引數對購物車進行初始化。

解決方式

因為當我們在元件化進行除錯的時候,我們每個Module在cartmodule/src/main/debug目錄下有自己的Application,對於入口引數比較簡單的情況,我們可以直接在Application中寫死,而對於一些比較複雜的或者動態的引數,我們可以繼續在此目錄下新疆一個Activity來配置我們單Module除錯所需要的引數,然後在整個專案進行編譯的時候剔除debug目錄下的檔案。

sourceSets {
    main {
        if (isModuleRun.toBoolean()) {
            manifest.srcFile 'src/main/debug/AndroidManifest.xml'
        } else {
            manifest.srcFile 'src/main/AndroidManifest.xml'
            java {
                //release 時 debug 目錄下檔案不需要合併到主工程
                exclude '**/debug/**'
            }
        }
    }
}
複製程式碼

總結

專案元件化執行了一段時間,通過劃分Module,單獨除錯,確實大大提升了開發效率,隨著使用的時間的推移,也在對元件化的理解也進一步加深,也在不斷地完善,下面幾點是在元件化過程中總結的一些經驗。

  • Module劃分:在劃分Module的時候沒必要劃分地太細,但是要嚴格按照業務來劃分,這樣單獨除錯對於習作開發才有意義。
  • Module隔離:業務Module之間應該是相互隔離不可見的,不能相互依賴,如果相互之間需要通訊,則必須經過路由轉發,便於統一管理。
  • 面向介面程式設計:不管是也業務Module還是BaseModule、LibraryModule以及RouterModule,在對外提供服務的時候儘可能的以介面的形式,不同的Module對外提供的服務介面應該都有一個共同的抽象父類,便於管理。
  • 防止迴圈依賴:迴圈依賴就是A依賴B,B依賴A,在執行期間動態新增依賴的時候,一定要考慮這個依賴是否被新增到專案中去了,所謂新增到專案中就是但凡被其它的Module進行依賴過就算新增進專案中,不然很容易造成迴圈依賴。

程式碼下載

相關文章