關於Android編譯,你需要了解什麼

本文來自張紹文老師的《Android開發高手課》,我把我認爲比較好的文章整理分享給大家。

作爲一名 Android 工程師,我們每天都會經歷無數次編譯。對於小項目來說,半分鐘或者1,2分鐘即可編譯完成,而對於大型項目來說,每次編譯可能需要花去一杯咖啡的時間。可能我講具體的數字你會更有體會,當時我在微信團隊時,全量編譯 Debug 包需要 5 分鐘,而編譯 Release 包更是要超過 15 分鐘。

如果每次編譯可以減少 1 分鐘,對微信整個 Android 團隊來說就可以節約 1200 分鐘(團隊 40 人 × 每天編譯 30 次 × 1 分鐘)。所以說優化編譯速度,對於提升整個團隊的開發效率是非常重要的。

那應該怎麼樣優化編譯速度呢?微信、Google、Facebook 等國內外大廠都做了哪些努力呢?除了編譯速度之外,關於編譯你還需要了解哪些知識呢?

編譯

雖然我們每天都在編譯,那到底什麼是編譯呢?
你可以把編譯簡單理解爲,將高級語言轉化爲機器或者虛擬機所能識別的低級語言的過程。對於 Android 來說,這個過程就是把 Java 或者 Kotlin 轉變爲 Android 虛擬機能夠運行的Dalvik 字節碼的過程。

編譯的整個過程會涉及詞法分析、語法分析 、語義檢查和代碼優化等步驟。對於底層編譯原理感興趣的同學,你可以挑戰一下編譯原理的三大經典鉅作:龍書、虎書、鯨魚書。

但今天我們的重點不是底層的編譯原理,而是希望一起討論 Android 編譯需要解決的問題是什麼,目前又遇到了哪些挑戰,以及國內外大廠又給出了什麼樣的解決方案。

1,Android 編譯的基礎知識

無論是微信的編譯優化,還是 Tinker 項目,都涉及比較多的編譯相關知識,因此我在 Android 編譯方面研究頗多,經驗也比較豐富。Android 的編譯構建流程主要包括代碼、資源以及 Native Library 三部分,整個流程可以參考官方文檔的構建流程圖

在這裏插入圖片描述
Gradle是 Android 官方的編譯工具,它也是 GitHub 上的一個開源項目。從 Gradle 的更新日誌可以看到,當前這個項目還更新得非常頻繁,基本上每一兩個月都會有新的版本。對於 Gradle,我感覺最痛苦的還是 Gradle Plugin 的編寫,主要是因爲 Gradle 在這方面沒有完善的文檔,因此一般都只能靠看源碼或者斷點調試的方法。最近我所在的公司就準備用Gradle搞一個渠道打包工具,對於項目的打包和構建過程,也是深有體會。

但是編譯實在太重要了,每個公司的情況又各不相同,必須強行造一套自己的“輪子”。已經開源的項目有 Facebook 的Buck以及 Google 的Bazel

爲什麼要自己“造輪子”呢?主要有下面幾個原因:

  • 統一編譯工具。Facebook、Google 都有專門的團隊負責編譯工作,他們希望內部的所有項目都使用同一套構建工具,這裏包括 Android、Java、iOS、Go、C++ 等。編譯工具的統一優化,所有項目都會受益。
  • 代碼組織管理架構。Facebook 和 Google 的代碼管理有一個非常特別的地方,就是整個公司的所有項目都放到同一個倉庫裏面。因此整個倉庫非常龐大,所以他們也不會使用 Git。目前 Google 使用的是Piper,Facebook 是基於HG修改的,也是一種基於分佈式的文件系統。
  • 極致的性能追求。Buck 和 Bazel 的性能的確比 Gradle 更好,內部包含它們的各種編譯優化。但是它們或多或少都有一些定製的味道,例如對 Maven、JCenter 這樣的外部依賴支持的也不是太好。

在這裏插入圖片描述
“程序員最痛恨寫文檔,還有別人不寫文檔”,所以它們的文檔也是比較少的,如果想做二次定製開發會感到很痛苦。如果你想把編譯工具切換到 Buck 和 Bazel,需要下很大的決心,而且還需要考慮和其他上下游項目的協作。當然即使我們不去直接使用,它們內部的優化思路也非常值得我們學習和參考。

Gradle、Buck、Bazel 都是以更快的編譯速度、更強大的代碼優化爲目標,我們下面一起來看看它們做了哪些努力。

2. 編譯速度

回想一下我們的 Android 開發生涯,在編譯這件事情上面究竟浪費了多少時間和生命。正如前面我所說,編譯速度對團隊效率非常重要。

