Android編譯提速黑科技—Wade Plugin

語言: CN / TW / HK

作者:潘濤

隨著得物App業務高速發展,Android專案的程式碼量與元件數量迅速增加,專案編譯時長也明顯升高。今年初增量編譯平均耗時接近3.9分鐘,嚴重影響了開發效率,也促使我們探索各種措施縮短編譯時間提升開發效率。

背景

四月初通過一系列常規優化,如改造增量註解處理器、 增量Transform、元件化、工程化、優化專案配置等等,耗時縮短到2.3分鐘。六月,Wade Plugin 第一版上線,進一步縮短到1.3分鐘。八月, Wade第二個大版本上線,最終將增編耗時降低到了0.8分鐘。本文主要介紹Wade Plugin的技術原理和實現思路。

簡介

Wade Plugin是得物Android自研的Gradle外掛,用於提升編譯速度。常規優化手段用盡以後, 專案的增編耗時仍需2.3分鐘, 其中DexArchiveBuilder、MergeProjectDex、 MergeLibDex、MergeExtDex佔了1.7分鐘。不難看出,如果要進一步降低耗時, 應該挖掘DexBuild和DexMerge的優化空間。

Wade Plugin通過Hook Android原生的編譯流程, 將原生的DexBuildTask替換為WadeDexBuild, 原生的DexMergeTask替換為WadeDexMerge。原生DexBuild平均耗時60秒, WadeDexBuild只需12秒; 原生DexMerge平均耗時42秒, WadeDexMerge只需2秒。

WadeDexBuild 

原理

呼叫Dx或D8工 具完成Class到Dex的轉換這一過程稱為DexConvert, 它佔了DexBuild大部分耗時。原生DexBuild以Jar和Class為粒度執行DexConvert。得物工程中平均1個Jar包含200+個.class, 相當於增量時每改動一個類會觸發200個類執行DexConvert。

理想情況是隻有改動的Class參與DexConvert.

優化方案

在DexConvert執行前, 解壓縮Jar, 以.class為粒度執行DexConvert。並且只有其中變更的.class參與, 未變更的.class執行結果複用上次編譯快取。具體有四種變更型別對應的快取複用策略: 對於新增的.class, 需要參與DexConvert;改動的.class參與DexConvert;移除的.class不參與DexConvert;未改變的也不參與。

其中, 移除.class的情況要特殊處理. 例如Demo.class移除後, 除了相應刪除產物Demo.dex, 還要尋找它的內部類的產物Demo$1.dex, Demo$2.dex等。

主要實現 

輸入(Task Inputs) 

首先要根據Consumer Transform決定參與WadeDexBuild的.class檔案路徑, 消費Transform Inputs的Transform即Consumer Transform。當接入了一個Consumer Transform, 它的輸出路徑參與編譯; 沒有Consumer Transform時, Java Compile、Kotlin Compile的輸出路徑參與編譯; 如有多個Consumer Transforms, 取最後一個Transform的輸出路徑作為DexBuild輸入。

增量編譯觸發條件

觸發條件決定了本次編譯是否走增量邏輯, 以及上次編譯的快取是否可用。WadeDexBuild的增量條件包括五大類共28條(AGP3和AGP4略有不同):

  • Gradle配置

  1. AndroidJarClasspath

  2. DesugaringClasspathClasses

  1. ErrorFormatMode

  2. MinSdkVersion

  1. Dexer

  2. UseGradleWorkers

  1. InBufferSize

  2. Debuggable

  1. Java8LangSupportType

  2. ProjectVariant

  1. NumberOfBuckets

  2. DxNoOptimizeFlagPresent 

  • Wade配置

  1. WadeExtension.scope

  2. WadeExtension.duplicateClass

  1. WadeExtension.dexBucketSize

  2. WadeExtension.jarBucketSize

  • Wade快取

  1. ProjectWorkspaceDir

  2. SubProjectWorkspaceDir

  1. ExternalLibWorkspaceDir

  2. MixedScopeWorkspaceDir

  • 輸入檔案

  1. ProjectClasses

  2. SubProjectClasses

  1. ExternalLibClasses

  2. MixedScopeClasses

  • 產物檔案

  1. ProjectOutputDex

  2. SubProjectOutputDex

  1. ExternalLibOutputDex

  2. MixedScopeOutputDex 

其中Gradle配置相關的條件和原生的觸發條件相似。

觸發全量編譯的情況, 例如Gradle配置中的Dexer由D8 Dexer改為DX Dexer, 上次編譯快取肯定無法複用, 需要重新完整編譯。

觸發增量編譯的情況, 例如修改了一個Kotlin類, 導致輸入檔案中的MixedScopeClasses有變化, 此時編譯快取應可複用, 則觸發增量編譯。

Dex Convert 

WadeDexBuild關鍵步驟是將原生Dex Convert由Jar為粒度轉換為Class為粒度執行。首先解壓縮Jar, 解壓後的.class寫入快取目錄, 再將參與上次編譯的Class與參與本次編譯的Class檔案逐個對比, 只有新增和變更的Class參與Dex Convert, 移除和未改變的直接刪除或沿用對應快取。

