Android編譯提速黑科技—Wade Plugin
作者:潘濤

隨著得物App業(yè)務(wù)高速發(fā)展,Android項(xiàng)目的代碼量與組件數(shù)量迅速增加,項(xiàng)目編譯時(shí)長(zhǎng)也明顯升高。今年初增量編譯平均耗時(shí)接近3.9分鐘,嚴(yán)重影響了開(kāi)發(fā)效率,也促使我們探索各種措施縮短編譯時(shí)間提升開(kāi)發(fā)效率。
背景
???

????
四月初通過(guò)一系列常規(guī)優(yōu)化,如改造增量注解處理器、 增量Transform、組件化、工程化、優(yōu)化項(xiàng)目配置等等,耗時(shí)縮短到2.3分鐘。六月,Wade Plugin 第一版上線,進(jìn)一步縮短到1.3分鐘。八月, Wade第二個(gè)大版本上線,最終將增編耗時(shí)降低到了0.8分鐘。本文主要介紹Wade Plugin的技術(shù)原理和實(shí)現(xiàn)思路。
簡(jiǎn)介
Wade Plugin是得物Android自研的Gradle插件,用于提升編譯速度。常規(guī)優(yōu)化手段用盡以后, 項(xiàng)目的增編耗時(shí)仍需2.3分鐘, 其中DexArchiveBuilder、MergeProjectDex、 MergeLibDex、MergeExtDex占了1.7分鐘。不難看出,如果要進(jìn)一步降低耗時(shí), 應(yīng)該挖掘DexBuild和DexMerge的優(yōu)化空間。

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

WadeDexBuild?
調(diào)用Dx或D8工具完成Class到Dex的轉(zhuǎn)換這一過(guò)程稱為DexConvert, 它占了DexBuild大部分耗時(shí)。原生DexBuild以Jar和Class為粒度執(zhí)行DexConvert。得物工程中平均1個(gè)Jar包含200+個(gè).class, 相當(dāng)于增量時(shí)每改動(dòng)一個(gè)類會(huì)觸發(fā)200個(gè)類執(zhí)行DexConvert。

理想情況是只有改動(dòng)的Class參與DexConvert.?
優(yōu)化方案
在DexConvert執(zhí)行前, 解壓縮Jar, 以.class為粒度執(zhí)行DexConvert。并且只有其中變更的.class參與, 未變更的.class執(zhí)行結(jié)果復(fù)用上次編譯緩存。具體有四種變更類型對(duì)應(yīng)的緩存復(fù)用策略: 對(duì)于新增的.class, 需要參與DexConvert;改動(dòng)的.class參與DexConvert;移除的.class不參與DexConvert;未改變的也不參與。

其中, 移除.class的情況要特殊處理. 例如Demo.class移除后, 除了相應(yīng)刪除產(chǎn)物Demo.dex, 還要尋找它的內(nèi)部類的產(chǎn)物Demo$1.dex, Demo$2.dex等。
主要實(shí)現(xiàn)?
輸入(Task Inputs)?

首先要根據(jù)Consumer Transform決定參與WadeDexBuild的.class文件路徑, 消費(fèi)Transform Inputs的Transform即Consumer Transform。當(dāng)接入了一個(gè)Consumer Transform, 它的輸出路徑參與編譯; 沒(méi)有Consumer Transform時(shí), Java Compile、Kotlin Compile的輸出路徑參與編譯; 如有多個(gè)Consumer Transforms, 取最后一個(gè)Transform的輸出路徑作為DexBuild輸入。
增量編譯觸發(fā)條件
觸發(fā)條件決定了本次編譯是否走增量邏輯, 以及上次編譯的緩存是否可用。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
產(chǎn)物文件
ProjectOutputDex
SubProjectOutputDex
ExternalLibOutputDex
MixedScopeOutputDex?
Dex Convert?
WadeDexBuild關(guān)鍵步驟是將原生Dex Convert由Jar為粒度轉(zhuǎn)換為Class為粒度執(zhí)行。首先解壓縮Jar, 解壓后的.class寫入緩存目錄, 再將參與上次編譯的Class與參與本次編譯的Class文件逐個(gè)對(duì)比, 只有新增和變更的Class參與Dex Convert, 移除和未改變的直接刪除或沿用對(duì)應(yīng)緩存。

性能優(yōu)化

