Android 增量構建的科技與狠活

發表於2023-09-21
本文作者:jungle

描述

最近生活中大家遇到的科技與狠活較多,當android的構建用上科技與狠活會不會倒沫子呢,讓我們拭目以待。

前言

對於 Android 應用,尤其是大型應用而言,構建耗時是令人頭疼的一件事。動輒幾分鐘甚至十幾分鐘的時間更是令大部分開發人員苦不堪言。而在實際開發過程中面對最多的就是本地的增量編譯,雖然官方對增量編譯有做處理,但在具體專案,尤其是中大型專案中,效果其實都不太理想。

背景

目前網易雲音樂及旗下 look 直播,心遇,musapp 先後採取了公共模組 aar 化,使用最新 agp 版本等措施,但整體構建耗時依然很久,增量構建一般在 2-5 min 左右。由於本人當前主要是負責開發 mus 的業務,因此結合目前 mus 的實際構建情況對增量構建做了一些最佳化工作。

耗時排查

結合 mus 構建的具體情況來看,目前構建耗時的大頭主要集中在一些 TransformdexMerge ( agp 版本 4.2.1 )。

對於 Transform 而言,主要是一些例如隱私掃描,自動化埋點等工具耗時嚴重,通常增量時這些 Transform 的耗時就達到數分鐘。

另外 dexMeger 任務也是增量構建時的大頭,mus 增量 dexMerge 耗時約為 35-40s ,雲音樂 dexMerge 增量構建耗時約 90-100s 。

最佳化方向

對於大型專案而言,最耗時的基本就是 Transform 了,這些 Transform 一般分為以下兩類:

  1. 功能型 Transform,移除只會影響自己的功能部分,不影響構建產物和專案執行。例如:埋點校驗,隱私掃描。
  2. 強依賴型 Transform ,移除影響編譯或專案正常執行。這部分通常是在 apt 中採集一些資訊,然後在 Transform 執行時生成 class ,在執行時呼叫執行。

功能型 Transform 可以透過編譯開關和 debug/release 判斷,避免在開發時呼叫執行。對於強依賴的 Transform 可以透過位元組開源的 byteX 之類的工具將 Transform 流程拍平,對增量和全量編譯都有效果。但是 byteX 的侵入性較大,需要將現有的 Transform 改成位元組提供的 Transform 的子類。這裡我們採用一種修改構建輸入產物的輕量級方案來實現 Transform 增量構建的最佳化。

同時對於 dex 相關操作耗時的點,可以結合 dexMerge 的實際流程做增量最佳化,確保只有最小粒度的改動點會觸發 dexmerge 操作。

Trasnform 增量構建

雖然 mus 目前依賴的大部分 TransformisIncremental 配置返回 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 都是新建一個類實現 Trasnformtransform 方法,在 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();
      ...
}

透過 hookTransformInvocation#getInputs 返回的 JarInputDirectoryInput ,將 JarInputsDirectory 中未發生改變的產物移除。

img

經過上述最佳化後原來耗時幾十秒到幾分鐘的 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

實際的構建產物如下:

img

增量構建時,agp 會按照以下規則來執行 dexMerge 任務:

  1. 如果有 jar 檔案狀態發生變更或者被移除了,即對應狀態 CHANGED 或者 REMOVE ,這種情況所有的桶都要重新走 dexMerge 流程,通常預設的 bucket 數量是 16 個,也就是當構建時有一個jar檔案發生變更時,所有的輸入產物全部都會參與 dexMeger 流程。(雖然 d8 命令列工具對增量dexMeger 本身有一定最佳化,增量速度對比全量會有一定加快,但對於大型專案而言總體還是很慢。)

img

  1. 如果是隻有新增的 jar 或者 dex 發生改變的Directory,那麼會根據對應的包名獲取到對應的桶的陣列,只對找到的桶的陣列進行增量的打包,這也就是我們說的 dexMerge 本身的增量操作。

img