關於編譯速度,我們最關心的可能還是編譯 Debug 包的速度,尤其是增量編譯(incremental build)的速度,我們希望可以做到更加快速的調試。正如下圖所示,我們每次代碼驗證都要經過編譯和安裝兩個步驟。
在這裏插入圖片描述
此處,我們從編譯時間和安裝時間兩個緯度來看Android的編譯速度。

  • 編譯時間。把 Java 或者 Kotlin 代碼編譯爲“.class“文件,然後通過 dx 編譯爲 Dex 文件。對於增量編譯,我們希望編譯儘可能少的代碼和資源,最理想情況是隻編譯變化的部分。但是由於代碼之間的依賴,大部分情況這並不可行。這個時候我們只能退而求其次,希望編譯更少的模塊。Android Plugin 3.0及以後的版本使用 Implementation 代替 Compile,正是爲了優化依賴關係。
  • 安裝時間。我們要先經過簽名校驗,校驗成功後會有一大堆的文件拷貝工作,例如 APK 文件、Library 文件、Dex 文件等。之後我們還需要編譯 Odex 文件,這個過程特別是在 Android 5.0 和 6.0 會非常耗時。對於增量編譯,最好的優化是直接應用新的代碼,無需重新安裝新的 APK。

對於增量編譯,我先來講講 Gradle 的官方方案Instant Run。在 Android Plugin 2.3 之前,它使用的 Multidex 實現。在 Android Plugin 2.3 之後,它使用 Android 5.0 新增的 Split APK 機制。

如下圖所示,資源和 Manifest 都放在 Base APK 中, 在 Base APK 中代碼只有 Instant Run 框架,應用的本身的代碼都在 Split APK 中。
在這裏插入圖片描述

Instant Run 有三種模式,如果是熱交換和溫交換,我們都無需重新安裝新的 Split APK,它們的區別在於是否重啓 Activity。對於冷交換,我們需要通過adb install-multiple -r -t重新安裝改變的 Split APK,應用也需要重啓。

雖然無論哪一種模式,我們都不需要重新安裝 Base APK。這讓 Instant Run 看起來是不是很不錯,但是在大型項目裏面,它的性能依然非常糟糕,主要原因是:

  • 多進程問題。“The app was restarted since it uses multiple processes”,如果應用存在多進程,熱交換和溫交換都不能生效。因爲大部分應用都會存在多進程的情況,Instant Run 的速度也就大打折扣。
  • Split APK 安裝問題。雖然 Split APK 的安裝不會生成 Odex 文件,但是這裏依然會有簽名校驗和文件拷貝(APK 安裝的乒乓機制)。這個時間需要幾秒到幾十秒,是不能接受的。
  • Javac 問題。在 Gradle 4.6 之前,如果項目中運用了 Annotation Processor。那不好意思,本次修改以及它依賴的模塊都需要全量 javac,而這個過程是非常慢的,可能會需要幾十秒。這個問題直到Gradle 4.7才解決,關於這個問題原因的討論你可以參考這個Issue

你還可以看看這一個 Issue:“full rebuild if a class contains a constant”,假設修改的類中包含一個“public static final”的變量,那同樣也不好意思,本次修改以及它依賴的模塊都需要全量 javac。這是爲什麼呢?因爲常量池是會直接把值編譯到其他類中,Gradle 並不知道有哪些類可能使用了這個常量。

詢問 Gradle 的工作人員,他們出給的解決方案是下面這個:


// 原來的常量定義:
public static final int MAGIC = 23

// 將常量定義替換成方法: 
public static int magic() {
  return 23;
}

對於大型項目來說,這肯定是不可行的。正如我在 Issue 中所寫的一樣,無論我們是不是真正改到這個常量,Gradle 都會無腦的全量 javac,這樣肯定是不對的。事實上,我們可以通過比對這次代碼修改,看看是否有真正改變某一個常量的值。

但是可能用過阿里的Freeline或者蘑菇街的極速編譯的同學會有疑問,它們的方案爲什麼不會遇到 Annotation 和常量的問題?

事實上,它們的方案在大部分情況比 Instant Run 更快,那是因爲犧牲了正確性。也就是說它們爲了追求更快的速度,直接忽略了 Annotation 和常量改變可能帶來錯誤的編譯產物。Instant Run 作爲官方方案,它優先保證的是 100% 的正確性。

當然 Google 的人也發現了 Instant Run 的種種問題,在 Android Studio 3.5 之後,對於 Android 8.0 以後的設備將會使用新的方案“Apply Changes”代替 Instant Run。目前我還沒找到關於這套方案更多的資料,不過我認爲應該是拋棄了 Split APK 機制。