Dex Convert粒度由Jar轉(zhuǎn)換為Class后耗時(shí)明顯降低。但項(xiàng)目中共有423個(gè)Jar, 解壓后83000+個(gè)Class, 導(dǎo)致Dex Convert前解壓縮和文件對(duì)比兩個(gè)步驟非常耗時(shí)。對(duì)這兩步的優(yōu)化主要有三方面。
ForkJoinPool
用ForkJoinPool替代傳統(tǒng)的ExecutorService做并發(fā), 因?yàn)樗腤ork Steeling算法特別適合小文件, 任務(wù)數(shù)特別多的場(chǎng)景, 能夠最大化利用CPU空閑時(shí)間。
mmap
文件對(duì)比是I/O密集型任務(wù), 普通文件流的讀寫速度較慢。Wade Plugin所有I/O操作都用mmap實(shí)現(xiàn), 包括讀、寫、拷貝等。文件流替換為mmap對(duì)整體速度提升有很明顯的效果。
CRC-32代替MD5
對(duì)比兩文件是否相同的常規(guī)做法是先比較文件長(zhǎng)度, 再校驗(yàn)文件MD5是否一致。由于Class數(shù)量太多, 計(jì)算MD5的耗時(shí)非??捎^。用CRC-32算法計(jì)算文件Hash, 作為Checksum來(lái)代替MD5能減少文件對(duì)比的時(shí)間。
CRC-32計(jì)算的Checksum可靠性不如MD5, 理論上會(huì)有Hash碰撞, 導(dǎo)致修改Class修改后被誤判為未修改, 接著使用緩存而非最新文件參與編譯, 反映到產(chǎn)物APK上意味著這次修改無(wú)效。但是實(shí)際發(fā)生概率極低, 整體來(lái)看值得犧牲理論上的正確性來(lái)保證每次編譯的效率。
優(yōu)化效果
優(yōu)化后解壓縮、寫緩存平均耗時(shí)5700ms, 文件對(duì)比耗時(shí)得益于CRC-32算法只需10ms, DexBuild整體耗時(shí)從原生的60秒降低到12秒。

WadeDexMerge?
優(yōu)化方案
主要實(shí)現(xiàn)