返回對應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 ,雲音樂等元件化工程,通常構建時只有殼工程是以資料夾的形式作為輸入產物在後續的 Transformdex 相關流程裡流轉,而子模組通常是以 jar 的形式參與構建,而我們實際開發中基本就是對各業務模組的改動,對應上述第一種情況,所有的桶全部會重新走的 dexMerger,而第二種情況只有改動殼工程程式碼或者新增依賴或者模組之類的才會命中,這種情況偏少可以不用考慮。

針對上述問題解決方法主要有兩種:

  1. 將所有的 jar 拆解為資料夾,這樣只有改動模組對應的分桶生效,但是這種問題在於哪怕只改動了一個模組中的兩個類,由於 bucket 是按照包名固定分在同一個桶裡,非相同包名則根據包名隨機分桶,很可能也會連帶著其他的 bucket 一起進行 dexMerger ,雖然可以適當擴大分桶的數量,但是同樣的,也沒法完全規避這種問題。
  2. 僅針對發生改變的輸入產物進行重新的 dexMerger,將新生成的 merge 後的 dex 打進 apk 或者移到裝置中確保執行時增量改變的這部分程式碼可以被執行。

為了確保最小化單元的 dex 參與後續的 dexMerge 流程,我們採用第二種方式作為 dexMerge 增量構建的方案。

增量構建產物的 dexMerge

透過 hook dexMerge 的關鍵流程,我們可以獲取到發生變化的 jar 檔案和包含 dex 的資料夾,然後把 dexMerge 輸入產物由原來的全部產物修改為我們 hook 之後的產物:

我們將所有發生變化的 dex 檔案彙總移動到臨時的檔案目錄內,然後將目標資料夾作為一個輸入產物即可,對於發生變更的 jar,我們也將其加到輸入的產物裡,然後繼續走原來的 dexMerge 流程。

打出來的增量 dex 產物如下:

img

同時我們需要變更增量 dexMerge 的輸出目錄,因為 dexMerger 正常執行時,在有程式碼修改的情況,所有的 bucket 都會被新的產物覆蓋,哪怕新的產物是空資料夾。如果不更改檔案目錄就會覆蓋掉之前全量打出的所有的 dex ,導致最終的 apk 包僅包含這次增量的 dex 從而無法正常執行。

同時由於每次增量構建變化的產物都不同,因此對每次構建產物的輸出目錄做了遞增,同樣是確保上次增量的產物不要被本次覆蓋掉,這裡每次的產物都對後續構建流程有作用,具體會在後續內容中說明。

當然,新的目錄具體放在哪裡,也跟我們選擇的方案有關係。

熱更新方案

因為有了增量的 dex,我們很容易聯想到熱更新的方案,即將增量構建出的 dex 推送到手機 sd 卡上,然後在執行時去動態載入。這種情況下增量 mergedex 產物放在哪個目錄下都可以,因為對後續構建流程已經沒有什麼太大影響了,影響的主要是執行時 dex 的載入邏輯。

1. 增量 dex 臨時產物

上述雖然有了增量的構建產物,但是為了執行時方便排序仍然會每次把當次編譯新增的 dex 移動到臨時目錄 pulledMergeDex 資料夾中。

img

然後透過 adb 每次批次清理裝置中臨時的 dex ,再將全部 pulledMergeDex 目錄下的 dex 推送到裝置中,這樣做的目的是為了確保裝置中 dex 的準確性,避免因為某次構建殘留的 dex 產物執行影響現有的程式碼邏輯。

img

2. 執行時動態載入 dex

由於 dex 的載入是按照 PathList 載入 dexElements 陣列的順序從前往後載入的,因此只要按照 dex 的熱更方案,在執行時反射替換 PathClassLoader 中的 dexElements 陣列,將之前推送到手機目錄中的陣列,按照倒序先排列好,然後再插入在 dexElements 陣列最前面即可,這裡熱更新的具體原理不再闡述。

接入專案中實測發現有些程式碼改動會不生效(主要是 ApplicationApplication 直接引用到的 class),具體原因應該是 Android N 對熱補丁的影響,本地在 AndroidMainfest 檔案中加了 safemode=true,但在實際裝置執行還是無效,不知道是不是現在裝置的版本不支援了。另外一種可行的方式就是類似 tinker 的解決方案對 Application 進行改造,然後透過另外的 ClassLoader 載入後續的 class 了。

Dex 重排方案

除了在執行時載入 dex,我們也可以嘗試在編譯時將增量的 dex 打包到 apk 中。

gradle 中對應的 task 都有對應的構建快取,如果我們增量的 dex 放置在一個隨機目錄中,後續的 task 例如 packageassemble 等檢測輸入產物沒有變化的情況下,是會直接走增量構建快取的,也就不會再執行了。而我們期望我們增量的 dex 被打進 apk 中,後續的 packagetask 必須要被執行。

這種情況下,構建產物的目錄就比較有講究了,我們可以取個巧,在之前 dexMeger 全量產物輸出的目錄下,增加一個 incremental 資料夾,專門做增量產物的 dexMeger,同樣的每次增量的產物在該檔案目錄下按照 index 遞增,這樣確保每次增量 dexMerge 的產物沒有衝突。

img

打包到 apk 中的 dex 同樣也是會按照 dex 的排列順序載入執行,因此我們需要將新增的 dex 在編譯時就排列在 apk 的最前面。 apkdex 的排序是在 package 任務中去執行的,因此我們需要嘗試去 hook package 的關鍵路徑,將我們新增的 dex 排在 Apkdex 陣列最前面。

Android Package 流程 hook

Android package 負責將之前打包流程中的所有產物彙總打包到最終對外輸出的 apk 產物裡,dex 自然也不例外。Android package 會結合產物的變化對 apk 中發生變更的檔案做更改,將 apk 中對比 CHANGED REMOVED 的檔案刪除,然後將構建產物中 ADDEDCHANGED 的產物重新新增到 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 檔案中。

img

我們這邊參考原來的 mapping 檔案,在每次編譯時,將構建產物中的 dex 路徑和該 dex 對應 apk 中的實際 dexpath classesX.dex 關聯起來做好 mapping ,然後存在單獨記錄的dex_mapping檔案裡。

img

每次增量編譯有新 mergedex 時,先將增量的 dex 按照 classes.dexclasses2.dex... 的順序排列,然後將 dex-mapping 中的構建產物和 apkdex 路徑的關係載入到記憶體中,按照原有的順序排列在增量的 dex 後面,最後透過 hook package 流程將變化的內容同步更新到 apk 檔案中。

整體流程如下圖:

img

apk 更新完成後,將最新的的 dexapkdex 路徑的 mapping 關係重新寫到 dex_mapping 檔案記錄最新的的 dexapk path 的關係。為了避免每次 dex 全部參與重排,可以在 classes.dexclassesN.dex 中預留一定數量的空位,避免每次所有 dex 重排。

實測 package 會有部分耗時增加,總體應該在 1s 以內,mus 整體 dexMerge 耗時由 35-40 s 縮減到3 s 左右。

目前該增量構建元件兩種方案都支援,可以根據開關配置,要注意的點是熱更的方案可能涉及到Application的改造。

最佳化效果

經過上述方案的最佳化,實測在 mus 中理想情況下更改子模組中一行最簡單的 kotlin 類中的一行程式碼 task 總耗時(不包含 configure )最快約 10s,實際開發情況來看基本在 20-40s 之間。這部分耗時主要是實際開發改動的 class 和模組會多一些,同時包含了configure 的耗時,這部分時間目前是無法避免的。同時也包含 class 編譯和 kapttask 一起的耗時,也會受到裝置的 cpu ,實時記憶體等影響。

img

以上資料基於個人電腦,2.3 GHz 四核 Intel Core i7,32 GB 3733 MHz LPDDR4X,不同裝置跑出的資料會有部分差異,但整體最佳化效果還是很明顯的。

總結

結合上述的最佳化方案,增量構建速度整體在一個比較低的水平,當然例如kotlin編譯,kapt,增量的判斷等還有進一步的最佳化空間,期待後續和其他 task 的進一步最佳化完成時繼續分享。

> 本文釋出自網易雲音樂技術團隊,文章未經授權禁止任何形式的轉載。我們常年招收各類技術崗位,如果你準備換工作,又恰好喜歡雲音樂,那就加入我們 grp.music-fe(at)corp.netease.com! 

相關文章