天天看點

知乎 Android Gradle plugin 實踐

作者:閃念基因

前言

自從 Android Studio 釋出以來,Gradle 就是 Android 官方推薦的建構工具,它可以靈活的管理依賴與建構過程,同時提供了強大的插件體系,可以很友善的自定義插件以實作各種自定義的擴充功能。知乎在很早的時候就引入了 Android Studio 并進行了 Gradle plugin 的開發,這篇文章會介紹一些知乎在這方面的一些工作。

實踐

與 Android Gradle plugin (AGP)類似,我們的插件也分為 application 插件和 library 插件,分别應用在 app 和 library 子產品中,隻要使用了知乎 Gradle plugin,AGP 就會被自動使用。原則上不允許使用綁定的 AGP 之外的版本,這有兩個原因:

  1. 相容多個版本 AGP 的開發成本,由于 AGP 除了 DSL 之外并沒有公開的文檔,内部 API 經常變動,相容多個版本的維護成本比較高。
  2. 内部統一,避免出現因 AGP 版本不同導緻的不相容問題,比如 databinding(databinding 經常出現破壞性 api 變更,多元件情況下很是難受)

關于語言的選擇:最早一版的插件是使用 java 進行開發的,但是對比 DSL 發現寫起來實在過于啰嗦,于是轉而使用 Groovy,由于 Groovy 文法基本上相容 Java,也可以直接使用 DSL 文法,上手成本幾乎沒有,同時由于是動态語言,開發起來非常靈活,自帶很多友善的擴充方法,很多功能寫起來非常友善,還可以配合 @TypeChecked 注解來解決靜态檢查的問題,是以後來絕大部分功能都使用 Groovy 來編寫。随着 google 對 kotlin 的支援力度越來越大,AGP 的很多代碼都已經用 kotlin 重寫了,但是由于 Groovy 與 kotlin 混編問題很大,并且使用 groovy 沒有什麼明顯缺點,是以目前仍然是使用 groovy 進行開發。

知乎最早的插件功能很簡單,就是提供了一個可動态配置的多管道打包功能并輸出為不同的檔案,這也是大部分同學第一次接觸 gradle 時使用的功能。後來工程越來越大,開發人員越來越多,不斷的出現各種問題與需求,插件的功能不斷豐富,現在已經內建了近五十種功能,這篇文章會選擇部分典型的功能介紹一下:

統一配置

元件化之後,元件工程越來越多,不可避免的會有依賴和配置的沖突,對新來的同學也不利,是以我們的 plugin 會為工程添加統一的配置,比如:

  1. 統一 compileSdkVersion、minSdkVersion、targetSdkVersion
  2. 強制使用相同的 support 庫、 kotlin 和 java 的版本
  3. 統一各個依賴的版本号以及版本号的自動更新
  4. 統一添加 git hook
  5. 提供預設的單元測試、覆寫率、pmd 等的配置
  6. 一些元件化相關的預設配置
  7. 等等

新來的開發同學在建立倉庫的時候隻要應用一下我們的 plugin 即可完成大部分的通用配置,避免了由于配置不當造成的沖突,減少建立倉庫配置的複雜度,同時我們的版本号會自動更新,友善對通用配置的統一更新。

依賴管理

在元件化之前,單體 APP 的依賴管理是一個很簡單的事情,絕大部分代碼都在同一個倉庫中,一目了然,但是随着元件化的深入,各個元件越來越多,因為依賴導緻的問題也逐漸出現。除了曾經在 《Databinding 變慢之謎》 中提到過的限制不合理依賴和 《知乎 Android 用戶端元件化實踐》 中提到的多元件合并之外,我們針對常見的問題,還在 plugin 中添加了下面的一些解決方案:

Peter Porker:Android DataBinding 編譯變慢之謎69 贊同 · 10 評論文章

知乎 Android Gradle plugin 實踐

Peter Porker:知乎 Android 用戶端元件化實踐181 贊同 · 48 評論文章

知乎 Android Gradle plugin 實踐

限制第三方庫的引入