一直以來,我心目中都有一套理想的編譯方案,這套方案安裝的 Base APK 依然只是一個殼 APK,真正的業務代碼放到 Assets 的 ClassesN.dex 中,它的架構圖如下。
在這裏插入圖片描述

  • 無需安裝。依然使用類似 Tinker 熱修復的方法,每次只把修改以及依賴的類插入到 pathclassloader 的最前方就可以,不熟悉的同學可以參考《微信 Android 熱補丁實踐演進之路》中的 Qzone 方案。
  • Oatmeal。爲了解決首次運行時 Assets 中 ClassesN.dex 的 Odex 耗時問題,我們可以使用“安裝包優化“中講過的 ReDex 中的黑科技:Oatmeal。它可以在 100 毫秒以內生成一個完全解釋執行的 Odex 文件。
  • 關閉 JIT。我們通過在 AndroidManifest 中添加android:vmSafeMode=“true”來關閉虛擬機的 JIT 優化,這樣也就不會出現 Tinker 在Android N 混合編譯遇到的問題。

對於編譯速度的優化,我還有幾個建議:

  • 更換編譯機器。對於實力雄厚的公司,直接更換 Mac 或者其他更給力的設備作爲編譯機,這種方式是最簡單的。
  • Build Cache。可以將大部分不常改變的項目拆離出去,並使用遠端 Cache模式保留編譯後的緩存。
  • 升級 Gradle 和 SDK Build Tools。我們應該及時去升級最新的編譯工具鏈,享受 Google 的最新優化成果。
  • 使用 Buck。無論是 Buck 的 exopackage,還是代碼的增量編譯,Buck 都更加高效。但我前面也說過,一個大型項目如果要切換到 Buck,其實顧慮還是比較多的。在 2014 年初微信就接入了 Buck,但是因爲跟其他項目協作的問題,導致在 2015 年切換回 Gradle 方案。

相比之下,可能目前最熱的 Flutter 中Hot Reload秒級編譯功能會更有吸引力。

當然最近幾個 Android Studio 版本,Google 也做了大量的其他優化,例如使用AAPT2替代了 AAPT 來編譯 Android 資源。AAPT2 實現了資源的增量編譯,它將資源的編譯拆分成 Compile 和 Link 兩個步驟。前者資源文件以二進制形式編譯 Flat 格式,後者合併所有的文件再打包。

除了 AAPT2,Google 還引入了 d8 和 R8,下面分別是 Google 提供的一些測試數據,如下圖。
在這裏插入圖片描述
在這裏插入圖片描述
那什麼是 d8 和 R8 呢?除了編譯速度的優化,它們還有哪些其他的作用?可以參考下面的介紹:Android D8 和 R8

3. 代碼優化

對於 Debug 包編譯,我們更關心速度。但是對於 Release 包來說,代碼的優化更加重要,因爲我們會更加在意應用的性能。

下面我就分別講講 ProGuard、d8、R8 和 ReDex 這四種我們可能會用到的代碼優化工具。

ProGuard

在微信 Release 包 12 分鐘的編譯過程裏,單獨 ProGuard 就需要花費 8 分鐘。儘管 ProGuard 真的很慢,但是基本每個項目都會使用到它。加入了 ProGuard 之後,應用的構建過程流程如下:
在這裏插入圖片描述
ProGuard 主要有混淆、裁剪、優化這三大功能,它的整個處理流程如下:
在這裏插入圖片描述
其中優化包括內聯、修飾符、合併類和方法等 30 多種,具體介紹與使用方法你可以參考官方文檔

D8

Android Studio 3.0 推出了d8,並在 3.1 正式成爲默認工具。它的作用是將“.class”文件編譯爲 Dex 文件,取代之前的 dx 工具。
在這裏插入圖片描述
d8 除了更快的編譯速度之外,還有一個優化是減少生成的 Dex 大小。根據 Google 的測試結果,大約會有 3%~5% 的優化。
在這裏插入圖片描述

R8

R8 在 Android Studio 3.1 中引入,它的志向更加高遠,它的目標是取代 ProGuard 和 d8。我們可以直接使用 R8 把“.class”文件變成 Dex。
在這裏插入圖片描述
同時,R8 還支持 ProGuard 中混淆、裁剪、優化這三大功能。由於目前 R8 依然處於實驗階段,網上的介紹資料並不多,你可以參考下面這些資料:
ProGuard 和 R8 對比:ProGuard and R8: a comparison of optimizers
Jake Wharton 大神的博客最近有很多 R8 相關的文章:https://jakewharton.com/blog/
R8 的最終目的跟 d8 一樣,一個是加快編譯速度,一個是更強大的代碼優化。

