天天看點

哔哩哔哩Android打包優化與雲編譯

本期作者

夏秋壘-移動技術部工程效率組資深開發工程師

01 背景介紹

B站使用大倉模式進行源碼依賴管理,大倉模有優勢也有挑戰,截止目前為止 Android 倉庫子子產品有620+,開發人員150+。

本地開發存在編譯慢、機器發熱、卡死、阻塞開發等問題。介于此前移動端已有龐大的 CI 建構叢集,我們探索出一種新的開發編譯方式——雲編譯。

02 原理簡介

俗話說性能不夠硬體來湊,得益于公司的高配置伺服器資源,我們移動端可以很友善地使用雲端資源提高編譯速度。

哔哩哔哩Android打包優化與雲編譯

通過 git 同步本地與遠端代碼基點,結合 diff 檔案還原本地開發中産生的差異改動,然後編譯出與本地無差别的 APK 産物。

03 開荒時代

使用 Docker 自定義的 Android 建構鏡像,可快速複制多個打包機器執行個體。

開發同學本地使用提供的雲編譯指令行工具執行編譯動作,指令行工具開始計算 commit、生成 diff,然後合成打包請求發送到遠端。

哔哩哔哩Android打包優化與雲編譯

遠端收到編譯請求以後,開始解析指令、下載下傳(同步)代碼、應用更新檔、執行編譯、傳回執行結果與編譯産物。

編譯成功後,本地下載下傳編譯産物(APK檔案),然後安裝并啟動。

整個流程與本地開發的差别為本地的代碼需要同步到遠端,打包的操作放在遠端,遠端執行成功後需下載下傳産物。

哔哩哔哩Android打包優化與雲編譯

新開發方式有優勢也有不足。

優勢:

  • 環境正确性
  • 支援并行并發
  • 編譯速度加速
  • 解放本地機器(專注于邏輯編寫)
  • 支援全源碼編譯 (非快遍模式,不使用緩存,全部使用源碼)

不足:

  • 增加學習成本
  • 部分任務需要本地編譯,用于代碼索引
  • 本地增量編譯失效,僅使用遠端緩存
  • 打包機更新與維護成本增加
  • 機器完全随機配置設定,會有競争、等待情況出現
  • 緩存使用率不高,編譯速度有提高的空間

04 持續優化與VIP模式

前期建構執行個體數為10個,可滿足一部分人使用,一段時間後大家覺的這種模式還不錯,相對于本地編譯,編譯速度還是有明顯的提升。

随着使用人數開始增多,開始出現機器競争、機器繁忙、任務等待等問題。大家吐槽調侃希望可以開通 VIP 模式,獨占某一台機器或者提高任務優先級。

原先的架構,用戶端與伺服器之間隻有一層 SLB 做反向代理,進行随機轉發。前後兩次打包任務可能配置設定的是不同機器,導緻需要重新下載下傳代碼,增加打包時間,也無法複用上一次的增量編譯緩存。

于是我們針對原先的架構模式,做了以下調整,并對打包流程和速度進行了優化。

哔哩哔哩Android打包優化與雲編譯
哔哩哔哩Android打包優化與雲編譯

優化打包速度,首先必須掌握整個打包流程與機制;其次需要衡量次元以及資料統計記錄,友善後期資料對比,指導優化方向;最後為了滿足日常問題的排查,需要一個管理背景記錄打包日志、監控執行個體狀态、修改配置與維護。

4.1 流程分析

哔哩哔哩Android打包優化與雲編譯

打包流程主要包括打包環境準備、服務啟動、任務執行。以下針對各個階段列舉具體的優化措施。

  • 編譯環境: 一般來說不經常改變,除非大版本更新、SDK 更新、流程改變等。
  • Docker 鏡像制作可以參考 Docker 官方文檔來做參考,不過國内網絡情況都懂的,最好使用網絡代理或者鏡像,來加速鏡像制作時間。
  • 服務啟動: 優化期間,釋出頻率較高。
  • 因為服務綁定 Docker 鏡像,每次釋出都需要重新開機 Docker 執行個體,導緻一些緩存丢失,最好減少重新開機次數并增加緩存預熱。
  • 執行任務: 流程固定,Gradle 有完整的生命周期,有很大的優化空間。

4.2 優化措施

  • 增加執行個體數量與提高并發

前期每個服務配置為 10C50G,可以保證單人獨占,效果明顯。随着使用人數增多,會出現機器繁忙問題,增加機器數量與提供并發量迫在眉睫。

後期改成高低配兩種服務,10C50G 為單人模式,30C100G 為多人模式,最大可以支援3人并發打包,多人模式也可以共享緩存,加速效果明顯。

  • 網絡代理

Docker 鏡像下載下傳,Android SDK & NDK, Gradle,Maven 等可以使用國内源或者公司内部源來加速,效果顯著。

舉例,項目中一般會有 Gradle 各個版本下載下傳,可以放在公司内部存儲,内網速度一般為千兆網絡,下載下傳速度較快。

  • 避免執行個體重新開機

預設 Docker 執行個體會直接啟動打包,每次更新服務都需要更新 Docker 鏡像版本,服務執行個體重新開機會導緻所有的代碼、編譯緩存、SDK 等丢失。對打包速度較大,是以盡可能的減少服務重新開機次數。

  • 服務熱更新

但是如果遇到線上問題,釋出版本是不可以避免,減少服務重新開機明顯不科學。通過流程優化,使服務支援熱更新,進而避免了重新開機Docker 執行個體,相關緩存也不會丢失,可繼續使用。

  • 打包預熱

服務啟動時,可以先挂起,不對外提供服務,系統内部進行預熱處理,如預先下載下傳或者更新,執行打包若幹次,等預熱完成後,再進行打包,也打包速度會相對較高的提升。

  • 代碼倉庫預熱

相對于 CI 服務,每次編譯都會拉取代碼,然後再進行編譯。但是雲編譯不不适合此方式,B站大小倉代碼總量大概為5個G,按照千兆網絡來算,全部拉取也需要幾分鐘。

可使用 git 提供 worktree 的模式,可以預先拉去所有代碼,當需要打包時,可以快速切換代碼。

  • 保留工作目錄

雲編譯根據使用者名、機器裝置号、本地工作目錄三個次元計算一個hash,映射遠端工作目錄路徑,這樣每次可以快速還原本地代碼,執行打包操作,完成後不删除代碼供下次使用。

  • Gradle Remote Cache

Gradle 提供一種 Remote Cache 機制,需要一個緩存伺服器,第一次編譯完成後,上傳到緩存伺服器,再次打包,如果代碼沒有修改,可以直接使用下載下傳并使用緩存。

  • 智能排程與運維

雲編譯提供管理背景與網關,可以根據使用者打包頻次,合理配置設定機器。使用者每次執行打包,都會配置設定到指定機器的指定目錄,提高緩存使用率,避免機器出現搶占情況。

  • 網絡優化

項目開發一般為 debug 模式,APK 是未經優化的大小約為150M,開發同學使用的是 MacBookPro,大部分使用的是 Wi-Fi(百兆網絡),則下載下傳需要15s左右。切換成有線網絡(千兆網絡),則下載下傳隻需1-2s即可。

4.3 優化結果

随機模式:冷機打包 5-10min, 熱機打包 3-7min,平均打包速度 5min。

VIP模式:冷機打包 4-8min, 熱機打包 1.5-4min,平均打包速度 3min,極端情況20s可出包。

4.4 系統展示

打包記錄

哔哩哔哩Android打包優化與雲編譯

執行個體清單

哔哩哔哩Android打包優化與雲編譯

線上日志

哔哩哔哩Android打包優化與雲編譯

05 分布式編譯

5.1 需求分析

随着業務發展子子產品變多,部分任務執行時間越來越長,影響整體編譯時長,編譯時間具有劣化的趨勢。常見耗時任務有 DexBuild 與 DexMerge,如下圖所示為某次首次冷編譯(本機無緩存),其中 dex 相關任務時長約占 1/3。

哔哩哔哩Android打包優化與雲編譯

再次編譯的時候,DexBuild 有明顯的下降,但是 DexMerge 任然需要不少的時間。

哔哩哔哩Android打包優化與雲編譯

從官網的編譯流程圖來,dex檔案就是從jar或class檔案通過指令轉換而來,同時 Android Sdk 中也提供 d8 指令來手動執行。

哔哩哔哩Android打包優化與雲編譯

雲編譯系統是一個編譯叢集,每次一個編譯記錄隻能占用一台主機,是否可以把一些比較耗時長的任務拆分到其他空閑機器協同來編譯,然後再回傳編譯結果。

以下為 AGP 中源碼,通過傳入的參數進行指派準備,最後執行 D8.run(), 而 D8.run() 可以在SDK d8 工具中找到。

package com.android.builder.dexing;
 
// 部分代碼有删減處理,不代表全部源碼
final class D8DexArchiveBuilder extends DexArchiveBuilder {
     
    @Override
    public void convert(
            @NonNull Stream<ClassFileEntry> input,
            @NonNull Path output,
            @Nullable DependencyGraphUpdater<File> desugarGraphUpdater)
            throws DexArchiveBuilderException {
        D8DiagnosticsHandler d8DiagnosticsHandler = new InterceptingDiagnosticsHandler();
        try {
            D8Command.Builder builder = D8Command.builder(d8DiagnosticsHandler);
      
            // ....
            // 部分代碼有删減處理,不代表全部源碼
            // ....
            if (dexParams.getWithDesugaring()) {
                builder.addLibraryResourceProvider(dexParams.getDesugarBootclasspath().getOrderedProvider());
                builder.addClasspathResourceProvider(dexParams.getDesugarClasspath().getOrderedProvider());
 
                if (dexParams.getCoreLibDesugarConfig() != null) {
                    builder.addSpecialLibraryConfiguration(dexParams.getCoreLibDesugarConfig());
                    if (dexParams.getCoreLibDesugarOutputKeepRuleFile() != null) {
                        builder.setDesugaredLibraryKeepRuleConsumer(
                            new FileConsumer(dexParams.getCoreLibDesugarOutputKeepRuleFile().toPath()));
                    }
                }
                if (desugarGraphUpdater != null) {
                    builder.setDesugarGraphConsumer(new D8DesugarGraphConsumerAdapter(desugarGraphUpdater));
                }
            } else {
                builder.setDisableDesugaring(true);
            }
 
            D8.run(builder.build(), MoreExecutors.newDirectExecutorService());
        } catch (Throwable e) {
            throw getExceptionToRethrow(e, d8DiagnosticsHandler);
        }
    }
}           

可以在此增加一個 hook 點,把需要執行 DexBuild 操作的檔案分發到空閑的機器上面,然後遠端執行 d8 指令,執行成功回傳檔案,然後再放在目标位置。

哔哩哔哩Android打包優化與雲編譯

Hook部分代碼

/**
 * @see com.android.builder.dexing.D8DexArchiveBuilder.convert
 */
private fun hookBuilder() {
    val dst = pool.get("com.android.builder.dexing.D8DexArchiveBuilder")
    if (dst.isFrozen) {
        log.error("clazz ${dst.simpleName} is frozen")
        return
    }
 
    dst.getDeclaredMethod("convert").aopReplace(object : MethodInvokeCallback {
        override fun invoke(self: Any, method: String, args: List<Any?>) {
            // XbuildDexBuilder 再調用 MyD8DexArchiveBuilder
            XbuildDexBuilder().convert(
                self,
                args[0] as Stream<ClassFileEntry>,
                args[1] as Path,
                args[2] as DependencyGraphUpdater<File>?,
            )
        }
    })
}
 
/**
 * @see com.android.builder.dexing.D8DexArchiveBuilder
 */
public final class MyD8DexArchiveBuilder extends DexArchiveBuilder {
 
    @Override
    public void convert(
        @NonNull Stream<ClassFileEntry> input,
        @NonNull Path output,
        @Nullable DependencyGraphUpdater<File> desugarGraphUpdater)
    throws DexArchiveBuilderException {
        try {
            // ....
            // 部分代碼有删減處理,不代表全部源碼
            // ....
             
            // >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
            DexBuildArgs args = new DexBuildArgs(dexParams, input, output, entryCount.get(), entrySize.get());
            args.getEntryList().addAll(list);
            MyD8DexArchiveBuilderProxy.run(builder, MoreExecutors.newDirectExecutorService(), args);
 
            // D8.run(builder.build(), MoreExecutors.newDirectExecutorService());
            // >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
        } catch (Throwable e) {
            throw getExceptionToRethrow(e, d8DiagnosticsHandler);
        }
    }
}           