在元件化之前,知乎曾經制定過第三方庫的引入标準,經過一系列流程後,第三方庫以向主工程提 merge request 的形式來引入進來,這在隻有一個集中式代碼倉庫的情況下并沒有問題。元件化拆分之後,由于依賴的傳遞性,隻要在元件中添加一個依賴,它就會被帶到主工程中來;由于元件代碼分散在不同的倉庫中,缺少了最終引入的統一把控,各個元件對第三方庫的依賴已經不受控制,不少人會按照自己的喜好添加自己用的順手的庫,有時候會因為一個微小的功能而引入一個第三方庫,甚至會因為已有的庫不符合自身喜好而自行添加另外一個的情況,這些往往會導緻功能重複、依賴冗雜,難以控制标準化。

我們的解決辦法是添加一個全局依賴的白名單,放在一個隻有極少數人有寫入權限的遠端配置檔案中,應用編譯時會檢查依賴是否在白名單中,如果出現了白名單之外的依賴,編譯會直接報錯并引導開發人員發起引入流程。比如,知乎使用的圖檔庫是 fresco,沒有使用 picasso,如果這時候直接添加 picasso 依賴,會報錯如下:

知乎 Android Gradle plugin 實踐

通過強制檢查白名單+制定引入流程的方式,我們杜絕了濫用第三方庫的問題

解決依賴沖突

發生依賴沖突的情況很多,常見的有兩種:一種是 Gradle configuration 導緻的沖突,另一種是是不同依賴之間的内容确實會有沖突,比如都包含了相同的類導緻的類重複

configuration 沖突

自從 AGP 3.0 開始,Android 開始棄用 compile 改為 implementation 和 api,并且官方推薦使用 implementation,然而我們在實踐過程中發現,使用 implementation 并不能帶來實際的收益,并且如果不同的元件 implementation 了不同的版本,編譯很有可能會報錯,雖然可以強制在 app 中指定版本号來忽略沖突,但是對上百個庫都使用強制版本号是比較麻煩的事情,是以我們開始推薦使用 api 而不是 implementation,但是君子協定式的推薦并不能 100% 通知到所有人,于是我們便通過插件直接将 implementation 轉換成 api,這樣便不用再擔心再遇到 implementation 的坑,開發同學也不必關心 api 與 implementation 的差別, plugin 已經在背後默默地做好了一切

依賴内容沖突

有一段時間,我們的元件變動很頻繁,雖然版本号是自動同步的,但是并不能識别元件的分裂、合并與改名這樣的變動,這就會導緻原來依賴得好好的,突然因為一個依賴改了名或者合并了,導緻老元件和新元件同時被依賴,編譯時經常報類重複問題,一個元件如果因為某個原因需要改名,不得不跑遍所有的元件都改一遍,才能保證各個元件沒有問題,過程很是麻煩。我們的解決方案是定義一個遠端配置檔案,在其中定義了各個庫的版本與依賴替換關系,編譯 apk 時插件會讀取配置檔案并自動根據配置檔案自動解決沖突。這樣如果某個元件發生了上述變動,隻要在配置檔案中修改一下,即可直接接入,無需再修改所有依賴它的元件,而依賴它的元件慢慢将此依賴修改即可。

no-op 沖突問題

還有一種不常見的依賴沖突,比如為了開發友善我們會在 debug 版本使用 stetho ,而 release 版本我們并不需要 stetho,是以我們會在 release 版本中使用一個 no-op 版本,使用方式如下:

debugImplementation 'com.facebook.stetho:stetho-okhttp3:1.5.0'
releaseImplementation 'com.zhihu.android.library:stetho-no-op:1.0.0'           

但是我們也想要在在 mrRelease 版本中使用 stetho 而不是 no-op,這時候很自然的會想到使用 mrImplementation

mrImplementation 'com.facebook.stetho:stetho-okhttp3:1.5.0'           

這時候由于 stetho 與 stetho-no-op 不是相同的庫但是存在同名的類,編譯時便會報類重複錯誤,于是我們定義了一個新的 extension,可以解決這個問題,新的寫法:

debugImplementation 'com.facebook.stetho:stetho-okhttp3:1.5.0'
releaseImplementation 'com.zhihu.android.library:stetho-no-op:1.0.0'
dependencyReplace {
    replace "com.zhihu.android.library:stetho-no-op" with "com.facebook.stetho:stetho-okhttp3:1.5.0" on "mr"
}           

這也可以用來解決相同的 library 針對不同管道提供不同版本的問題

代碼品質

強制 lint

