close

隨着得物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配置

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

其中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 = 0long slow = 0while (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 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 技術最新動向


進群加好友,技術聊不停

arrow
arrow
    全站熱搜
    創作者介紹
    創作者 鑽石舞台 的頭像
    鑽石舞台

    鑽石舞台

    鑽石舞台 發表在 痞客邦 留言(0) 人氣()