效能優化

Dex Convert粒度由Jar轉換為Class後耗時明顯降低。但專案中共有423個Jar, 解壓後83000+個Class, 導致Dex Convert前解壓縮和檔案對比兩個步驟非常耗時。對這兩步的優化主要有三方面。

  • ForkJoinPool

用ForkJoinPool替代傳統的ExecutorService做併發, 因為它的Work Steeling演算法特別適合小檔案, 任務數特別多的場景, 能夠最大化利用CPU空閒時間。

  • mmap

檔案對比是I/O密集型任務, 普通檔案流的讀寫速度較慢。Wade Plugin所有I/O操作都用mmap實現, 包括讀、寫、拷貝等。檔案流替換為mmap對整體速度提升有很明顯的效果。

  • CRC-32代替MD5

對比兩檔案是否相同的常規做法是先比較檔案長度, 再校驗檔案MD5是否一致。由於Class數量太多, 計算MD5的耗時非常可觀。用CRC-32演算法計算檔案Hash, 作為Checksum來代替MD5能減少檔案對比的時間。

CRC-32計算的Checksum可靠性不如MD5, 理論上會有Hash碰撞, 導致修改Class修改後被誤判為未修改, 接著使用快取而非最新檔案參與編譯, 反映到產物APK上意味著這次修改無效。但是實際發生概率極低, 整體來看值得犧牲理論上的正確性來保證每次編譯的效率。

  • 優化效果

優化後解壓縮、寫快取平均耗時5700ms, 檔案對比耗時得益於CRC-32演算法只需10ms, DexBuild整體耗時從原生的60秒降低到12秒。

WadeDexMerge 

優化方案

DexMerge通過合併.dex檔案來降低APK內Dex檔案數量和體積, 提升安裝速度和首次執行速度。原生DexMerge的缺點是不支援增量編譯, 耗時和Dex檔案數量成正比, 得物專案的DexMerge耗時在30~60秒之間。

對於程式碼量少, 類總數不多的專案可以不執行DexMerge。AGP本身也有自動跳過DexMergingTask的邏輯, 當MinSDKVersion>23時, Dex數量小於500個不會執行DexMerge, MinSDKVersion<23時, Dex數量小於50個則自動跳過DexMerge。

Hook DexMergingTask可以做到忽略AGP的Dex數量閾值強行跳過DexMerge。但對於Dex數非常多的工程, 強行跳過DexMerge的副作用明顯, 在得物App上強行跳過會導致包體積增加40M左右、安裝APK耗時增加15秒、首次啟動耗時增加約10秒。

WadeDexMerge支援了強行跳過DexMerge與增量Merge兩種策略, 預設使用增量Merge。跳過DexMerge的實現比較簡單, 只需注意隨後的PackageTask只識別.dex, 而不能識別.jar, 要先處理DexBuild產物中的.jar檔案, 再和.dex產物一起拷貝到PackageTask的inputDir即可, 其中inputDir可以通過反射PackageAndroidArtifact.getDexFolders()獲得。這裡主要介紹WadeDexMerge增量編譯的實現。

主要實現

DexMerge輸入檔案有.jar和.dex, 輸出.dex檔案。增量實現的核心是對輸入檔案作分桶, 只對變更的桶Merge, 其他桶複用快取。

假設本次編譯只有Bucket0中一個檔案發生變更, 其他Bucket均無變化, 那麼只需對Bucket0做Merge。分桶後, 需要找出本次編譯相比於上次編譯變更了哪些檔案以及它們的變更型別。這個場景類似於經典演算法題“如何找出兩個陣列中不相同的元素?”,因此可以用快慢指標來計算檔案變更。

如圖,慢指標指向上次編譯的檔案陣列, 快指標指向本次編譯的檔案陣列, 對比兩個指標的檔案, 如果相同則快指標指向下一個檔案, 直到找到不同, 此時慢指標指向下一個檔案, 再開始下一輪對比。虛擬碼如下:

long fast = 0
long slow = 0
while (slow < prev.size()) {
long temp = fast
while (temp < curr.size()) {
if (prev[slow] == curr[temp]) {
break
}
temp++
}
if (temp != curr.size()) {
fast = temp
boolean isModified = isModified(prev[slow], curr[fast], reuseScope)
if (isModified) { //found difference
fileChanges.add(new DefaultFileChange(prev[slow], ChangeType.MODIFIED))
}
} else {//not found
fileChanges.add(new DefaultFileChange(prev[slow], ChangeType.REMOVED))
}
slow++
}

桶總數和桶內檔案數(Bucket Size)直接影響到增量效果。理論上, 分桶越多越好, 如果有100個Bucket, 相當於增量只需1/100的全量Merge時間。但Bucket越多意味著APK內.dex越多, 又會影響到包體積、安裝時間和首次啟動耗時。經過多次試驗, Bucket總數在50~100個時綜合效果最好, Merge耗時降低明顯, 副作用也不大。目前得物工程中共有66個Bucket, 其中Jar型別23個, Dex型別43個。

高可用