為了保證代碼品質,我們的插件會在元件以任何形式釋出之前強制運作 lint,以防止有嚴重問題的代碼被釋出出去,并且禁止忽略 lint 的錯誤,在 lintOptions 中設定 abortOnError=false 将無效,同時我們也會将在實踐中發現容易出問題的規則級别更新為 fatal 以防止出現問題:

防止外部調用java 代碼

一個元件中絕大部分代碼都是不應該被外部調用到的,但是由于 java 的可見性修飾符比較弱,無法對跨元件的引用做有效的限制。有時候為了代碼結構清晰(更多的是習慣性地)我們會一些類設定為 public,這些被标記為 public 的代碼就可以被外部任意調用,即使将類标記為 protected 或者預設的 package private,仍然有可能被外部使用到。于是這就經常出現一種情況:A 元件中有某個類 Foo.java,做為内部使用,但是 B 元件的開發同學在寫代碼的時候無意(或者有意)間找到了這個類,發現可以被自己使用,便直接使用了 Foo.bar 方法,有一天 A 元件的開發同學因為業務變動,給 Foo.bar 方法添加新增了一個參數,自己運作起來沒有問題,但是一編譯主工程就會發現打包挂了,因為 B 元件引用了 Foo.java,A 元件同學莫名背鍋。

kotlin 和 swift 都提供了 internal 修飾符來解決這個問題,而 java 本身并沒有這個功能,但是 Android 官方提供了一種解決方案,那就是 support-annotations 庫的 RestrictTo 注解,配合 lint 使用。比如 Foo.java 要禁止外部使用,可以這樣:

@RestrictTo(RestrictTo.Scope.LIBRARY)
public class Foo {
 ...
}           

如果類很多,挨個打注解也是一個麻煩事兒,這時候 plugin 便派上用場了,它可以自動化為類打上 RestrictTo 注解,同時我們新增了一個 @Open 注解,隻有打上這個注解的類,才可以被外部調用

@Open
public class Foo {
 ...
}           

當然,如果僅僅這樣還是不夠的,RestrictTo 對應的 lint 規則為 RestrictedApi,在 官網 可以看到,它僅僅是 4 / 10 ,Error 級别,我們還要将它升一級

lintOptions {
    enable "RestrictedApi"
    fatal "RestrictedApi"
}           

配合強制 lint,這種代碼将無法釋出

資源限制

與 java 代碼類似,Android 資源檔案正常情況下也是可以被外部任意使用的,官方提供的限制方式為聲明一個 public.xml 檔案并在裡面聲明标記為 public 的資源名稱,而未被聲明為 public 的就是 private 的,但是這樣做也比較麻煩。與 java 代碼限制類似,我們的解決方案為:約定一個 res-public 檔案夾,如果一個資源被添加到了 res-public 檔案夾,則認為是 public,其他檔案夾的則認為是 private ,這樣哪些資源是 public 的便一目了然。

知乎 Android Gradle plugin 實踐

同樣是配合 lint 使用,對應規則為 PrivateResource,與上面類似,不再贅述。

禁止手寫 Parcelable

Parcelable 是 Android 特有的序列化方式,官方聲稱其比 Serializable 更加高效,但是在實際使用中會發現它存在一個嚴重的問題:序列化和反序列化的順序和類型必須完全一緻,如果不一緻,會發生一些奇奇怪怪的很難排查的問題。常用的解決方案是:1. 使用 IDE 插件自動生成 Parcelable 代碼;2. 使用 apt 注解自動生成 Parcelable 代碼。但是實踐中經常會出現這樣的情況:

  1. 有些同學喜歡炫技,完全手寫 Parcelable
  2. 使用 IDE 生成的代碼,在新增字段的時候忘了再次生成
  3. 雖然 apt 可以自動生成,但是可能是新人不知道有這事或者主觀忽略了此功能

我們會通過 plugin 檢查沒有使用 apt 自動生成 Parcelable 代碼的類,并在編譯時報錯提醒,引導開發同學使用自動化工具

重複資源檢查

不同于 java 代碼,不同元件的資源是允許重名的,上層的元件資源名會覆寫掉下層的同名資源,app 在運作的時候,下層元件代碼會發現引用的資源與預期的并不一緻,就會出現顯示出錯或者 crash 等問題,解決方案有兩種:

  1. 對于新元件,添加資源字首。對于老元件,由于拆元件的時候,知乎工程已經很大了,考慮各種依賴情況,全部添加字首的話工程過于浩大(雖然已經提供了自動化的工具)
  2. 編譯期檢查,我們的插件會檢查目前元件的資源與其他元件的資源是否有沖突

