用生命週期規範元件化流程

兮塵發表於2019-03-08

寫在前面

demo 有空會在 github 上更新,歡迎關注。demo 怎麼做:一條commit對應一條規範。所以不會很快,可以先 star 收藏以便查閱。

本文是在我重構個人專案時光貓(歡迎體驗)的時候,邊元件化邊記錄下來的。期中踩了很多坑,也不斷思考優雅的解決方案。因為是個人專案,所以可以隨便浪,可以毫無保留地公佈細節,應該還是有指導意義的。

一開始我是按最流行的元件化方案來做,但是在重構的過程中發現,最流行的未必是最好的。比如流行方案裡會有 common 基礎庫,各個元件需要依賴這個庫。這樣實際上很糟糕。由於沒有明確 common 的職責,會慢慢導致臃腫,什麼程式碼都 feel 一下,感受感受,誒,下沉到 common 庫。這會極大影響元件的編譯時間。實際上,我剛元件化完成的時候,這種方案並沒有帶來很大的效率提升,甚至更慢了,除錯一次先等 10 分鐘,以前只要等 5 分鐘誒。雖然是五十步笑百步,但 10 分鐘真的難受。然後是元件可以切換獨立模式和庫模式這個設定。一開始很驚豔,但是用了後體驗很糟糕。原來我的專案裡有 butterknife,每次切換都要大改很麻煩,因為butterknife在元件庫模式和獨立模式下的用法不統一,獨立模式用R.id.xxx,庫模式用R2.id.xxx,侵入太大。還有很多做法,比如每個元件兩份 AndroidManifest.xml 、元件釋出為 aar 等等,有些是沒有和部落格中說的有實實在在的效率提升,有些根本沒有必要。最終我按我的想法,把元件化流程慢慢規範化,明確了元件化的內容和邊界,才有這篇文章。我的專案時光貓經歷元件化後,單個大元件(3w+行程式碼)全量編譯 ~10 分鐘,增量編譯(除錯一次)~1 分鐘,單個小元件(1w-行程式碼)全量編譯<3分鐘,增量編譯<30秒,滿足開發的效率需要。當然,秒編譯秒執行也是可以做得到的。如果有精力把基礎層更細化一點,編譯效率應該還可以提升。

用生命週期規範元件化流程

在實現元件化的過程中,同一個問題可能有不同的技術路徑可以解決,但是需要解決的問題主要有以下幾點:

  1. 元件除錯時獨立:每個元件都可以成為一個可釋出的整體,所以元件開發過程中要滿足單獨執行及除錯的要求,這樣還可以提升開發過程中專案的編譯速度。

  2. 元件間資料傳遞和函式呼叫:資料傳遞與元件間方法的相互呼叫,這也是上面我們提到的一個必須要解決的問題。

  3. 元件間介面跳轉:不同元件之間不僅會有資料的傳遞,也會有相互的頁面跳轉。在元件化開發過程中如何在不相互依賴的情況下實現互相跳轉?

  4. 元件間 UI 混合:主專案不直接訪問元件中具體類的情況下,如何獲取元件中 Fragment 的例項並將元件中的 Fragment 例項新增到主專案的介面中?View 呢?

  5. 元件的按需依賴:元件開發完成後,全部整合如何實現?還有就是在除錯階段,只想依賴幾個元件進行開發除錯時,如果實現?

  6. 元件隔離與程式碼隔離:元件解耦的目標以及如何實現程式碼隔離。不僅元件之間相互隔離,還有第五個問題中模組依賴元件時可以動態增刪元件,這樣就是模組不會對元件中特定的類進行操作,所以完全的隔絕模組對元件中類的使用會使解耦更加徹底,程式也更加健壯。

  7. 元件混淆與體積優化:元件化意味著重構,如果重構後還要加大量程式碼維持元件化,導致體積變大,豈不是要被打?

  8. 特殊元件標準化:明確定義某些固定的職責和邊界,以期望不用帶腦子就可以直接往這些特殊元件內丟程式碼,不用擔心變垃圾堆。

上面 8 個問題實際上並不能覆蓋所有情況,大大小小的坑很多,不成系統。

現在,我提議把它們放到元件化生命週期裡,初步構建一個成體系的元件化指導。

為什麼從生命週期這個角度切入?

首先,明確元件的生命週期可以減少開發過程中,由於需求變化帶來的隱形時間成本。比如老闆說要在現在的視訊 app 里加個直播功能,你是新建一個 git 分支,然後在原視訊元件裡寫,還是新建一個元件寫?ok,現在老闆腦子有坑,你剛把 demo 做出來,老闆說不做了?!你怎麼辦?得廢棄掉這個分支吧。ok,過了兩個月,視訊元件發展起來,以前的介面和現在的介面不一樣了,現在老闆突然說再加個直播吧。這時以前寫的 demo 還在,但是如果是直接在視訊元件裡寫的,現在不能用,修改過來絕對費事;而如果是新建元件寫的,直接像引入第三方庫一樣就行了。哪個簡單?

本文的定義裡,元件和第三方庫是類似的。

對於第三方庫,我們需要引入第三方庫使用第三方庫,覺得不好用會移除第三方庫。這些過程大多時候是無意識之間完成的,一般看文件做,文件寫得亂就栽了。而且通常情況下,移除第三方庫是沒有說明的。移除的時候不僅按引入第三方庫的說明,刪除掉引入部分,還要看專案在哪裡用了這個庫,刪掉這個庫會不會崩等等,很麻煩。

元件也要經歷類似的生命週期,不過元件的生命週期更簡單,也容易掌控,因為元件間隔離解耦。每個元件可以有自己的資料庫,可以有自己的架構等等,基本是一條龍服務,和重新寫個 app 區別不大(如果你的元件不能成為一個獨立的app,那這個元件也許沒有必要單獨做)。

其次,元件化不是一下子完成的,它需要漫長的時間。這時元件劃分尤為重要。什麼時候把這個功能拆出去?什麼時候合進來?什麼時候加新功能?怎麼加新功能?怎麼確保元件化的同時業務正常跑?怎麼不浪費時間在元件化上?怎麼確定這樣劃分元件是相對好的?問題在於,事物是不斷髮展的,現在的架構也許還能用,過一兩個月就可能成垃圾堆了(深有體會)。所以元件劃分是生命週期的重要組成部分。

面向生命週期的元件設計,需要明確元件建立,元件開發,元件釋出,元件移除,元件劃分,元件維護的一整套操作規範。下面將就這套體系一一詳細分析原因和給出解決方案。

1. 元件劃分

元件化和模組化相比,元件化更加細粒度,但是一定要把握元件劃分的度,不能太細,也不能太粗。

劃分太細會導致元件過多,維護困難(當然,人手多的話,實行維護者元件責任制可以完美解決,所以我們優先選擇細粒度劃分)

劃分太粗會導致編譯過慢,元件化形如虛設。

架構

分三層:宿主殼和除錯殼,元件層,基礎層。各層間依賴關係:

  • 宿主殼、除錯殼以 runtimeOnly 依賴所有元件,不得依賴基礎層。
  • 元件層間不得相互依賴。元件層按需依賴基礎層的各種庫。所有元件對基礎層的依賴都是按需依賴,必要時可以不依賴基礎層。
  • 基礎層間不得相互依賴。基礎層的 module 對於第三方依賴,最多可以使用 api 長依賴於第三方,不要用 implementation 的短依賴。(當然,自己寫的 module 以及一些特殊元件甚至可以什麼都不依賴)

時光貓部分架構如圖:

用生命週期規範元件化流程

各層包含的內容如下。

宿主殼、除錯殼

不能有一點兒 java 程式碼,僅在 build.gradle 裡新增對各個元件的依賴,AndroidManifest.xml裡宣告許可權,入口,變數,彙總 Activity。

宿主殼和除錯殼本質是一樣的,宿主殼等價於同時除錯所有元件的除錯殼。

元件層

每個元件都應該是可獨立執行的。這意味著元件內可以宣告新的資料結構和建立對應的資料表和資料庫。