在高可用建設方面, 主要通過資料統計、建立編譯情況監控、編譯指標週報及時獲取大盤情況和發現問題; 相容不同AGP和Gradle版本以提高外掛的相容性; 持續監控編譯異常並迭代修復問題 提高穩定性。

七大指標

七個指標反映團隊的編譯總體情況:

  • 增量編譯耗時

  • 平均編譯耗時

  • 全量編譯耗時

  • 增量編譯耗時50分位值

  • 增量佔比

  • 編譯成功率

  • 人均編譯總時長

指標的計算依賴埋點資料上報, 埋點中部分欄位的值較難獲取。例如本次編譯的JavaCompileTask是否為增量, 需通過對AGP和Gradle插樁實現, 有三處Hook點可以切入。

Wade早期版本使用方案一, 實際使用發現Hook Gradle的類相容性較差。目前使用方案二, Hook AGP的 com.android.build.gradle.tasks.JavaCompileCreationAction 類, 注入 WadeJavaCompile 類代替原生的 org.gradle.api.tasks.compile.JavaCompile 類。 WadeJavacCompileJavaCompile 的包裝類, 重寫 compile() 取到Javac的增量標識 inputs.isIncremental . 虛擬碼如下: 

public class WadeJavaCompile extends JavaCompile {
...
private static File mFile;


@Override
protected void compile(IncrementalTaskInputs inputs) {
...
boolean isIncremental = inputs.isIncremental();
try {
FileUtils.writeStringToFile(mFile, "isIncremental:" + isIncremental + "\n", true);
} catch (IOException e) {
...
}
super.compile(inputs);
}


...
}

對AGP原生類的Hook過程大致可分為3步, 獲取Gradle的 VisitableURLClassLoader , 用ASM或Javassist編輯目標類的位元組碼, 反射呼叫 ClassLoader.defineClass() 載入編輯後的位元組碼。

VisitableURLClassLoader
apply wade plugin
apply android plugin

相容性

主要相容了AGP3和AGP4、Gradle5和Gradle6兩套版本。

外掛中的關鍵步驟如增量編譯觸發條件、反射獲取Consumer Transform、WadeDexMergeTask等都針對不同版本分別做了適配。

穩定性

實際使用過程中遇到了各種疑難雜症, 這裡列出前10個常見異常。

  1. java.io.IOException: The input doesn't contain any classes. Did you specify the proper '-injars' options?

  2. java.io.FileNotFoundException: /Users/panes/app/build/intermediates/compile_and_runtime_not_namespaced_r_class_jar/debug/R.jar (No such file or directory)

  1. Caused by: com.android.tools.r8.utils.b: Error:YeezyCompleteListener.class, Type com.xxx is defined multiple times

  2. Caused by: org.gradle.api.UncheckedIOException:java.util.zip.ZipException: error in opening zip file

  1. Caused by: com.android.tools.r8.utils.b: Error: Class content provided for type descriptor xxx.r actually defines class com.xxx.R

  2. A failure occurred while executing com.android.build.gradle.internal.tasks.Workers$ActionFacade

  1. com.android.builder.dexing.DexArchiveMergerException: Error while merging dex archives: Type com.xxx.R is defined multiple times

  2. base.apk code is missing

  1. Archive is not readable : /Users/panes/android/app/build/intermediates/mixed_scope_dex_archive/developerDebug/out/c6795cc73f81ff9c1c0b5d0adb06b1b4161c540cbf761ba11415aae4856b11b4_4.jar

  2. Could not determine dependencies of app:wadeInputChangesInspect

經過近30個版本的迭代, 這些問題都已解決。最近版本v2.6.4上線至今經歷6800次編譯, 異常次數4次。

基準測試  

Benchmark跑分顯示, 10次增量編譯(只改動一行程式碼)的平均耗時14.4秒, 10次無量編譯(程式碼不變)平均耗時6.2秒。跑分時清理後臺任務、關閉了其他佔用資源的程序, 但實際編譯環境比理想環境複雜得多, 基準測試只用於驗證理論是否有效。

總結

Wade Plugin開發過程中困難重重, 重寫Android原生的編譯流程做到既大幅提升速度又保證穩定可靠並非易事。其中還有更多細節未介紹到, 如增編時識別熱點程式碼、複用檔案變更計算結果、Hook PackageTask做Apk內檔案兜底防止出包異常。同時也期待後續版本能有更多提升。

耗時2年,Android進階三部曲第三部《Android進階指北》出版!

『BATcoder』做了多年安卓還沒編譯過原始碼?一個影片帶你玩轉!

『BATcoder』我去!安裝Ubuntu還有坑?

重生!進階三部曲第一部《Android進階之光》第2版 出版!

 BATcoder技術 群,讓一部分人先進大廠

大家 ,我是劉望舒,騰訊最具價值專家TVP,著有三本業內知名暢銷書,連續四年蟬聯電子工業出版社年度優秀作者,百度百科收錄的資深技術專家。

想要 加入  BATcoder技術群,公號回覆  即可。

為了防止失聯,歡迎關注我的小號

   微信改了推送機制,真愛請星標本公號 :point_down: