本文作者:jungle
描述
最近生活中大家遇到的科技與狠活較多,當android的構建用上科技與狠活會不會倒沫子呢,讓我們拭目以待。
前言
對於 Android
應用,尤其是大型應用而言,構建耗時是令人頭疼的一件事。動輒幾分鐘甚至十幾分鐘的時間更是令大部分開發人員苦不堪言。而在實際開發過程中面對最多的就是本地的增量編譯,雖然官方對增量編譯有做處理,但在具體專案,尤其是中大型專案中,效果其實都不太理想。
背景
目前網易雲音樂及旗下 look
直播,心遇,mus
等 app
先後採取了公共模組 aar
化,使用最新 agp
版本等措施,但整體構建耗時依然很久,增量構建一般在 2-5 min
左右。由於本人當前主要是負責開發 mus
的業務,因此結合目前 mus
的實際構建情況對增量構建做了一些最佳化工作。
耗時排查
結合 mus
構建的具體情況來看,目前構建耗時的大頭主要集中在一些 Transform
和 dexMerge
( agp 版本 4.2.1 )。
對於 Transform
而言,主要是一些例如隱私掃描,自動化埋點等工具耗時嚴重,通常增量時這些 Transform
的耗時就達到數分鐘。
另外 dexMeger
任務也是增量構建時的大頭,mus
增量 dexMerge
耗時約為 35-40s ,雲音樂 dexMerge
增量構建耗時約 90-100s 。
最佳化方向
對於大型專案而言,最耗時的基本就是 Transform
了,這些 Transform
一般分為以下兩類:
- 功能型
Transform
,移除只會影響自己的功能部分,不影響構建產物和專案執行。例如:埋點校驗,隱私掃描。 - 強依賴型
Transform
,移除影響編譯或專案正常執行。這部分通常是在apt
中採集一些資訊,然後在Transform
執行時生成class
,在執行時呼叫執行。
功能型 Transform
可以透過編譯開關和 debug/release
判斷,避免在開發時呼叫執行。對於強依賴的 Transform
可以透過位元組開源的 byteX
之類的工具將 Transform
流程拍平,對增量和全量編譯都有效果。但是 byteX
的侵入性較大,需要將現有的 Transform
改成位元組提供的 Transform
的子類。這裡我們採用一種修改構建輸入產物的輕量級方案來實現 Transform
增量構建的最佳化。
同時對於 dex
相關操作耗時的點,可以結合 dexMerge
的實際流程做增量最佳化,確保只有最小粒度的改動點會觸發 dex
的 merge
操作。
Trasnform 增量構建
雖然 mus
目前依賴的大部分 Transform
的 isIncremental
配置返回 true
,但是實際的 io
和插樁很少有做增量邏輯的。
在增量構建時,大部分 class
在第一次構建時已經經過各 Transform
的處理,被插樁修改後移動到對應的下一級 Transform
目錄了,增量時這部分已經處理過的產物其實沒有必要再在各 Transform
之間執行插樁和 io
了。
目前大部分 Transform
的寫法都是如下寫法:
input.jarInputs.each { JarInput jarInput ->
ile destFile = transformInvocation.getOutputProvider().getContentLocation(destName , jarInput.contentTypes, jarInput.scopes, Format.JAR)
FileUtils.copyFile(srcFile, destFile)
}
input.directoryInputs.each { DirectoryInput directoryInput ->
File destFile = transformInvocation.getOutputProvider().getContentLocation(directoryInput.name, directoryInput.contentTypes, directoryInput.scopes, Format.DIRECTORY)
...
FileUtils.copyDirectory(directoryInput.file, destFile)
}
這裡在增量構建時應該做的是隻對發生變化的產物做插樁和 copy
的操作:
// 虛擬碼如下:
// jar 增量處理
if(!isIncremental) return
if (Status.ADDED ==jarInput.status || Status.CHANGED==jarInput.status){
File destFile = transformInvocation.getOutputProvider().getContentLocation(destName , jarInput.contentTypes, jarInput.scopes, Format.JAR)
FileUtils.copyFile(srcFile, destFile)
}
// class 增量處理
val dest = outputProvider!!.getContentLocation(
directoryInput.name, directoryInput.contentTypes,
directoryInput.scopes, Format.DIRECTORY
)
if(Status.ADDED ==dirInput.status || Status.CHANGED==dirInput.status){
dirInput.changedFiles.forEach{
// 插樁邏輯
...
// 只移動增量變化插樁後的class檔案到對應目錄下
copyFileToTarger(it,dest)
}
}
當然由於一些歷史原因,有些 Transform
的程式碼可能都找不到,無法改造,因此為了相容所有情況,這邊簡單對 Transform
的輸入產物做了簡單的 hook
替換操作。
通常實現一個 Transform
都是新建一個類實現 Trasnform
的 transform
方法,在 transform
方法裡執行具體操作,而 Trasnform
產物的入參正是在 com.android.build.api.transform.TransformInvocation#getInputs
的方法裡:
public interface TransformInvocation {
Context getContext();
/**
* Returns the inputs/outputs of the transform.
* @return the inputs/outputs of the transform.
*/
@NonNull
Collection<TransformInput> getInputs();
...
}
透過 hook
掉 TransformInvocation#getInputs
返回的 JarInput
和 DirectoryInput
,將 JarInputs
和 Directory
中未發生改變的產物移除。
經過上述最佳化後原來耗時幾十秒到幾分鐘的 Transform
基本都能被壓縮到1-2 s以內。
DexMerge 增量最佳化
事實上 agp
版本更新非常頻繁,對於不同版本,dex
耗時不同。對於 3.x 的版本 dex
相關 task
主要耗時集中在dexBuilder
上,而4.x的版本主要耗時則集中在dexMerger
,由於目前 mus
等業務都使用 4.2 及以上版本的 agp
,研究發現 4.x 的版本實際上對 dexBuilder
有做了增量的處理,整體耗時不多,因此主要對4.2及以上版本 dexMerger
耗時做最佳化。
顧名思義,dexMerge
實際上是對已經打出的 dex
進行合併,將多個dex
或者 jar
合成一個較大的 dex
的流程。按照正常情況,dex
數量越多,應用的啟動速度越慢,因此對於大型專案,dexMerge
也是必不可少的一步。
dexMerge 流程
dexMerger
是有分桶操作的,桶的數量一般不額外配置使用預設值 16,通常桶的分配邏輯是按照包名來的,也就是說同一包名下的 class
會被分配到同一個桶裡。
fun getBucketNumber(relativePath: String, numberOfBuckets: Int): Int {
...
val packagePath = File(relativePath).parent
return if (packagePath.isNullOrEmpty()) {
0
} else {
when (numberOfBuckets) {
1 -> 0
else -> {
// 同一包名下class被分到同一個bucket裡
val normalizedPackagePath = File(packagePath).invariantSeparatorsPath
return abs(normalizedPackagePath.hashCode()) % (numberOfBuckets - 1) + 1
}
}
}
}
public val File.invariantSeparatorsPath: String
get() = if (File.separatorChar != '/') path.replace(File.separatorChar, '/') else path
實際的構建產物如下:
增量構建時,agp
會按照以下規則來執行 dexMerge
任務:
- 如果有
jar
檔案狀態發生變更或者被移除了,即對應狀態CHANGED
或者REMOVE
,這種情況所有的桶都要重新走dexMerge
流程,通常預設的bucket
數量是 16 個,也就是當構建時有一個jar
檔案發生變更時,所有的輸入產物全部都會參與dexMeger
流程。(雖然d8
命令列工具對增量dexMeger
本身有一定最佳化,增量速度對比全量會有一定加快,但對於大型專案而言總體還是很慢。)
- 如果是隻有新增的
jar
或者dex
發生改變的Directory
,那麼會根據對應的包名獲取到對應的桶的陣列,只對找到的桶的陣列進行增量的打包,這也就是我們說的dexMerge
本身的增量操作。
返回對應bucket id 陣列的程式碼如下:
private fun getImpactedBuckets(
fileChanges: SerializableFileChanges,
numberOfBuckets: Int
): Set<Int> {
val hasModifiedRemovedJars =
(fileChanges.modifiedFiles + fileChanges.removedFiles)
.find { isJarFile(it.file) } != null
if (hasModifiedRemovedJars) {
// 1. 如果有CHANGED或者REMOVE狀態的jar,則返回全部bucket陣列。
return (0 until numberOfBuckets).toSet()
}
// 2. 如果是新增jar,或者是directory中class發生變化,返回計算到的bucket陣列。
val addedJars = (fileChanges.addedFiles).map { it.file }.filter { isJarFile(it) }
val relativePathsOfDexFilesInAddedJars =
addedJars.flatMap { getSortedRelativePathsInJar(it, isDexFile) }
val relativePathsOfChangedDexFilesInDirs =
fileChanges.fileChanges.map { it.normalizedPath }.filter { isDexFile(it) }
return (relativePathsOfDexFilesInAddedJars + relativePathsOfChangedDexFilesInDirs)
.map { getBucketNumber(it, numberOfBuckets) }.toSet()
}
這種增量操作適用的是大部分程式碼囊括在殼工程中且不會頻繁改動底層庫的業務,不知道是不是因為國外包括 google
官方本身專案開發模式就是這樣。對於大部分國內的專案,只要你做了元件化,甚至沒做業務元件化但是有多個子模組型別的專案,只要有涉及到子模組的改動,所有的產物都要全部重新參與 dexMerge
。
對於 mus
,雲音樂等元件化工程,通常構建時只有殼工程是以資料夾的形式作為輸入產物在後續的 Transform
和 dex
相關流程裡流轉,而子模組通常是以 jar
的形式參與構建,而我們實際開發中基本就是對各業務模組的改動,對應上述第一種情況,所有的桶全部會重新走的 dexMerger
,而第二種情況只有改動殼工程程式碼或者新增依賴或者模組之類的才會命中,這種情況偏少可以不用考慮。
針對上述問題解決方法主要有兩種:
- 將所有的
jar
拆解為資料夾,這樣只有改動模組對應的分桶生效,但是這種問題在於哪怕只改動了一個模組中的兩個類,由於bucket
是按照包名固定分在同一個桶裡,非相同包名則根據包名隨機分桶,很可能也會連帶著其他的bucket
一起進行dexMerger
,雖然可以適當擴大分桶的數量,但是同樣的,也沒法完全規避這種問題。 - 僅針對發生改變的輸入產物進行重新的
dexMerger
,將新生成的merge
後的dex
打進apk
或者移到裝置中確保執行時增量改變的這部分程式碼可以被執行。
為了確保最小化單元的 dex
參與後續的 dexMerge
流程,我們採用第二種方式作為 dexMerge
增量構建的方案。
增量構建產物的 dexMerge
透過 hook
dexMerge
的關鍵流程,我們可以獲取到發生變化的 jar
檔案和包含 dex
的資料夾,然後把 dexMerge
輸入產物由原來的全部產物修改為我們 hook
之後的產物:
我們將所有發生變化的 dex
檔案彙總移動到臨時的檔案目錄內,然後將目標資料夾作為一個輸入產物即可,對於發生變更的 jar
,我們也將其加到輸入的產物裡,然後繼續走原來的 dexMerge
流程。
打出來的增量 dex
產物如下:
同時我們需要變更增量 dexMerge
的輸出目錄,因為 dexMerger
正常執行時,在有程式碼修改的情況,所有的 bucket
都會被新的產物覆蓋,哪怕新的產物是空資料夾。如果不更改檔案目錄就會覆蓋掉之前全量打出的所有的 dex
,導致最終的 apk
包僅包含這次增量的 dex
從而無法正常執行。
同時由於每次增量構建變化的產物都不同,因此對每次構建產物的輸出目錄做了遞增,同樣是確保上次增量的產物不要被本次覆蓋掉,這裡每次的產物都對後續構建流程有作用,具體會在後續內容中說明。
當然,新的目錄具體放在哪裡,也跟我們選擇的方案有關係。
熱更新方案
因為有了增量的 dex
,我們很容易聯想到熱更新的方案,即將增量構建出的 dex
推送到手機 sd
卡上,然後在執行時去動態載入。這種情況下增量 merge
的 dex
產物放在哪個目錄下都可以,因為對後續構建流程已經沒有什麼太大影響了,影響的主要是執行時 dex
的載入邏輯。
1. 增量 dex 臨時產物
上述雖然有了增量的構建產物,但是為了執行時方便排序仍然會每次把當次編譯新增的 dex
移動到臨時目錄 pulledMergeDex
資料夾中。
然後透過 adb
每次批次清理裝置中臨時的 dex
,再將全部 pulledMergeDex
目錄下的 dex
推送到裝置中,這樣做的目的是為了確保裝置中 dex
的準確性,避免因為某次構建殘留的 dex
產物執行影響現有的程式碼邏輯。
2. 執行時動態載入 dex
由於 dex
的載入是按照 PathList
載入 dexElements
陣列的順序從前往後載入的,因此只要按照 dex
的熱更方案,在執行時反射替換 PathClassLoader
中的 dexElements
陣列,將之前推送到手機目錄中的陣列,按照倒序先排列好,然後再插入在 dexElements
陣列最前面即可,這裡熱更新的具體原理不再闡述。
接入專案中實測發現有些程式碼改動會不生效(主要是 Application
和 Application
直接引用到的 class
),具體原因應該是 Android N 對熱補丁的影響,本地在 AndroidMainfest
檔案中加了 safemode=true
,但在實際裝置執行還是無效,不知道是不是現在裝置的版本不支援了。另外一種可行的方式就是類似 tinker
的解決方案對 Application
進行改造,然後透過另外的 ClassLoader
載入後續的 class
了。
Dex 重排方案
除了在執行時載入 dex
,我們也可以嘗試在編譯時將增量的 dex
打包到 apk
中。
gradle
中對應的 task
都有對應的構建快取,如果我們增量的 dex
放置在一個隨機目錄中,後續的 task
例如 package
,assemble
等檢測輸入產物沒有變化的情況下,是會直接走增量構建快取的,也就不會再執行了。而我們期望我們增量的 dex
被打進 apk
中,後續的 package
等 task
必須要被執行。
這種情況下,構建產物的目錄就比較有講究了,我們可以取個巧,在之前 dexMeger
全量產物輸出的目錄下,增加一個 incremental
資料夾,專門做增量產物的 dexMeger
,同樣的每次增量的產物在該檔案目錄下按照 index
遞增,這樣確保每次增量 dexMerge
的產物沒有衝突。
打包到 apk
中的 dex
同樣也是會按照 dex
的排列順序載入執行,因此我們需要將新增的 dex
在編譯時就排列在 apk
的最前面。 apk
中 dex
的排序是在 package
任務中去執行的,因此我們需要嘗試去 hook
package
的關鍵路徑,將我們新增的 dex
排在 Apk
內 dex
陣列最前面。
Android Package 流程 hook
Android package
負責將之前打包流程中的所有產物彙總打包到最終對外輸出的 apk
產物裡,dex
自然也不例外。Android package
會結合產物的變化對 apk
中發生變更的檔案做更改,將 apk
中對比 CHANGED
和 REMOVED
的檔案刪除,然後將構建產物中 ADDED
和 CHANGED
的產物重新新增到 apk
中去。
public void updateFiles() throws IOException {
// Calculate packagedFileUpdates
List<PackagedFileUpdate> packagedFileUpdates = new ArrayList<>();
// dex 檔案的變更
packagedFileUpdates.addAll(mDexRenamer.update(mChangedDexFiles));
...
deleteFiles(packagedFileUpdates);
...
addFiles(packagedFileUpdates);
}
private void deleteFiles(@NonNull Collection<PackagedFileUpdate> updates) throws IOException {
// 當前 CHANGED REMOVED 狀態的檔案 先移除apk
Predicate<PackagedFileUpdate> deletePredicate =
mApkCreatorType == ApkCreatorType.APK_FLINGER
? (p) -> p.getStatus() == REMOVED || p.getStatus() == CHANGED
: (p) -> p.getStatus() == REMOVED;
...
for (String deletedPath : deletedPaths) {
getApkCreator().deleteFile(deletedPath);
}
}
private void addFiles(@NonNull Collection<PackagedFileUpdate> updates) throws IOException {
// NEW CHANGED 狀態的檔案 新增進apk
Predicate<PackagedFileUpdate> isNewOrChanged =
pfu -> pfu.getStatus() == FileStatus.NEW || pfu.getStatus() == CHANGED;
...
for (File arch : archives) {
getApkCreator().writeZip(arch, pathNameMap::get, name -> !names.contains(name));
}
}
檔案關係則透過 DexIncrementalRenameManager
來維護,DexIncrementalRenameManager
每次會先去 dex-renamer-state.txt
去載入當前的 dex mapping
關係,結合變更的 dex
去對 apk
中檔案做更改,同時每次排序完成後會將新的 dex mapping
更新在 dex-renamer-state.txt
檔案中。
我們這邊參考原來的 mapping
檔案,在每次編譯時,將構建產物中的 dex
路徑和該 dex
對應 apk
中的實際 dex
的 path
classesX.dex
關聯起來做好 mapping
,然後存在單獨記錄的dex_mapping
檔案裡。
每次增量編譯有新 merge
的 dex
時,先將增量的 dex
按照 classes.dex
,classes2.dex
... 的順序排列,然後將 dex-mapping
中的構建產物和 apk
中 dex
路徑的關係載入到記憶體中,按照原有的順序排列在增量的 dex
後面,最後透過 hook
package
流程將變化的內容同步更新到 apk
檔案中。
整體流程如下圖:
在 apk
更新完成後,將最新的的 dex
和 apk
中 dex
路徑的 mapping
關係重新寫到 dex_mapping
檔案記錄最新的的 dex
和 apk path
的關係。為了避免每次 dex
全部參與重排,可以在 classes.dex
和 classesN.dex
中預留一定數量的空位,避免每次所有 dex
重排。
實測 package
會有部分耗時增加,總體應該在 1s 以內,mus
整體 dexMerge
耗時由 35-40 s 縮減到3 s 左右。
目前該增量構建元件兩種方案都支援,可以根據開關配置,要注意的點是熱更的方案可能涉及到Application
的改造。
最佳化效果
經過上述方案的最佳化,實測在 mus
中理想情況下更改子模組中一行最簡單的 kotlin
類中的一行程式碼 task
總耗時(不包含 configure
)最快約 10s,實際開發情況來看基本在 20-40s 之間。這部分耗時主要是實際開發改動的 class
和模組會多一些,同時包含了configure
的耗時,這部分時間目前是無法避免的。同時也包含 class
編譯和 kapt
等 task
一起的耗時,也會受到裝置的 cpu
,實時記憶體等影響。
以上資料基於個人電腦,2.3 GHz 四核 Intel Core i7,32 GB 3733 MHz LPDDR4X,不同裝置跑出的資料會有部分差異,但整體最佳化效果還是很明顯的。
總結
結合上述的最佳化方案,增量構建速度整體在一個比較低的水平,當然例如kotlin編譯,kapt,增量的判斷等還有進一步的最佳化空間,期待後續和其他 task
的進一步最佳化完成時繼續分享。
> 本文釋出自網易雲音樂技術團隊,文章未經授權禁止任何形式的轉載。我們常年招收各類技術崗位,如果你準備換工作,又恰好喜歡雲音樂,那就加入我們 grp.music-fe(at)corp.netease.com!