沒有必要把 model 下沉到基礎層。如果需要共用同一個 model,那要麼劃分元件就不合理,要麼通過 ARouter 從別的元件獲取 fragment 的方式解決。

如果實在要把 model 下沉到基礎層,方便元件共享資料模型,那麼必須新建一個庫放在基礎層(命名為 CommonData)不允許放 CommonSDK 裡,因為要保證基礎層的庫都服從單一職責。

基礎層

儘量不要下沉程式碼到基礎層! 儘量不要下沉程式碼到基礎層! 儘量不要下沉程式碼到基礎層!

基礎層的設定就是為了讓元件放心地依賴基礎層,放心地複用基礎層的程式碼,以達到高效開發的目的。 所以,不要讓基礎層成為你的程式碼垃圾桶。對基礎層的要求有兩個,對內和對外。 對外,命名要秒懂。這樣在寫元件的業務程式碼的時候,多想一下基礎層裡的程式碼,複用比造輪子更重要。 對內,要分類清晰。不要讓某個基礎層的庫裡堆積了大量xxUtils.java這種垃圾程式碼。不是說不寫,而是說少寫,以及分類清晰地寫。推薦AndroidUtilCode,同時可以學習一下Util是怎麼分類的。其次,下沉到基礎層的程式碼,其他元件不一定想用,所以在下沉程式碼之前,先經過 code review。

基礎層包括自己的 CommonSDK、自己的 CommonBase、CommonResource、第三方 SDK,第三方 UI,第三方程式碼級庫。

  1. CommonSDK

    • 你的 Utils
    • Events實體
  2. CommonBase

    • 你的自寫 widget
    • BaseActivity, BaseFragment等
    • base_layout
    • res:base_style.xml
  3. CommonData 這是基礎層特殊庫,含

    • 共享資料庫
    • ARouter路由表
  4. CommonResource

    • 資源
  5. 第三方 SDK科大訊飛 SDK騰訊 bugly SDKBmob SDK 等解決方案專業化、需要註冊申請才能使用的 SDK。 這部分的依賴放在基礎層。注意:

    1. 要根據元件配置動態依賴,減少編譯時間。因為不是所有元件都全部用到了所有的 SDK,真實情況是元件用到的只是部分 SDK。
    2. 對每個第三方 SDK 必須新建一個 module 來包含住這個 SDK。元件按需依賴。
  6. 第三方程式碼級庫ButterKnifeDaggerARouterGliderxjavaArmsMVP等註解、生成程式碼、定義了一種程式碼風格的庫。 這部分的依賴放在 CommonSDK。這意味著每個元件都會依賴所有的 第三方程式碼級庫。具體有哪些程式碼級庫在下面列出:

    • ButterKnife:繫結view
    • Dagger:物件注入
    • ARouter:跨元件路由框架
    • Glide:圖片載入框架
    • rxjavarxAndroid:非同步
    • ArmsMVP:上面的庫都在這裡整合,且這個庫還定義了一套 MVP 規範,有模板支援一鍵生成 ArmsMVP 風格的 MVP,不用手動建立各個檔案了。
  7. 第三方 UISmartRefreshLayoutQMUIAndroid-skin-support 等 UI widget 庫。 這部分的依賴放在自己的 CommonBase。 對比較大的庫,優先考慮放在元件內依賴,在下沉到 CommonBase 前要三思,不要讓其他元件被動附加不必要的編譯時間。

  8. 其他第三方 library, 按庫大小優先考慮放在元件內依賴,儘量不要下沉到基礎層。

MVC、MVP、MVVM 如何下沉

均最多隻能下沉 M 到基礎層。MVC 裡的 VCMVP 裡的 VPMVVM裡的 VVM 均只能放元件層。

為什麼?

M 是資料層,如果為了元件間共享資料不擇手段,那就下沉 M。 注意,不建議所有的 M 都下沉到基礎層。有兩個原因:

  1. M 隨便下沉會導致基礎層臃腫,其他元件被迫編譯,被迫增加時間成本
  2. M 放元件內部更容易維護

VCPVM 都是業務,業務應該留在元件內。

時刻記住,只有足夠優秀的程式碼才能下沉到基礎層。

Utils 規範:使用 Kotlin

為什麼是 Kotlin?因為 Kotlin 方便擴充套件某一類 util(使用擴充套件函式)。

強制:必須註釋!!!

建議:放在基礎層的 CommonSDK 裡。(也可以獨立成為一個 module,但建議放在 CommonSDK)

靜態方法

原來:

//宣告
final class Utils {
    public static boolean foo() {
        return false;
    }
}
//使用
final boolean test = Utils.foo();
複製程式碼

轉化後:

//定義
object Utils {
    @JvmStatic
    fun foo(): Boolean = true
}
// Kotlin 裡使用
val test = Utils.foo()
// Java 裡使用
final boolean test = Utils.foo()
複製程式碼

單例模式

請使用懶漢式,因為 Utils 在使用時再初始化,防止在沒有用到的時候影響效能。

  1. 餓漢式

Kotlin 引入了 object 型別,可以很容易宣告單例模式。

物件宣告就是單例了。object DataProviderManager就可以理解為建立了類DataProviderManager並且實現了單例模式。

object Singleton {
    ...
}

// Kotlin 中呼叫
Singleton.xx()

// Java 中呼叫
Singleton.INSTANCE.xx()
複製程式碼

這種方式和 Java 單例模式的餓漢式一樣,不過比 Java 中的實現程式碼量少很多,其實是個語法糖。反編譯生成的 class 檔案後如下:

public final class Singleton {
    public static final Singleton INSTANCE = null;

    static {
        Singleton singleton = new Singleton();
    }

    private Singleton() {
        INSTANCE = this;
    }
}
複製程式碼
  1. 懶漢式

前面的 object 的實現方式是餓漢式的,開始使用前就例項化好了,如何在第一次呼叫時在初始化呢?Kotlin 中的延遲屬性 Lazy 剛好適合這種場景。

// kotlin 宣告
class Singleton private constructor() {
    companion object {
        val instance: Singleton by lazy { Singleton() }
    }
}

// Kotlin 中呼叫
Singleton.instance.xx()

// Java 中呼叫
Singleton.Companion.getInstance().xx()
複製程式碼

res 規範:命名清晰

全部:

  • 使用外掛 Android File Grouping Plugin 它可以在不改變檔案目錄結構的情況下,將檔案按名稱進行分組。
    • 分組規則,按名稱中的下劃線”_”作為分隔符,將下劃線前面的做作為一組
    • 分組不會移動檔案
    • 分組也不會實際建立目錄

元件內的 res:

  • resourcePrefix "元件名_" //給 Module 內的資源名增加字首, 避免資源名衝突

基礎層內的 res:

  • resourcePrefix "base_" //元件也想用base_的話,可以用元件名_base_

下面對元件範圍內的 res 進行規範

string.xml

string分類

  • 標記型string_short.xml:如comfirmcancelokdeleteapp_name等短小精悍的string
  • 長篇大論幫助文件型string_long.xml:如 faq
  • 格式化型string_format.xml:如全國排名第 %d 名
  • 字串陣列array_string.xml
  • 區分單複數的數量字串Quantity Strings (Plurals)
  • 魔法字串string_important.xml:如這哥們把女神名字當base signal,混進使用者編輯的文字裡存資料庫了,不能翻譯,不要亂動。動就是兩開花。

注意: 0. 專案不大,建議先別翻譯

  1. 展示給使用者的才翻譯,其餘不要翻譯
  2. 元件中的 string 資源下沉到基礎層,如果暫時不想分類放好,可以新建個string_<元件名>.xml。但是不推薦這麼做,因為遲早要變垃圾桶。

以上歡迎補充。

asset

那個元件用到就放哪個元件的 asset/<元件名> 資料夾裡避免衝突,不要集中下沉到基礎層(除非是基礎層自己的 asset)。

apk 編譯時會自動 merge。

特殊元件

由規範 5(只依賴部分元件)和規範 6(元件隔離、程式碼隔離)知,不能隨便把程式碼下沉到基礎層。

但是元件間的共享是硬需求,每次下沉前都要考慮一番不符合敏捷開發的原則。

有沒有一個解決方案,可以不用帶腦子就直接下沉,實現共享呢?

一個簡單易操作的方法是定義特殊元件,只要符合特殊元件要求的,就可以直接下沉。

CommonResource

CommonResource 包含且僅包含以下型別的資源

  • drawable-xxx, mipmap-xxx
  • color
  • anim
  • integer, bool
  • raw, asset:打包時不會壓縮這裡的檔案,也不會校驗是否完整,也就是說會 100% 打包進 APK 裡。
  • string

注意:

  • 上面其實只有一句話:除了attr.xml<declare-styleable.../>外所有資源。 因為<declare-styleable.../>是與widget和style繫結的,所以在定義widget的元件裡放<declare-styleable.../>,在CommonResource裡放style級的<declare-styleable.../>
  • 在向量圖裡使用了主題顏色的,考慮好style要一起隨之下沉。例如下面的 android:fillColor="?icon_color" 其中?icon_color是在主題裡定義的。 這裡需要帶點腦子... 可能會拋錯Drawable com.time.cat:drawable/ic_notes has unresolved theme attributes! Consider using Resources.getDrawable(int, Theme) or Context.getDrawable(int). 解決:https://stackoverflow.com/questions/9469174/set-theme-for-a-fragment最後發現是Android-skin-support這個庫的內部衝突,砍!
<vector xmlns:android="http://schemas.android.com/apk/res/android"
        android:width="24dp"
        android:height="24dp"
        android:viewportHeight="24.0"
        android:viewportWidth="24.0">
    <path
        android:fillColor="?icon_color"
        android:pathData="M3,18h18v-2H3v2zm0,-5h18v-2H3v2zm0,-7v2h18V6H3z"/>
</vector>
複製程式碼

CommonData

資料模型共享庫。

  • 你用greendao,這個庫就直接依賴greendao,生成的資料模型類統一管理
  • 你用bmob(一個雲資料庫saas),這個庫就直接依賴它
  • 你用Room,這個庫就直接依賴Room
  • ...

就是資料模型相關的,都在這裡定義。

優點:元件間共享資料方便 缺點:一個元件依賴了這個庫,但它只想用其中一個資料庫,於是它被迫編譯其他資料,造成編譯過慢。

推薦做法:CommonData還是要有的,但是不能浪,只能放多個元件共享的資料模型,比如使用者模型,既用於登入元件,也用於賬戶元件,還用於活動元件。如果是隻有一個元件使用這個資料庫,那請在元件內定義和使用這個資料庫,不要下沉到 CommonData。

CommonBase

Base 庫,要求極致的複用。含

  • java : BaseFragment.javaBaseActivity.javaBasePresenter.java等等
  • kotlin : extention(也可以BaseFragment.ktBaseActivity.ktBasePresenter.kt等等)
  • res > layout : base_toolbar.xmlbase_progressbar.xml等等

這裡我提倡“過度封裝”,越多Base對以後效率提升越多。在我的專案中

base_rv.xml
base_rv_stateful.xml
base_rv_stateful_trans.xml
base_refresh_rv.xml
base_refresh_rv_stateful.xml
base_refresh_rv_stateful_trans.xml
base_toolbar_refresh_rv.xml
base_toolbar_refresh_rv_stateful.xml
base_toolbar_refresh_rv_stateful_trans.xml

BaseFragment.java
BaseSupportFragment.java
BaseAdapterSupportFragment.java
BaseRefreshAdapterSupportFragment.java
BaseToolBarFragment.java
BaseToolBarRefreshAdapterSupportFragment.java
BaseToolbarAdapterSupportFragment.java
BaseToolbarSupportFragment.java
複製程式碼

PS:別問我為什麼用這種方式,因為簡單,清晰,別人易接手。

CommonSDK

自己的SDK和第三方SDK。含

  • ArmsMVP(rx系列,retrofit系列等程式碼風格級的庫)
  • bugly
  • xxUtils.kt
  • log日誌工具
  • toast等提示工具
  • 打點上報工具
  • SharePreference,MMKV等key-value儲存工具
  • RouterHub(ARouter的路由表)

注意:

  • 第三方庫提供了單例,可以直接呼叫時,一定不要直接用。 以 ARouter 為例。ARouter提供了 ARouter.getInstance()...的用法,請不要偷懶,要自己再包一層:
public class NAV {
    public static void go(String path) {
        ARouter.getInstance().build(path).navigation();
    }

    public static void go(String path, Bundle bundle) {
        ARouter.getInstance().build(path).with(bundle).navigation();
    }

    public static void go(String path, int resultCode) {
        ARouter.getInstance().build(path).withFlags(resultCode).navigation();
    }

    public static void go(String path, Bundle bundle, int resultCode) {
        ARouter.getInstance().build(path).with(bundle).withFlags(resultCode).navigation();
    }

    public static void go(Context context, String path) {
        ARouter.getInstance().build(path).navigation(context);
    }

    public static void go(Context context, String path, Bundle bundle) {
        ARouter.getInstance().build(path).with(bundle).navigation(context);
    }

    public static void go(Context context, String path, int resultCode) {
        ARouter.getInstance().build(path).withFlags(resultCode).navigation(context);
    }

    public static void go(Context context, String path, Bundle bundle, int resultCode) {
        ARouter.getInstance().build(path).with(bundle).withFlags(resultCode).navigation(context);
    }

    public static void go(Context context, String path, int enterAnim, int exitAnim) {
        ARouter.getInstance().build(path)
                .withTransition(enterAnim, exitAnim).navigation(context);
    }

    public static void go(Context context, String path, Bundle bundle, int enterAnim, int exitAnim) {
        ARouter.getInstance().build(path)
                .with(bundle)
                .withTransition(enterAnim, exitAnim).navigation(context);
    }

    public static void go(Context context, String path, int resultCode, int enterAnim, int exitAnim) {
        ARouter.getInstance().build(path)
                .withFlags(resultCode)
                .withTransition(enterAnim, exitAnim).navigation(context);
    }

    public static void go(Context context, String path, Bundle bundle, int resultCode, int enterAnim, int exitAnim) {
        ARouter.getInstance().build(path)
                .with(bundle).withFlags(resultCode)
                .withTransition(enterAnim, exitAnim).navigation(context);
    }
    ...
}
複製程式碼

再以 EventBus 為例。EventBus提供了 EventBus.getDefault()...的用法,請不要偷懶,要自己再包一層:

public class MyEventBus {
    public static void post(Object obj) {
        EventBus.getDefault().post(obj);
    }
    ...
}
複製程式碼

類似地可以給 MMKV 等 key-value 庫套一層。

為什麼要多套一層?

因為以後這些第三方庫隨時可能會被換掉。比如EventBus,如果要換帶 tag 的 AndroidEventBus,侵入性極大。而用自家定義的 MyEventBus ,在呼叫函式方面影響小一點(但不是沒有影響,因為註解之類的還要改,只是讓你改的地方少一點)。

2. 元件建立

建立一個元件,有兩個來源。一是拆分而來建立的新元件,二是為新功能建立一個新元件。

從原有程式碼中拆分出的新元件

有這些場景:

  1. 以前是所有程式碼都寫在 app/ 裡一把梭的話,首先要把裡面程式碼全部拉出來新建一個元件,然後再以元件劃分的規範逐步處理。
  2. 以前是元件劃分不規範,現在重新劃分出新元件。
  3. 以前是元件下沉不規範,現在從基礎層重新認領程式碼,形成新元件。

所以對應有三種情況,

  1. 舊程式碼在殼裡apply plugin: 'com.android.application'
  2. 舊程式碼在元件層裡apply plugin: 'com.android.library'
  3. 舊程式碼在基礎層裡apply plugin: 'com.android.library'

這 3 種情況按元件劃分的規範處理舊程式碼。然後按下面為新功能建立新元件的規範新建元件。

為新功能建立新元件

要明確兩個部分:新元件功能是什麼?新元件用什麼架構?

新功能的確定