Proguard 規則限制

proguard 除了可以混淆和删除無用代碼之外,還有一個很重要的功能就是檢查部分不相容的變更,上面提到的修改 Foo.bar 參數就可以通過 progurad 來及時發現。不過 proguard 規則有一個與第三方依賴差不多的問題:aar 中可以攜帶 proguard 規則,理論上來說,開發同學可以在自己元件中任意添加 proguard 規則并影響到主工程。雖然 proguard 的文法比較簡單,但是大部分同學經常使用的隻有梭哈 keep 和 dontwarn :

某個字段被 proguard 删掉了,怎麼辦,全元件 keep 一把梭

-keep class com.xxx.xxx.** { *; }           

某個方法報找不到了,全元件 dontwarn 一把梭

-dontwarn com.xxx.xxx.**           

少數機智的同學會放大招

-dontwarn **           

部分喪心病狂的第三方 sdk 會給出狂放的 proguard 規則,而引入的同學可能會不加分辨的照單全收

-ignorewarnings           

這就會造成幾個問題,一是盲目的 keep 導緻部分代碼沒有被混淆壓縮,包體積無謂的增大,另外一個就是盲目的 dontwarn 導緻不相容變更無法及時被發現。對于開發來說,後者的影響更大。是以我們在插件中增加了檢查 proguard 規則的功能:

  1. 禁止在元件中使用 -keep ** 這樣的規則,在 proguard 文法中,表示的類的時候,雙星 ** 可以表示任意長的類名段,比如 com.zhihu.** 會覆寫 com.zhihu 及其包下的所有類與子包中的類,影響範圍過大,很容易濫用,禁止;
  2. 禁止使用 -ignorewarnings ,ignorewarnings 會忽略所有的代碼不相容的情況,一旦使用此設定,後患無窮;
  3. dont-dontwarn : 禁止對特定包使用 -dontwarn ,-dontwarn 指令會忽略指定包下的所有代碼不相容的情況。比如 -dontwarn ** ,等同于 -ignorewarnings, -dontwarn com.zhihu.** 會忽略所有知乎代碼的不相容情況。我們禁止了能夠影響 com.zhihu 包代碼的 dontwarn 通配規則,比如 -dontwarn com.** 以及 -dontwarn com.zhihu.library.** 都會被禁止(當然,也可以擴大到其他的包)

如果元件的 proguard 規則違反了我們的規定,打包會直接挂掉,并引導修改:

知乎 Android Gradle plugin 實踐

自動查錯

有些同學可能會注意到上面 proguard 規則限制示例圖的底部出錯提示,它是我們插件的一個自動查錯功能,對于那些常見的編譯錯誤,在插件能夠自動解決之前(或者無法解決的時候),插件會自動尋找對應錯誤的解決方案。原理就是手動收集常見的錯誤,并将錯誤特征和解決方法都配置在遠端,當在編譯出錯的時候插件會收集錯誤資訊,并根據錯誤資訊查詢是否有已知的解決方案,并提示給開發同學。

除了上述功能之外,知乎 Gradle plugin 還有很多其他的功能,比如删除 R 檔案、檢查不合理資源、資源瘦身、jacoco 全量插樁等等功能,不少功能都可以單獨寫一篇文章了,這裡就不再一一展開了

最後

工欲善其事,必先利其器。雖然我們在團隊文檔中維護了不少的實踐、代碼規範和行為準則,但是隻在文檔中聲明這些東西是遠遠不夠的,實踐經驗告訴我們,依賴自覺性的規則往往是靠不住的,你無法保證所有人都被通知到完全了解,更無法讓所有人都會按标準執行,典型的如上面提到的第三方庫引入限制功能,如果沒有強有力的統一執行,規範很快就會被淡忘甚至被繞過。是以我們在不斷的實踐中,對于典型的問題,在建立規範的同時,還會建立相應的工具化的檢查措施,不僅僅是 Gradle plugin,還包括其他的各種 CI 工具,隻有這樣,才能更好的執行标準,落實規範。

作者:Peter Porker

出處:https://zhuanlan.zhihu.com/p/70900227