Android編譯提速黑科技—Wade Plugin
作者:潘濤
原文連結:https://mp。weixin。qq。com/s/414nz4T-_KyH42xo_mdj-w
隨著得物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
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
3。ErrorFormatMode
4。MinSdkVersion
5。Dexer
6。UseGradleWorkers
7。InBufferSize
8。Debuggable
9。Java8LangSupportType
10。ProjectVariant
11。NumberOfBuckets
12。DxNoOptimizeFlagPresent
Wade配置
13。WadeExtension。scope
14。WadeExtension。duplicateClass
15。WadeExtension。dexBucketSize
16。WadeExtension。jarBucketSize
Wade快取
17。ProjectWorkspaceDir
18。SubProjectWorkspaceDir
19。ExternalLibWorkspaceDir
20。MixedScopeWorkspaceDir
輸入檔案
21。ProjectClasses
22。SubProjectClasses
23。ExternalLibClasses
24。MixedScopeClasses
產物檔案
25。ProjectOutputDex
26。SubProjectOutputDex
27。ExternalLibOutputDex
28。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
類。
WadeJavacCompile
是
JavaCompile
的包裝類, 重寫
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()
載入編輯後的位元組碼。
Gradle程序和Gradle Daemon程序一般常駐後臺, Android Studio開啟後第一次編譯會觸發載入AGP類的位元組碼, 之後再編譯都不會觸發類載入, 所以只有一次Hook機會, 必須保證Hook的位元組碼比AGP“搶先”載入到
VisitableURLClassLoader
。因此, Wade外掛接入要求在Root Project中
apply wade plugin
, 以確保Hook程式碼能在App Project的
apply android plugin
之前執行。
相容性
主要相容了AGP3和AGP4、Gradle5和Gradle6兩套版本。
外掛中的關鍵步驟如增量編譯觸發條件、反射獲取Consumer Transform、WadeDexMergeTask等都針對不同版本分別做了適配。
穩定性
實際使用過程中遇到了各種疑難雜症, 這裡列出前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
http://
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內檔案兜底防止出包異常。同時也期待後續版本能有更多提升。
下一篇:請問怎麼練粗手腕?