首先,新元件功能不能與已有的元件功能100%相同,否則這不是新功能。 其次,新元件功能可以與已有元件功能互動,但不能有重合。 最後,新元件功能的劃分要保持粒度一致。一個元件一個 Activity 是允許的,只要劃分的粒度和其他元件的粒度保持一致。即,類比於化學,劃分到元素,就氫,氦,鋰,鈹,硼這樣劃,不要劃什麼電子,也不要劃什麼氧分子,粒度要保持在元素這一層。然後,像這種一個 Activity 的輕量級元件,推薦釋出為 aar。

新元件架構的確定

常用有三種架構:MVC、MVP、MVVM。按需求選擇即可,元件間獨立,架構不一樣是允許的。

如果是前端過來的同學,推薦 MVVM,如果是傳統 Android 開發,則推薦 MVP。

新元件架構的建立傻瓜化

使用模板或 AS 外掛實現。

這裡推薦 JessYanCoding 的 MVPArms 框架,結合 ArmsComponentMVPArms 一系提供了 ArmsComponent-Template (Module 級一鍵模板) 一鍵搭建整體元件架構, MVPArmsTemplate (頁面級一鍵模板) 一鍵生成每個業務頁面所需要的 MVP 及 Dagger2 相關類。通過模板工具的方式統一架構,方便快捷。新建過程不用過多浪費時間在架構的維護上,按著 MVP 莽就是了~

目前我沒發現有提供模板工具的其他架構,但也許有了我沒發現也說不定?歡迎補充!

PS:官方 MVVM 架構應該可以整個模板出來...

PPS:JessYanCoding 的 MVPArms 框架整合了常用第三方庫,牆裂推薦!

3. 元件開發

元件除錯時獨立

!!!禁止切換為獨立模式!元件只允許庫模式

現在元件化很流行的做法是把元件劃分為庫模式和獨立模式,在開發時使用獨立模式,在釋出時使用庫模式。比如說,gradle.properties中定義一個常量值 isPlugin(是否是獨立模式,true為是,false為否)然後在各個元件的build.gradle中這麼寫:

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

但是,這沒必要。

至少有四點原因,足以使你放棄切換這種模式。

  1. 可靠性問題。注意開發時和釋出時開發者面對的元件其實是不一樣的。多個元件一起釋出打包時經常出現依賴衝突等毛病,和開發時不一樣,浪費時間。
  2. 切換代價。要同時除錯兩個元件之間的通訊,往往要頻繁切換庫模式和獨立模式,每次都Gradle Sync一次,代價極大。
  3. 第三方庫。比如ButterKnife,切換元件成庫模式和獨立模式的過程中,ButterKnife可能導致切換失敗,因為庫模式用R2和獨立模式用R,侵入性極大。
  4. 維護代價。由於有兩個模式,需要維護兩份AndroidManifest.xml,裡面細節差異很大。

設立這兩種模式的初衷在於元件解耦和敏捷開發,這就是一句笑話。

為解決這個問題,制定規範如下:

  • 元件只允許庫模式

  • 在庫模式的基礎上套一個空殼,以間接實現元件的獨立模式。

  • 第三方庫統一按庫模式來。元件中ButterKnife統一使用 R2,不得使用R。原來使用R的,請ctrl+shift+R全域性替換,用正規表示式替換成 R2

  • 殼只由兩個個檔案構成。build.gradleAndroidManifest.xml

    改下build.gradle宣告塞進哪些元件,改AndroidManifest.xml宣告入口Activity(其他Activity的宣告只要在元件內宣告,Build 時會合並進去)

    事實上,殼可以寫個模板資料夾,需要的時候複製貼上即可。

    可以在build.gradle裡宣告AndroidManifest.xml放在同一級目錄下,不用建立多餘的資料夾。

