查看原文
其他

哔哩哔哩Android打包优化与云编译

夏秋垒 哔哩哔哩技术 2023-05-19

本期作者


夏秋垒

移动技术部工程效率组资深开发工程师


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,似乎远程开发是个趋势。结合云端设备,或许也会有着不一样的开发体验。


以上是今天的分享内容,如果你有什么想法或疑问,欢迎大家在留言区与我们互动,如果喜欢本期内容的话,欢迎点个“在看”吧!


您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存