哔哩哔哩Android打包优化与云编译
本期作者
夏秋垒
移动技术部工程效率组资深开发工程师
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,似乎远程开发是个趋势。结合云端设备,或许也会有着不一样的开发体验。
以上是今天的分享内容,如果你有什么想法或疑问,欢迎大家在留言区与我们互动,如果喜欢本期内容的话,欢迎点个“在看”吧!