好處:

  1. 可靠性問題。開發者面對的只有庫模式,最終打包的也是庫模式的元件。(只用apply plugin: 'com.android.library'
  2. 切換代價。用套殼的方式間接實現獨立模式來除錯元件,極為靈活。
    • 可以一個元件一個殼,兩個元件一個殼,想除錯多少元件就把它們都塞進一個殼。
    • 切換殼的過程不需要 Gradle Sync,直接指定哪個殼直接 Build 就行
  3. ButterKnife。老專案肯定廣泛使用了ButterKnife,在元件化時,一次修改,永久有效(只需R改成R2),不像前面切換兩個模式時,需要同時切換RR2
  4. 維護代價。只用維護庫模式的元件。空殼隨意維護一下就行,反正沒有java或kotlin之類的程式碼。

壞處:

  1. 每個殼都需要磁碟空間來 build,所以這種做法本質上是用空間換取時間。
  2. 暫時沒發現其他壞處,有待觀察。

多元件除錯

因為元件只允許庫模式,所以需要建立空殼,間接實現獨立模式,以供除錯。

殼只由兩個個檔案構成。build.gradleAndroidManifest.xmlbuild.gradle宣告塞進哪些元件,AndroidManifest.xml宣告入口Activity(其他Activity的宣告只要在元件內宣告,Build 時會合並進去)

事實上,殼可以寫個模板資料夾,需要的時候複製貼上即可。可以在build.gradle裡宣告AndroidManifest.xml放在同一級目錄下,不用建立多餘的資料夾。

可以一個元件一個殼,兩個元件一個殼,想除錯多少元件就把它們都塞進一個殼。切換殼的過程不需要 Gradle Sync,直接指定哪個殼直接 Build 就行

元件開發

元件是完整又獨立的個體,元件內允許建自己的資料庫,不必下沉到基礎層。

儘量複用基礎層,比如繼承 Base 系列,佈局 include base 系列的 layout 等等。

允許 implementation 的形式引入元件自己的依賴,注意使用 gradle dependencies 檢視依賴樹,確定是否和其他元件依賴衝突。

允許多個架構共存。比如這個元件 MVC,那個元件 MVP,那個元件 MVVM。不過還是建議統一一下。我目前個人專案裡這 3 種元件都有,同時維護,好像也不費勁...不過公司的一般有人事變動,統一架構更有利於溝通。

元件內的生命週期注入:元件需要在 Application 建立時初始化資料庫等,如何統一注入到 Application 的生命週期裡呢? 還是推薦 MVPArms,支援 Application、Activity、Fragment的生命週期注入。當然也可以自己實現:

public class GlobalConfiguration implements ConfigModule {
    @Override
    public void applyOptions(Context context, GlobalConfigModule.Builder builder) {
     //使用 builder 可以為框架配置一些配置資訊
    }

    @Override
    public void injectAppLifecycle(Context context, List<AppLifecycles> lifecycles) {
     //向 Application的 生命週期中注入一些自定義邏輯
    }

    @Override
    public void injectActivityLifecycle(Context context, List<Application.ActivityLifecycleCallbacks> lifecycles) {
    //向 Activity 的生命週期中注入一些自定義邏輯
    }

    @Override
    public void injectFragmentLifecycle(Context context, List<FragmentManager.FragmentLifecycleCallbacks> lifecycles) {
    //向 Fragment 的生命週期中注入一些自定義邏輯
    }
}
複製程式碼

這裡的注入實現了部分的 AOP(面向切面程式設計)。

通過注入生命週期,可以實現更多優雅的解決方案,如:

我一行程式碼都不寫實現Toolbar!你卻還在封裝BaseActivity?

元件耦合

元件間不得相互依賴,應該相對獨立。

元件間的通訊必須考慮沒有迴應的情況。也就是說,單獨除錯元件時,即使訪問了另一個不存在的元件,也不能崩。

只要處理好沒有迴應的情況,就把握住了元件的邊界,輕鬆解耦。

元件間資料傳遞

使用事件匯流排。預設用 greendao 的 EventBus,對 Event 的管理使用分包的方式。

  • 如果只在元件中使用的 Event,則 Event 類放在元件的 core/event 目錄下統一管理
  • 如果是跨元件的 Event,則 Event 類放在基礎層 CommonSDK 裡。
  • 推薦 EventBus 的事件跳轉外掛:EventBus事件匯流排專用的事件導航

AndroidEventBus 與 EventBus、otto的特性對比:要效率就用 greendao 的 EventBus,要對事件分類清晰管理就用 AndroidEventBus,不建議用 otto。

名稱 訂閱函式是否可執行在其他執行緒 特點
greenrobot的EventBus 使用name pattern模式,效率高,但使用不方便。
square的otto 使用註解,使用方便,但效率比不了EventBus。
AndroidEventBus 使用註解,使用方便,但效率比不上EventBus。訂閱函式支援tag(類似廣播接收器的Action)使得事件的投遞更加準確,能適應更多使用場景。

元件間函式呼叫

將被呼叫函式宣告到一個介面裡,稱為服務。

用 ARouter 實現。

  • HelloService 放在基礎層 CommonSDK 裡,裡面函式的輸入輸出資料型別支援基本資料型別和 CommonSDK 裡的任意類,但不要支援元件裡的,因為不同兩個元件裡的類宣告一定不同。
  • HelloServiceImpl放在有需要的地方,通常是元件裡。特殊服務的實現,比如 json 序列化服務、降級策略等,放基礎層 CommonSDK 裡。
  1. 暴露服務(宣告服務介面,實現服務介面)
// 宣告介面,其他元件通過介面來呼叫服務
public interface HelloService extends IProvider {
    String sayHello(String name);
}

// 實現介面
@Route(path = "/yourservicegroupname/hello", name = "測試服務")
public class HelloServiceImpl implements HelloService {

    @Override
    public String sayHello(String name) {
    return "hello, " + name;
    }

    @Override
    public void init(Context context) {

    }
}
複製程式碼
  1. 發現服務,用 @Autowired(使用服務)
public class Test {
    @Autowired
    HelloService helloService;

    @Autowired(name = "/yourservicegroupname/hello")
    HelloService helloService2;

    HelloService helloService3;

    HelloService helloService4;

    public Test() {
    ARouter.getInstance().inject(this);
    }

    public void testService() {
        if (helloService == null) return;//!!!元件中使用服務時一定要判空,因為元件之間獨立,服務不一定獲取到。

        // 1. (推薦)使用依賴注入的方式發現服務,通過註解標註欄位,即可使用,無需主動獲取
        // Autowired註解中標註name之後,將會使用byName的方式注入對應的欄位,不設定name屬性,會預設使用byType的方式發現服務(當同一介面有多個實現的時候,必須使用byName的方式發現服務)
        helloService.sayHello("Vergil");
        helloService2.sayHello("Vergil");

        // 2. 使用依賴查詢的方式發現服務,主動去發現服務並使用,下面兩種方式分別是byName和byType
        helloService3 = ARouter.getInstance().navigation(HelloService.class);
        helloService4 = (HelloService) ARouter.getInstance().build("/yourservicegroupname/hello").navigation();
        helloService3.sayHello("Vergil");
        helloService4.sayHello("Vergil");
    }
}
複製程式碼

以上服務的定義、實現、使用,和c語言中的函式有異曲同工之妙。c語言裡也是先宣告函式,再實現函式,最後才能呼叫實現好的函式。

元件間介面跳轉

用 ARouter 實現。

ARouter 有個特點,就是 path 下的類放哪裡都行,只需要在基礎層的 CommonSDK 中宣告 path,其他元件就能用 path 跳轉,不用關心 path 下的類的具體內容。

構建標準的路由請求
// 1. 基礎
ARouter.getInstance().build("/home/main").navigation();

// 2. 指定分組
ARouter.getInstance().build("/home/main", "ap").navigation();

// 3. Uri 直接解析
Uri uri;
ARouter.getInstance().build(uri).navigation();

// 4. startActivityForResult
// navigation的第一個引數必須是Activity,第二個引數則是RequestCode
ARouter.getInstance().build("/home/main", "ap").navigation(this, 5);

// 5. 直接傳遞Bundle
Bundle params = new Bundle();
ARouter.getInstance()
    .build("/home/main")
    .with(params)
    .navigation();

// 6. 指定Flag
ARouter.getInstance()
    .build("/home/main")
    .withFlags();
    .navigation();


// 7. 傳遞物件
ARouter.getInstance()
    .withObject("key", new TestObj("Jack", "Rose"))
    .navigation();
// 覺得介面不夠多,可以直接拿出Bundle賦值
ARouter.getInstance()
        .build("/home/main")
        .getExtra();

// 8. 轉場動畫(常規方式)
ARouter.getInstance()
    .build("/test/activity2")
    .withTransition(R.anim.slide_in_bottom, R.anim.slide_out_bottom)
    .navigation(this);

// 9. 轉場動畫(API16+)
ActivityOptionsCompat compat = ActivityOptionsCompat
    .makeScaleUpAnimation(v, v.getWidth() / 2, v.getHeight() / 2, 0, 0);
    // 注:makeSceneTransitionAnimation 使用共享元素的時候,需要在navigation方法中傳入當前Activity
ARouter.getInstance()
    .build("/test/activity2")
    .withOptionsCompat(compat)
    .navigation();
複製程式碼
處理路由請求

攔截、綠色通道、降級、外部喚起。

// 1. 攔截(比如登陸檢查)
// 攔截器會在跳轉之間執行,多個攔截器會按優先順序順序依次執行
// priority 低的優先
@Interceptor(priority = 8, name = "測試用攔截器")
public class TestInterceptor implements IInterceptor {
    @Override
    public void process(Postcard postcard, InterceptorCallback callback) {
       ...
       callback.onContinue(postcard);  // 處理完成,交還控制權
       // callback.onInterrupt(new RuntimeException("我覺得有點異常"));      // 覺得有問題,中斷路由流程

       // 以上兩種至少需要呼叫其中一種,否則不會繼續路由
    }

    @Override
    public void init(Context context) {
        // 攔截器的初始化,會在sdk初始化的時候呼叫該方法,僅會呼叫一次
    }
}
// 處理跳轉結果,看是否被攔截了
// 使用兩個引數的navigation方法,可以獲取單次跳轉的結果
ARouter.getInstance().build("/test/1").navigation(this, new NavigationCallback() {
    @Override
    public void onFound(Postcard postcard) {
    ...
    }

    @Override
    public void onLost(Postcard postcard) {
    ...
    }
});

// 2. 綠色通道(跳過所有的攔截器)
ARouter.getInstance().build("/home/main").greenChannel().navigation();

// 3. 降級(沒有攔截,正常跳轉,但是目的 path 丟失)
// 自定義全域性降級策略
// 實現DegradeService介面,並加上一個Path內容任意的註解即可
@Route(path = "/xxx/xxx")
public class DegradeServiceImpl implements DegradeService {
    @Override
    public void onLost(Context context, Postcard postcard) {
        // do something.
    }

    @Override
    public void init(Context context) {

    }
}

// 4. 外部喚起(通過 URL 跳轉)
// 新建一個Activity用於監聽Schame事件,之後直接把url傳遞給ARouter即可
public class SchameFilterActivity extends Activity {
    @Override
    protected void onCreate(Bundle savedInstanceState) {
       super.onCreate(savedInstanceState);

       Uri uri = getIntent().getData();
       ARouter.getInstance().build(uri).navigation();
       finish();
    }
}
// AndroidManifest.xml
<activity android:name=".activity.SchameFilterActivity">
    <!-- Schame -->
    <intent-filter>
        <data
        android:host="m.aliyun.com"
        android:scheme="arouter"/>

        <action android:name="android.intent.action.VIEW"/>

        <category android:name="android.intent.category.DEFAULT"/>
        <category android:name="android.intent.category.BROWSABLE"/>
    </intent-filter>
</activity>

複製程式碼
ARouter 搞不定的情況

Notification、小部件等需要給 RemoteView 構建 PendingIntent 的場景。

用中轉 Activity 曲線救國。

路由 pathaction 傳給中轉 Activity中轉Activity 再根據 action 開啟對應的 Activity

中轉Activity裡不需要 setContentView,跳完就直接 finish()。

如下:

RouterHub = {
    MainActivity = "/app/main",
    OtherActivity = "/app/other",
}
public class RouterActivity extends AppCompatActivity {

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        Intent intent = getIntent();
        if (RouterHub.MainActivity.equals(intent.getAction())) {
            ARouter.getInstance().build(RouterHub.MASTER_MainActivity).navigation(this);
            finish();
        } else if (RouterHub.OtherActivity.equals(intent.getAction())) {
            ARouter.getInstance().build(RouterHub.MASTER_InfoOperationActivity).navigation(this);
            finish();
        }
        finish();
    }
}