經過測試,在正常使用情況下(非高峰,否則無空閑機器),可以有效的降低任務時長。

同理 DexMerge 也可以做類似的 Hook,隻不過 merge 操作是把 m 個 dex 檔案合并成 n 個 dex檔案。

項目中的 merge 的輸入檔案将近1000個,并且每次修改代碼,就有可能導緻整個 merge 任務重新執行,無法複用緩存。

基于實際場景,采用分治法,将輸入檔案采用取餘方式分組,每組再分發到遠端機器上執行,執行成功後再回傳結果。

哔哩哔哩Android打包優化與雲編譯

Merge 操作經過分組合并有效的降低了任務執行時間,同時分組後支援自定義緩存。

實際項目中分組為21個,一般情況下,開發同學隻是修改局部部分檔案,再次編譯的時候,隻有其中1-2個分組有變動,隻需要重執行有過變動的分組即可,提高了執行效率。

哔哩哔哩Android打包優化與雲編譯

相關日志

哔哩哔哩Android打包優化與雲編譯

圖中所示,合并的檔案有971個,分成21組,其中19組使用了緩存,本次執行消耗5.8s。

編譯任務與遠端任務

哔哩哔哩Android打包優化與雲編譯

5.2 其他

  • d8本地與遠端執行

實操過程中發現大部分情況檔案越大 dex 執行時間越長,網絡傳輸是有損耗的,是以并不是所有的 dex 操作都值得分發到遠端,隻有超過一定門檻值的時候,才會分發到遠端。

通過統計與計算 build 過程輸入檔案需要大于1M, merge 過程輸入檔案需大于 3M,滿足這樣條件分發到遠端編譯會有不小的提速收益。

  • 大檔案檔案分割

根據上一條,dex執行時間與檔案大小相關,實操過程發現部分jar檔案非常大,比如R.java合并後的 jar 有将近200M, 可以通過切片方式,把一個大的jar檔案分割成若幹較小的檔案,然後再進行 d8 處理,消耗時間會短很多。

  • d8 優化

d8 實際上是一個 shell 執行 jar檔案的方式,可以通過 GraalVM 來轉成本地可執行檔案,也能有一定幅度的性能提升。

  • 禁止原生緩存

原生 DexMerge 任務緩存命中率差,并且執行緩存過程也消耗不少時間,可以選擇性設定禁止緩存。

哔哩哔哩Android打包優化與雲編譯
  • 自定義共享緩存

如上所述,部分原生Gradle緩存機制效果差,DexBuild 與 DexBuild 操作可以采取自定義緩存方式,遠端在收到編譯任務可以先判斷是否有緩存,再做具體執行,同時再把執行結果緩存起來用來複用。

  • 環境隔離

實操過程中,d8 編譯的結果有可能會有一些異常情況,可以采取單獨配置代碼目錄與 GradleUserHome目錄,正常編譯子產品與分布式編譯模式分開管理,友善區分以及快速降級。

  • 手動降級

前期功能不穩定,需手動開啟。經過一個月測試功能比較穩定,已經預設開啟。如需關閉,手動主動關閉。

5.3 結果

經過一段時間觀察,目前功能穩定,有效的的解決dex執行緩慢問題,同時整體編譯速度維持在正常水準。

06 功能示範

本地打包指令為 ./gradlew :app:assembleDebug -q -s,雲編譯也類似 hub -b ":app:assembleDebug -q -s" --vip。

哔哩哔哩Android打包優化與雲編譯
哔哩哔哩Android打包優化與雲編譯
哔哩哔哩Android打包優化與雲編譯

06 未來規劃

  • 雲模拟器與雲裝置

通過伺服器強大的性能,模拟多個模拟器或裝置,用于開發、調試、測試。雲端裝置可以快速複制與銷毀,可以用于相容性測試與相容性開發。

  • 雲IDE

最近推出Fleet, 以及 Visual Studio Code 和 IDEA 的 Remote Development,似乎遠端開發是個趨勢。結合雲端裝置,或許也會有着不一樣的開發體驗。

作者:夏秋壘

來源:微信公衆号:哔哩哔哩技術

出處:https://mp.weixin.qq.com/s/xls_AL9IyR3580zz8CfCOA

繼續閱讀