ReDex

如果說 R8 是未來想取代的 ProGuard 的工具,那 Facebook 的內部使用的ReDex其實已經做到了。Facebook 內部的很多項目都已經全部切換到 ReDex,不再使用 ProGuard 了。跟 ProGuard 不同的是,它直接輸入的對象是 Dex,而不是“.class”文件,也就是它是直接針對最終產物的優化,所見即所得。

在前面的文章中,我已經不止一次提到 ReDex 這個項目,因爲它裏面的功能實在是太強大了,具體可以參考專欄前面的文章《包體積優化(上):如何減少安裝包大小?》。

  • Interdex:類重排和文件重排、Dex 分包優化。
  • Oatmeal:直接生成的 Odex 文件。
  • StripDebugInfo:去除 Dex 中的 Debug 信息。

此外,ReDex 中例如Type Erasure和去除代碼中的Aceess 方法也是非常不錯的功能,它們無論對包體積還是應用的運行速度都有幫助,因此我也鼓勵你去研究和實踐一下它們的用法和效果。

但是 ReDex 的文檔也是萬年不更新的,而且裏面摻雜了一些 Facebook 內部定製的邏輯,所以它用起來的確非常不方便。目前我主要還是直接研究它的源碼,參考它的原理,然後再直接單獨實現。

事實上,Buck 裏面其實也還有很多好用的東西,但是文檔裏面依然什麼都沒有提到,所以還是需要“read the source code”。

  • Library Merge 和 Relinker
  • 多語言拆分
  • 分包支持
  • ReDex 支持

    持續交付

Gradle、Buck、Bazel 它們代表的都是狹義上的編譯,我認爲廣義的編譯應該包括打包構建、Code Review、代碼工程管理、代碼掃描等流程,也就是業界最近經常提起的持續集成。
在這裏插入圖片描述

目前最常用的持續集成工具有 Jenkins、GitLab CI、Travis CI 等,GitHub 也有提供自己的持續集成服務。每個大公司都有自己的持續集成方案,例如騰訊的 RDM、阿里的摩天輪、大衆點評的MCI等。

下面我來簡單講一下我對持續集成的一些經驗和看法:

  • 自定義代碼檢查。每個公司都會有自己的編碼規範,代碼檢查的目的在於防止不符合規範的代碼提交到遠程倉庫中。比如微信就定義了一套代碼規範,並且寫了專門的插件來檢測。例如日誌規範、不能直接使用 new Thread、new Handler 等,而且違反者將會得到一定的懲罰。自定義代碼檢測可以通過完全自己實現或者擴展 Findbugs 插件,例如美團它們就利用 Findbugs 實現了Android 漏洞掃描工具 Code Arbiter
  • 第三方代碼檢查。業界比較常用的代碼掃描工具有收費的 Coverity,以及 Facebook 開源的Infer,例如空指針、多線程問題、資源泄漏等很多問題都可以掃描出來。除了增加檢測流程,我最大的體會是需要同時增加人員的培訓。我遇到很多開發者爲了解決掃描出來的問題,空指針就直接判空、多線程就直接加鎖,最後可能會造成更加嚴重的問題。
  • Code Review。關於 Code Review,集成 GitLab、Phabricator 或者 Gerrit 都是不錯的選擇。我們一定要重視 Code Review,這也是給其他人展示我們“偉大”代碼的機會。而且我們自己應該是第一個 Code Reviewer,在給別人 Review 之前,自己先以第三者的角度審視一次代碼。這樣先通過自己這一關的考驗,既尊重了別人的時間,也可以爲自己樹立良好的技術品牌。

持續集成涉及的流程有很多,你需要結合自己團隊的現狀。如果只是一味地去增加流程,有時候可能適得其反。

總結

在 Android 8.0,Google 引入了Dexlayout庫實現類和方法的重排,Facebook 的 Buck 也第一時間引入了 AAPT2。ReDex、d8、R8 其實都是相輔相成,可以看到 Google 也在攝取社區的知識,但同時我們也會從 Google 的新技術發展裏尋求思路。

我在寫今天的內容時還有另外一個體會,Google 爲了解決 Android 編譯速度的問題,花了大量的力氣結果卻不盡如人意。我想說如果我們敢於跳出系統的制約,可能纔會徹底解決這個問題,正如在 Flutter 上面就可以完美實現秒級編譯。其實做人、做事也是如此,我們經常會陷入局部最優解的困局,或者走進“思維怪圈”,這時如果能跳出路徑依賴,從更高的維度重新思考、審視全局,得到的體會可能會完全不一樣。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章