// 使用
Intent intent2Add = new Intent(context, RouterActivity.class);
intent2Add.setAction(RouterHub.MASTER_InfoOperationActivity);
intent2Add.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_NEW_TASK);
PendingIntent pendingIntent2Add = PendingIntent.getActivity(context, <requestCode>, intent2Add, <flag>);
複製程式碼

元件間 UI 混合

元件間 UI 耦合是常規操作,比如聚合頁需要展示各個元件內部的 UI 卡片,就發生 UI 耦合。

如何在元件保持獨立的情況下,獲取另一元件的內部 UI ?

  1. 獲取元件 Fragment:用 ARouter 獲取 Fragment:

    // 獲取Fragment
    Fragment fragment = (Fragment) ARouter.getInstance()
          .build("/test/fragment")
          .navigation();
    複製程式碼

    Fragment 與 Activity等的互動:使用前面 2. 元件開發 > 元件間資料傳遞2. 元件開發 > 元件間函式呼叫2. 元件開發 > 元件間介面跳轉 的方法。

  2. 獲取元件 View:使用 ARouter,把元件 View 封裝進服務裡,通過發現服務來獲取 View。 互動同樣使用前面 2. 元件開發 > 元件間資料傳遞2. 元件開發 > 元件間函式呼叫2. 元件開發 > 元件間介面跳轉 的方法。 PS:不推薦跨元件 UI,因為要考慮到獲取 View 為空的情況。

4. 元件維護

元件混淆

  1. 只在 release 裡混淆。對於更高的安全需求,Proguard可能有些力不從心了。而DexGuard就能提供更安全的保護,關於它的資料可以點這裡

    buildTypes {
        release {
            minifyEnabled true   //開啟混淆
            zipAlignEnabled true  //壓縮優化
            shrinkResources true  //移出無用資源
            proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' //預設的混淆檔案以及我們指定的混淆檔案
        }
    }
    複製程式碼
  2. 每個元件單獨維護自己的混淆配置,並且在 build.gradle 裡使之生效。

    buildTypes {
        release {
            consumerProguardFiles 'proguard-rules.pro'
        }
    }
    複製程式碼
  3. 第三方庫的混淆:第三方庫可能要求某些類不能混淆,請按該官方要求來寫對應的混淆配置,並把所有第三方混淆配置彙總成一個檔案。所以會全域性維護一個統一的第三方混淆配置。

注意:

  1. 宿主殼的混淆開關配置會直接影響到元件,也就是說如果你的宿主殼開啟混淆,就算你的元件關閉混淆開關,最終元件還是會被混淆的。

  2. 元件混淆檔案的指定是通過consumerProguardFiles這個屬性來指定的,並不是proguardFiles 屬性,而且我們無需配置其他的選項,只需要配置consumerProguardFiles屬性就可以。該屬性表示在打包的時候會自動尋找該module下我們指定的混淆檔案對程式碼進行混淆。如果我們釋出出一些開源庫去給別人用,想要對庫程式碼進行混淆的話,也可以通過配置該屬性來達到目的。

體積優化

參考:https://blog.csdn.net/cchp1234/article/details/77750428

原因分析:

  1. 螢幕適配問題,增加多種資原始檔和圖片
  2. 機型適配問題
  3. Android 版本相容問題
  4. 各種開發框架,第三方lib引入
  5. 炫酷的UI、UE效果
  6. 冗餘程式碼等

以下是具體優化。

assets 優化

  1. 字型檔案:可以使用字型資原始檔編輯神器Glyphs進行壓縮刪除不需要的字元從而減少APK的大小。

  2. WEB頁面:可以考慮使用7zip壓縮工具對該檔案進行壓縮,在正式使用的時候解壓

  3. 某些圖片:可以使用tinypng進行圖片壓縮, 目前tinypng已經支援png和jpg圖片、.9圖的壓縮

  4. 將快取檔案放到服務端,通過網路下載。我們可以在專案中使用資源動態載入形式,例如:表情,語言,離線庫等資源動態載入,減小APK的大小

res 優化

  1. 對於一些不必要的裝置尺寸,不必要全部(主要看產品需求);

  2. 對資原始檔,主要是圖片資源進行壓縮,壓縮工具是 ImageOptim;

  3. 一些UI效果可以使用程式碼渲染替代圖片資源;

  4. 資原始檔的複用,比如兩個頁面的背景圖片相同底色不同,就可以複用背景圖片,設定不同的底色;

  5. 使用 VectorDrawable 和 SVG 圖片來替換原有圖片。如果提升 minSdkVersion 效果會更好,minSdkVersion 21 以上直接使用向量圖,可以不用為了相容低版本而另外生成 .png 圖片檔案。使用SVG不用考慮螢幕適配問題,體積非常小;

  6. 如果raw資料夾下有音訊檔案,儘量不要使用無損的音訊格式,比如wav。可以考慮相比於mp3同等質量但檔案更小的opus音訊格式。

  7. 能不用圖片的就不用圖片,可以使用shape程式碼實現。

  8. 使用 WEBP。較大 png、jpg檔案轉化為 webp 檔案,這個 AS 自帶,在圖片(單個)或包含圖片的資料夾(批量)上右擊選擇 Convert to WebP 即可。Webp 無損圖片比 PNG 圖片的 size 小 26%。Webp 有損圖片在同等 SSIM(結構化相似)質量下比 JPEG 小 25-34% 。無損Webp支援透明度(透明通道)只佔22%額外的位元組。如果可以接受有損RGB壓縮,有損Webp也支援透明度,通常比PNG檔案size小3倍。

  9. 強烈推薦:利用AndResGuard資源壓縮打包工具,縮短資源名,也有混淆資源的效果。

lib目錄優化

  1. 減少不必要的.so檔案。比如一些第三方SDK要求引入.so檔案,通常還很大,比如語音識別(.so通常是為了本地識別,但很多時候沒必要)如果能用 rest api 的話,儘量不要用這些 SDK。

  2. 坑:RenderScript 的支援庫 libRSSupport.so 體積真的很大啊~還沒找到除掉的方法...

  3. 選擇性刪除 arm64-v8a、armeabi-v7a、armeabi、x86下的so檔案:

    • mips / mips64: 極少用於手機可以忽略
    • x86 / x86_64: x86 架構的手機都會包含由 Intel 提供的稱為 Houdini 的指令集動態轉碼工具,實現對 arm.so 的相容,再考慮 x86 1% 以下的市場佔有率,x86 相關的 .so 也是可以忽略的
    • armeabi: ARM v5 這是相當老舊的一個版本,缺少對浮點數計算的硬體支援,在需要大量計算時有效能瓶頸
    • armeabi-v7a: ARM v7 目前主流版本
    • arm64-v8a: 64 位支援。注意:arm64-v8a是可以向下相容的,但前提是你的專案裡面沒有arm64-v8a的資料夾。如果你有兩個資料夾armeabiarm64-v8a兩個資料夾,armeabi裡面有a.sob.so,arm64-v8a裡面只有a.so,那麼arm64-v8a的手機在用到b的時候發現有arm64-v8a的資料夾,發現裡面沒有b.so,就報錯了,所以這個時候刪掉arm64-v8a資料夾,這個時候手機發現沒有適配arm64-v8a,就會直接去找armeabi的so庫,所以要麼你別加arm64-v8a,要麼armeabi裡面有的so庫,arm64-v8a裡面也必須有。

    最終解決:只用armeabi,其餘像 mips, x86, armeabi-v7a, arm64-v8a都刪掉。手機發現沒有適配arm64-v8a,就會直接去找armeabi的 so 庫。

     buildTypes {
         release {
             ndk {
                 abiFilters "armeabi"// 只保留 armeabi 架構減少體積
             }
         }
     }
    
    複製程式碼

