
隨着得物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秒。
調用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配置
AndroidJarClasspath
DesugaringClasspathClasses
ErrorFormatMode
MinSdkVersion
Dexer
UseGradleWorkers
InBufferSize
Debuggable
Java8LangSupportType
ProjectVariant
NumberOfBuckets
DxNoOptimizeFlagPresent
Wade配置
WadeExtension.scope
WadeExtension.duplicateClass
WadeExtension.dexBucketSize
WadeExtension.jarBucketSize
Wade緩存
ProjectWorkspaceDir
SubProjectWorkspaceDir
ExternalLibWorkspaceDir
MixedScopeWorkspaceDir
輸入文件
ProjectClasses
SubProjectClasses
ExternalLibClasses
MixedScopeClasses
產物文件
ProjectOutputDex
SubProjectOutputDex
ExternalLibOutputDex
MixedScopeOutputDex
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秒。
優化方案
主要實現
如圖,慢指針指向上次編譯的文件數組, 快指針指向本次編譯的文件數組, 對比兩個指針的文件, 如果相同則快指針指向下一個文件, 直到找到不同, 此時慢指針指向下一個文件, 再開始下一輪對比。偽代碼如下:
桶總數和桶內文件數(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類。WadeJavacCompile是JavaCompile的包裝類, 重寫compile()取到Javac的增量標識inputs.isIncremental. 偽代碼如下:
對AGP原生類的Hook過程大致可分為3步, 獲取Gradle的VisitableURLClassLoader, 用ASM或Javassist編輯目標類的字節碼, 反射調用ClassLoader.defineClass()加載編輯後的字節碼。
兼容性
穩定性
實際使用過程中遇到了各種疑難雜症, 這裡列出前10個常見異常。
java.io.IOException: The input doesn't contain any classes. Did you specify the proper '-injars' options?
java.io.FileNotFoundException: /Users/panes/app/build/intermediates/compile_and_runtime_not_namespaced_r_class_jar/debug/R.jar (No such file or directory)
Caused by: com.android.tools.r8.utils.b: Error:YeezyCompleteListener.class, Type com.xxx is defined multiple times
Caused by: org.gradle.api.UncheckedIOException:java.util.zip.ZipException: error in opening zip file
Caused by: com.android.tools.r8.utils.b: Error: Class content provided for type descriptor xxx.r actually defines class com.xxx.R
A failure occurred while executing com.android.build.gradle.internal.tasks.Workers$ActionFacade
com.android.builder.dexing.DexArchiveMergerException: Error while merging dex archives: Type com.xxx.R is defined multiple times
base.apk code is missing
Archive is not readable : /Users/panes/android/app/build/intermediates/mixed_scope_dex_archive/developerDebug/out/c6795cc73f81ff9c1c0b5d0adb06b1b4161c540cbf761ba11415aae4856b11b4_4.jar
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內文件兜底防止出包異常。同時也期待後續版本能有更多提升。

推薦閱讀
嘔心整理 | 2021 Android 求職記錄
抖音客戶端創作體驗優化攻略
2022 Android 技術最新動向
進群加好友,技術聊不停