如圖,慢指針指向上次編譯的文件數(shù)組, 快指針指向本次編譯的文件數(shù)組, 對(duì)比兩個(gè)指針的文件, 如果相同則快指針指向下一個(gè)文件, 直到找到不同, 此時(shí)慢指針指向下一個(gè)文件, 再開(kāi)始下一輪對(duì)比。偽代碼如下:
long fast = 0long slow = 0while (slow < prev.size()) {long temp = fastwhile (temp < curr.size()) {if (prev[slow] == curr[temp]) {break}temp++}if (temp != curr.size()) {fast = tempboolean isModified = isModified(prev[slow], curr[fast], reuseScope)if (isModified) { //found differencefileChanges.add(new DefaultFileChange(prev[slow], ChangeType.MODIFIED))}} else {//not foundfileChanges.add(new DefaultFileChange(prev[slow], ChangeType.REMOVED))}slow++}
桶總數(shù)和桶內(nèi)文件數(shù)(Bucket Size)直接影響到增量效果。理論上, 分桶越多越好, 如果有100個(gè)Bucket, 相當(dāng)于增量只需1/100的全量Merge時(shí)間。但Bucket越多意味著APK內(nèi).dex越多, 又會(huì)影響到包體積、安裝時(shí)間和首次啟動(dòng)耗時(shí)。經(jīng)過(guò)多次試驗(yàn), Bucket總數(shù)在50~100個(gè)時(shí)綜合效果最好, Merge耗時(shí)降低明顯, 副作用也不大。目前得物工程中共有66個(gè)Bucket, 其中Jar類型23個(gè), Dex類型43個(gè)。
高可用
在高可用建設(shè)方面, 主要通過(guò)數(shù)據(jù)統(tǒng)計(jì)、建立編譯情況監(jiān)控、編譯指標(biāo)周報(bào)及時(shí)獲取大盤情況和發(fā)現(xiàn)問(wèn)題; 兼容不同AGP和Gradle版本以提高插件的兼容性; 持續(xù)監(jiān)控編譯異常并迭代修復(fù)問(wèn)題提高穩(wěn)定性。
七大指標(biāo)

七個(gè)指標(biāo)反映團(tuán)隊(duì)的編譯總體情況:
增量編譯耗時(shí)
平均編譯耗時(shí)
全量編譯耗時(shí)
增量編譯耗時(shí)50分位值
增量占比
編譯成功率
人均編譯總時(shí)長(zhǎng)
指標(biāo)的計(jì)算依賴埋點(diǎn)數(shù)據(jù)上報(bào), 埋點(diǎn)中部分字段的值較難獲取。例如本次編譯的JavaCompileTask是否為增量, 需通過(guò)對(duì)AGP和Gradle插樁實(shí)現(xiàn), 有三處Hook點(diǎn)可以切入。

Wade早期版本使用方案一, 實(shí)際使用發(fā)現(xiàn)Hook Gradle的類兼容性較差。目前使用方案二, Hook AGP的com.android.build.gradle.tasks.JavaCompileCreationAction類, 注入WadeJavaCompile類代替原生的org.gradle.api.tasks.compile.JavaCompile類。WadeJavacCompile是JavaCompile的包裝類, 重寫compile()取到Javac的增量標(biāo)識(shí)inputs.isIncremental. 偽代碼如下:?
public class WadeJavaCompile extends JavaCompile {...private static File mFile;protected void compile(IncrementalTaskInputs inputs) {...boolean isIncremental = inputs.isIncremental();try {FileUtils.writeStringToFile(mFile, "isIncremental:" + isIncremental + "\n", true);} catch (IOException e) {...}super.compile(inputs);}...}
對(duì)AGP原生類的Hook過(guò)程大致可分為3步, 獲取Gradle的VisitableURLClassLoader, 用ASM或Javassist編輯目標(biāo)類的字節(jié)碼, 反射調(diào)用ClassLoader.defineClass()加載編輯后的字節(jié)碼。
VisitableURLClassLoader。因此, Wade插件接入要求在Root Project中apply wade plugin, 以確保Hook代碼能在App Project的apply android plugin之前執(zhí)行。兼容性

穩(wěn)定性
實(shí)際使用過(guò)程中遇到了各種疑難雜癥, 這里列出前10個(gè)常見(jiàn)異常。
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
經(jīng)過(guò)近30個(gè)版本的迭代, 這些問(wèn)題都已解決。最近版本v2.6.4上線至今經(jīng)歷6800次編譯, 異常次數(shù)4次。
基準(zhǔn)測(cè)試?

Benchmark跑分顯示, 10次增量編譯(只改動(dòng)一行代碼)的平均耗時(shí)14.4秒, 10次無(wú)量編譯(代碼不變)平均耗時(shí)6.2秒。跑分時(shí)清理后臺(tái)任務(wù)、關(guān)閉了其他占用資源的進(jìn)程, 但實(shí)際編譯環(huán)境比理想環(huán)境復(fù)雜得多, 基準(zhǔn)測(cè)試只用于驗(yàn)證理論是否有效。
總結(jié)
Wade Plugin開(kāi)發(fā)過(guò)程中困難重重, 重寫Android原生的編譯流程做到既大幅提升速度又保證穩(wěn)定可靠并非易事。其中還有更多細(xì)節(jié)未介紹到, 如增編時(shí)識(shí)別熱點(diǎn)代碼、復(fù)用文件變更計(jì)算結(jié)果、Hook PackageTask做Apk內(nèi)文件兜底防止出包異常。同時(shí)也期待后續(xù)版本能有更多提升。

? 耗時(shí)2年,Android進(jìn)階三部曲第三部《Android進(jìn)階指北》出版!
? 『BATcoder』做了多年安卓還沒(méi)編譯過(guò)源碼?一個(gè)視頻帶你玩轉(zhuǎn)!
? 重生!進(jìn)階三部曲第一部《Android進(jìn)階之光》第2版 出版!
?BATcoder技術(shù)群,讓一部分人先進(jìn)大廠
大家好,我是劉望舒,騰訊最具價(jià)值專家TVP,著有三本業(yè)內(nèi)知名暢銷書,連續(xù)四年蟬聯(lián)電子工業(yè)出版社年度優(yōu)秀作者,百度百科收錄的資深技術(shù)專家。
想要加入?BATcoder技術(shù)群,公號(hào)回復(fù)
BAT?即可。
為了防止失聯(lián),歡迎關(guān)注我的小號(hào)
??微信改了推送機(jī)制,真愛(ài)請(qǐng)星標(biāo)本公號(hào)??