清除無用的資原始檔

Resource shrinking: 需要和 Code shrinking 一起使用。在程式碼中刪除所有未使用的程式碼後,Resource shrinking才可以知道哪些資源 APK 程式仍然使用,你必須先刪除未使用的程式碼,Resource 才會成為無用的,從而被清除掉。

清除未使用的替代資源

Gradle resource shrinker 只刪除你在程式碼中未使用資源,這意味著它不會刪除不同的裝置配置的可替代資源。如果有必要,你可以使用Android Gradle plugin 的resconfigs屬性刪除替代資原始檔。

例如:我們專案中適配10種國家語言,而專案依賴了v7、v4等其他support包裡面包含20種國家語言,那麼我們可以通過resconfigs 刪除剩餘的可替代資原始檔,這對於我們APK大小可減少了不少,

以下程式碼說明了如何限制你的語言資源,只是英語和法語:

android {
    defaultConfig {
        ...
        resConfigs "en", "fr"
    }
}
複製程式碼

像上面那樣通過resconfig屬性指定的語言。未指定的語言的任何資源都被刪除。

整理程式碼

  1. 減少不必要的依賴。建議使用com.github.znyang:library-analysis外掛生成依賴樹和依賴體積報告,根據報告合併、剔除不必要的庫。

  2. 刪除程式碼。多寫函式,多寫函式,多寫函式!超過 30 行一定要寫函式!必要時寫類!儘量別once copy, paste everywhere一段程式碼,到處貼上(本來寫得就亂,到處貼上無異於到處倒垃圾)推薦閱讀程式碼整潔之道

  3. 定期進行程式碼review,這是一個很重要的工作,可以是團隊成員之間熟悉彼此程式碼同時也可以查詢出一些自己看不出來的bug以及減小一些無用程式碼;

  4. 做一些程式碼lint之類的工作,Android studio已經提供了這樣的功能,定期執行程式碼lint操作是一個比較不錯的習慣;

  5. 雲函式:如果能在服務端處理的邏輯,最後由服務端處理,這樣減少程式碼和提高容錯(服務端可以及時更新)

編譯加速(gradle 優化)

參考 Android中的Gradle之配置及構建優化

  1. 使用最新的Android gradle外掛
  2. 避免使用multidex。用multidex的話,當minSdkVersion為21以下的時候的時候(不包含21),編譯時間會大大增加。
  3. 減少打包的資原始檔。參考體積優化一節
  4. 禁用PNG壓縮。
     android {
         ...
         if (project.hasProperty(‘devBuild’)){
             aaptOptions.cruncherEnabled = false
         }
         ...
     }
    複製程式碼
  5. 使用Instant run。andorid studio 3.0之後對instant run進行了很大的優化,之前的版本出現過更新的程式碼沒有執行到手機上,所以一直關閉著。現在可以嘗試使用。
  6. 不要使用動態依賴版本。
    // 錯誤的示範
    implementation 'com.appadhoc:abtest:latest'
    implementation 'com.android.support:appcompat-v7:27+'
    複製程式碼
  7. 對Gradle後臺程式的最大堆大小的分配
  8. 使用Gradle快取

給出時光貓其中一個除錯殼的 build.gradle供參考,更多build.gradle會在 demo 裡放出:

apply plugin: 'com.android.application'
apply plugin: 'idea'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-android-extensions'
apply plugin: 'com.alibaba.arouter'

static def releaseTime() {
    return new Date().format("yyyy-MM-dd", TimeZone.getTimeZone("UTC"))
}

def keystorePSW = ''
def keystoreAlias = ''
def keystoreAliasPSW = ''
// default keystore file, PLZ config file path in local.properties
def keyfile = file('C:/Users/dlink/Application/key/dlinking.jks')

Properties properties = new Properties()
// local.properties file in the root director
properties.load(project.rootProject.file('local.properties').newDataInputStream())
def keystoreFilepath = properties.getProperty("keystore.path")
def BILIBILI_APPKEY = properties.getProperty('BILIBILI_APPKEY')
def ali_feedback_key = properties.getProperty('ali_feedback_key')
def ali_feedback_key_secret = properties.getProperty('ali_feedback_key_secret')

if (keystoreFilepath) {
    keystorePSW = properties.getProperty("keystore.password")
    keystoreAlias = properties.getProperty("keystore.alias")
    keystoreAliasPSW = properties.getProperty("keystore.alias_password")
    keyfile = file(keystoreFilepath)
}

android {
    signingConfigs {
        config {
            storeFile keyfile
            storePassword keystorePSW
            keyAlias keystoreAlias
            keyPassword keystoreAliasPSW
            println("====== signingConfigs.debug ======")
        }
    }
    compileSdkVersion rootProject.ext.android["compileSdkVersion"]
    buildToolsVersion rootProject.ext.android["buildToolsVersion"]

    compileOptions {
        sourceCompatibility JavaVersion.VERSION_1_8
        targetCompatibility JavaVersion.VERSION_1_8
    }
    dexOptions {
        //預編譯
        preDexLibraries true
        //支援大工程
        jumboMode = true
        //執行緒數
        threadCount = 16
        //dex記憶體,公式:dex記憶體 + 1G < Gradle記憶體
        javaMaxHeapSize "4g"
        additionalParameters = [
                '--multi-dex',//多分包
                '--set-max-idx-number=60000'//每個包內方法數上限
        ]
    }
    lintOptions {
        checkReleaseBuilds false
        disable 'InvalidPackage'
        disable "ResourceType"
        // Or, if you prefer, you can continue to check for errors in release builds,
        // but continue the build even when errors are found:
        abortOnError false
    }

    defaultConfig {
        applicationId "com.time.cat"
        minSdkVersion rootProject.ext.android["minSdkVersion"]
        targetSdkVersion rootProject.ext.android["targetSdkVersion"]
        versionCode rootProject.ext.android["versionCode"]
        versionName rootProject.ext.android["versionName"]

        manifestPlaceholders = [UMENG_CHANNEL_VALUE: "for_test"]
        testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
        multiDexEnabled true

        //相容Android6.0系統所需
        useLibrary 'org.apache.http.legacy'

        // Enable RS support
        renderscriptTargetApi 25
        renderscriptSupportModeEnabled false

        // ButterKnife
        javaCompileOptions {
            annotationProcessorOptions {
                includeCompileClasspath = true
                arguments = [moduleName: project.getName()]
            }
        }
    }
    packagingOptions {
        exclude 'META-INF/rxjava.properties'
    }
    buildTypes {
        debug {
            minifyEnabled false
            shrinkResources false
            manifestPlaceholders = [UMENG_CHANNEL_VALUE: "for_test"]
            debuggable true
            signingConfig signingConfigs.config
        }
        release {
            minifyEnabled true
            shrinkResources true
            zipAlignEnabled true
            useProguard true
            proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro', file(rootProject.ext.path["globalProguardFilesPath"])
            applicationVariants.all { variant ->
                variant.outputs.all {
                    def apkName = "${defaultConfig.applicationId}_${defaultConfig.versionName}_${releaseTime()}.apk"
                    outputFileName = apkName
                }
            }
            ndk {
                abiFilters "armeabi"
            }
            signingConfig signingConfigs.config
            proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
            multiDexKeepProguard file('multidex-config.pro')
        }
    }
}
idea {
    module {
        downloadJavadoc = true
        downloadSources = true
    }
}
dependencies {
    //region project
    runtimeOnly rootProject.ext.dependencies["module-reader"]

    runtimeOnly project(':modules:module-rawtext-editor')
    runtimeOnly project(":modules:module-controller")
    //endregion
}

apply plugin: 'com.zen.lib.analysis'

libReport {
    output = [
            "txt", "html" // default
    ]
    ignore = [
            "com.android.support:support-v4"
    ]
}

複製程式碼

強烈推薦:使用//region project//endregion進行程式碼摺疊,能清晰組織類的結構,賊好用~

//region 摺疊後顯示我
我可以被
摺疊,無論
有多長
//endregion
複製程式碼

