本期作者
夏秋壘-移動技術部工程效率組資深開發工程師
01 背景介紹
B站使用大倉模式進行源碼依賴管理,大倉模有優勢也有挑戰,截止目前為止 Android 倉庫子子產品有620+,開發人員150+。
本地開發存在編譯慢、機器發熱、卡死、阻塞開發等問題。介于此前移動端已有龐大的 CI 建構叢集,我們探索出一種新的開發編譯方式——雲編譯。
02 原理簡介
俗話說性能不夠硬體來湊,得益于公司的高配置伺服器資源,我們移動端可以很友善地使用雲端資源提高編譯速度。
通過 git 同步本地與遠端代碼基點,結合 diff 檔案還原本地開發中産生的差異改動,然後編譯出與本地無差别的 APK 産物。
03 開荒時代
使用 Docker 自定義的 Android 建構鏡像,可快速複制多個打包機器執行個體。
開發同學本地使用提供的雲編譯指令行工具執行編譯動作,指令行工具開始計算 commit、生成 diff,然後合成打包請求發送到遠端。
遠端收到編譯請求以後,開始解析指令、下載下傳(同步)代碼、應用更新檔、執行編譯、傳回執行結果與編譯産物。
編譯成功後,本地下載下傳編譯産物(APK檔案),然後安裝并啟動。
整個流程與本地開發的差别為本地的代碼需要同步到遠端,打包的操作放在遠端,遠端執行成功後需下載下傳産物。
新開發方式有優勢也有不足。
優勢:
- 環境正确性
- 支援并行并發
- 編譯速度加速
- 解放本地機器(專注于邏輯編寫)
- 支援全源碼編譯 (非快遍模式,不使用緩存,全部使用源碼)
不足:
- 增加學習成本
- 部分任務需要本地編譯,用于代碼索引
- 本地增量編譯失效,僅使用遠端緩存
- 打包機更新與維護成本增加
- 機器完全随機配置設定,會有競争、等待情況出現
- 緩存使用率不高,編譯速度有提高的空間
04 持續優化與VIP模式
前期建構執行個體數為10個,可滿足一部分人使用,一段時間後大家覺的這種模式還不錯,相對于本地編譯,編譯速度還是有明顯的提升。
随着使用人數開始增多,開始出現機器競争、機器繁忙、任務等待等問題。大家吐槽調侃希望可以開通 VIP 模式,獨占某一台機器或者提高任務優先級。
原先的架構,用戶端與伺服器之間隻有一層 SLB 做反向代理,進行随機轉發。前後兩次打包任務可能配置設定的是不同機器,導緻需要重新下載下傳代碼,增加打包時間,也無法複用上一次的增量編譯緩存。
于是我們針對原先的架構模式,做了以下調整,并對打包流程和速度進行了優化。
優化打包速度,首先必須掌握整個打包流程與機制;其次需要衡量次元以及資料統計記錄,友善後期資料對比,指導優化方向;最後為了滿足日常問題的排查,需要一個管理背景記錄打包日志、監控執行個體狀态、修改配置與維護。
4.1 流程分析
打包流程主要包括打包環境準備、服務啟動、任務執行。以下針對各個階段列舉具體的優化措施。
- 編譯環境: 一般來說不經常改變,除非大版本更新、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 系統展示
打包記錄
執行個體清單
線上日志
05 分布式編譯
5.1 需求分析
随着業務發展子子產品變多,部分任務執行時間越來越長,影響整體編譯時長,編譯時間具有劣化的趨勢。常見耗時任務有 DexBuild 與 DexMerge,如下圖所示為某次首次冷編譯(本機無緩存),其中 dex 相關任務時長約占 1/3。
再次編譯的時候,DexBuild 有明顯的下降,但是 DexMerge 任然需要不少的時間。
從官網的編譯流程圖來,dex檔案就是從jar或class檔案通過指令轉換而來,同時 Android Sdk 中也提供 d8 指令來手動執行。
雲編譯系統是一個編譯叢集,每次一個編譯記錄隻能占用一台主機,是否可以把一些比較耗時長的任務拆分到其他空閑機器協同來編譯,然後再回傳編譯結果。
以下為 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 指令,執行成功回傳檔案,然後再放在目标位置。
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 任務重新執行,無法複用緩存。
基于實際場景,采用分治法,将輸入檔案采用取餘方式分組,每組再分發到遠端機器上執行,執行成功後再回傳結果。
Merge 操作經過分組合并有效的降低了任務執行時間,同時分組後支援自定義緩存。
實際項目中分組為21個,一般情況下,開發同學隻是修改局部部分檔案,再次編譯的時候,隻有其中1-2個分組有變動,隻需要重執行有過變動的分組即可,提高了執行效率。
相關日志
圖中所示,合并的檔案有971個,分成21組,其中19組使用了緩存,本次執行消耗5.8s。
編譯任務與遠端任務
5.2 其他
- d8本地與遠端執行
實操過程中發現大部分情況檔案越大 dex 執行時間越長,網絡傳輸是有損耗的,是以并不是所有的 dex 操作都值得分發到遠端,隻有超過一定門檻值的時候,才會分發到遠端。
通過統計與計算 build 過程輸入檔案需要大于1M, merge 過程輸入檔案需大于 3M,滿足這樣條件分發到遠端編譯會有不小的提速收益。
- 大檔案檔案分割
根據上一條,dex執行時間與檔案大小相關,實操過程發現部分jar檔案非常大,比如R.java合并後的 jar 有将近200M, 可以通過切片方式,把一個大的jar檔案分割成若幹較小的檔案,然後再進行 d8 處理,消耗時間會短很多。
- d8 優化
d8 實際上是一個 shell 執行 jar檔案的方式,可以通過 GraalVM 來轉成本地可執行檔案,也能有一定幅度的性能提升。
- 禁止原生緩存
原生 DexMerge 任務緩存命中率差,并且執行緩存過程也消耗不少時間,可以選擇性設定禁止緩存。
- 自定義共享緩存
如上所述,部分原生Gradle緩存機制效果差,DexBuild 與 DexBuild 操作可以采取自定義緩存方式,遠端在收到編譯任務可以先判斷是否有緩存,再做具體執行,同時再把執行結果緩存起來用來複用。
- 環境隔離
實操過程中,d8 編譯的結果有可能會有一些異常情況,可以采取單獨配置代碼目錄與 GradleUserHome目錄,正常編譯子產品與分布式編譯模式分開管理,友善區分以及快速降級。
- 手動降級
前期功能不穩定,需手動開啟。經過一個月測試功能比較穩定,已經預設開啟。如需關閉,手動主動關閉。
5.3 結果
經過一段時間觀察,目前功能穩定,有效的的解決dex執行緩慢問題,同時整體編譯速度維持在正常水準。
06 功能示範
本地打包指令為 ./gradlew :app:assembleDebug -q -s,雲編譯也類似 hub -b ":app:assembleDebug -q -s" --vip。
06 未來規劃
- 雲模拟器與雲裝置
通過伺服器強大的性能,模拟多個模拟器或裝置,用于開發、調試、測試。雲端裝置可以快速複制與銷毀,可以用于相容性測試與相容性開發。
- 雲IDE
最近推出Fleet, 以及 Visual Studio Code 和 IDEA 的 Remote Development,似乎遠端開發是個趨勢。結合雲端裝置,或許也會有着不一樣的開發體驗。
作者:夏秋壘
來源:微信公衆号:哔哩哔哩技術
出處:https://mp.weixin.qq.com/s/xls_AL9IyR3580zz8CfCOA