依賴控制

升級 gradle 到 3.0+,使用 implementation, api, runtimeOnly, compileOnly 進行依賴控制。

  • implementation: 短依賴。我的依賴的依賴,不是我的依賴。
  • api: 長依賴。我的依賴,以及依賴的依賴,都是我的依賴。
  • runtimeOnly: 不合群依賴。寫程式碼和編譯時不會參與,只在生成 APK 時被打包進去。
  • compileOnly: 假裝依賴。只在寫程式碼和編譯時有效,不會參與打包。

使用規範:

  • implementation: 用於元件範圍內的依賴,不與其他元件共享。(作用域是元件內)
  • api: 用於基礎層的依賴,要穿透基礎層,暴露給元件層。(作用域是所有)
  • runtimeOnly: 用於 app 宿主殼的依賴,元件間是絕對隔離的,編譯時不可見,但參與打包。(無作用域)
  • compileOnly: 用於高頻依賴,防止 already present 錯誤。一般是開源庫用來依賴 support 庫防止與客戶的 support 庫版本衝突的。

每個元件在開發時隨時都有可能引入依賴,那麼在引入依賴時,什麼時候只給元件,什麼時候應該下沉到基礎層?

依賴引入原則:最少依賴原則。

基礎層的依賴必須儘量少,不得隨意下沉依賴。一個依賴能下沉,當且僅當基礎層的某個庫需要這個依賴。如果這個依賴只是給元件用的,不是基礎庫需要的,就算有幾十幾百個元件都要這個依賴,也要分別在元件的 build.gradle 里加,一定不能下沉到基礎層。就算元件層的不同元件有相同的依賴,打包時也不會有衝突(gradle真好用)。

釋出 aar

runtimeOnly 支援對 aar 的依賴,所以一個元件相對穩定後,把該元件打包成 aar,釋出到 github 或 maven,在本地 setting.gradle 註釋掉該元件的宣告,用 arr 替換原來的,可大幅減少編譯時間和運存佔用。

在元件的build.gradle加入下面這段,sync後執行gradle uploadArchives實現自動打包出 arr 到指定資料夾。


//////// 打包釋出配置開始 ////////
apply plugin: 'maven'
ext {
    // 從Github上clone下來的專案的本地地址
    GITHUB_REPO_PATH = "C:\Users\dlink\Documents\GitHub\mempool\TimeCatMaven"       //這裡指定的就是剛剛clone下來的在本地的路徑
    PUBLISH_GROUP_ID = 'com.timecat.widget'
    PUBLISH_ARTIFACT_ID = 'widget-calendar'
    PUBLISH_VERSION = '1.0.0'
}
uploadArchives {
    repositories.mavenDeployer {
        def deployPath = file(project.GITHUB_REPO_PATH)
        repository(url: "file://${deployPath.absolutePath}")
        pom.project {
            groupId project.PUBLISH_GROUP_ID
            artifactId project.PUBLISH_ARTIFACT_ID
            version project.PUBLISH_VERSION
            // 使用:
            // implementation "com.timecat.widget:widget-calendar:1.0.0"
        }
    }
}
//////// 打包釋出配置結束 ////////

複製程式碼

純 widget(依賴最多是 support 庫等基礎層已有的依賴)也可打包成 arr 釋出出去,減少編譯時間。

注意:

  • 在打包 aar 的時候有一定的機率沒把生成檔案一起打包進去,比如打包一個含 ARouter 的一個元件時,ARouter 應該生成了路由表和一些輔助類,如果生成的這些檔案沒有一起打包進 aar 裡,那麼在實際釋出的時候是找不到路由的。為了確保打包時生成類確實打包進 aar 裡,最好先執行 gradle assemble 完整構建一次元件,再 gradle uploadArchives釋出到 marven。

  • 檢視 aar 裡是含有生成類路由表等檔案,請使用 Android Studio 的一個外掛:Android Bundle Support,可以像分析 apk 一樣分析 jar 或 aar 檔案。

    用生命週期規範元件化流程

常見錯誤:already present

在引入 aar 和升級 aar 後,再編譯時,可能出現某個類 already present 的錯誤導致 Build 失敗。 只有兩種解決方法,分析如下:

  • 若在 Build 過程中有某個 aar 升級,雖然 sync 成功,但是舊版本的 aar 可能已經有一部分被分包並 Build 到快取裡了(快取一般是build/intermediates/debug目錄下),接著新版本的 aar 再 Build 進去,就會導致合併這些快取時出現already present 錯誤。 解決方法:Build > Clean > Rebuild 還是不能解決,就刪除 <元件名>/build 資料夾,再 Build
  • 上面方法試過後,如果還是有 already present 錯誤,那一定是依賴重複。 關於為什麼重複?有兩種可能: 1、同時依賴一個庫的多個不同版本
    1. A 依賴 B,B 依賴 C,如果 A 這時也依賴 C,那 C 裡的類可能報這個錯。 解決方法:分析報錯類所在庫的被依賴關係。雙擊 Shift 輸入重複的類,使用 gradle dependencies 檢視依賴樹,確定是否重複依賴,只保留一個即可。
    ...
    //提示Error:Program type already present: android.arch.lifecycle.LiveData
        api('com.github.applikeysolutions:cosmocalendar:1.0.4') {
          exclude group: "android.arch.lifecycle"
          exclude module: "android.arch.lifecycle"
        }
    ...
    複製程式碼
    關於modulegroup,請從arr的釋出那裡舉一反三
      PUBLISH_GROUP_ID = 'com.timecat.widget' // group
      PUBLISH_ARTIFACT_ID = 'widget-calendar' // module
    複製程式碼

5. 元件釋出

兩種方式

  1. 釋出為 aar,要依賴則通過runtimeOnly rootProject.ext.dependencies["module-book-reader"]
  2. 複製貼上,要依賴則通過runtimeOnly project(':modules:module-book-reader')

PS:aar 對編譯效率貌似提升不大...並沒有顯著減少全量編譯的時間。有待考究,歡迎補充!

注意:有個坑是“打包元件 aar 時連同其依賴也打包進去”,gradle不會自動把依賴打包進去。但是不建議把依賴一起打包,因為可能導致 already present 錯誤。建議保持基礎層提供的 api 相對穩定即可。

6. 元件移除

!!!不建議在殼內移除元件。

為什麼?

雖然元件移除簡單,衝突也少,但就是不建議移除。規範做法是複製這個殼,再在複製版裡移除依賴,成為新的殼。因為你移除後說不定又想用回來呢?真香警告!想用回來就選原來的殼進行編譯即可。保留原來的殼,有利與減少編譯時間。本質上這是用空間換取時間。

元件移除:在殼的 build.gradle 裡註釋掉對應的元件即可。

因為元件間絕對獨立,就像獨立的 app 一樣,只不過被做成庫而已,移除元件像移除一個無關緊要的庫一樣簡單。

//        runtimeOnly project(':modules:module-rawtext-editor')
        runtimeOnly project(":modules:module-master")
//        runtimeOnly project(":modules:module-controller")
        runtimeOnly project(":modules:module-github")
複製程式碼

還記得 runtimeOnly 嗎?不合群依賴,編譯時不參與,但執行時能用。也就是說,編譯時就算是 app 殼,也無法訪問元件內的任意一個類。對殼來說,runtimeOnly的元件形如虛設。

可能你會移除掉 android.intent.category.LAUNCHER 的 Activity 對應的元件...這是啟動 Activity,換一個即可。

寫在後面

展望:元件共享

元件共享:在公司或 github 上共享你的元件,給大家留點頭髮。

人們可以自由分享元件,通過選擇別人寫好的元件,套個殼編譯一下,就是一個新的 APP!

妙啊~

尾聲

恭喜看到最後,demo 有空會在 github 上更新,歡迎關注

一曲《吾碼無蟲》獻給大家。

新建空白文件,回車泣鬼,指落驚神。嘈嘈切切鍵交錯,指令如泉湧,入棧細無聲。

連結編譯執行,碼到成功,測試從容。嗶哩嗶哩印虛空,程式傳千古,位元即永恆。

相關文章