干货 | 掌控 Android 编译利器,携程火车票AAR 编译速度优化实践

2023-05-06 00:00:00 编译 模块 依赖 构建 资源

一、 背景

Android 项目一般使用 Gradle 作为构建打包工具,随着业务需求的不断迭代,代码量提升的同时,Gradle 编译耗时也在不断的增长,而编译速度会直接决定开发流程效率的高低,影响面主要涉及到开发和测试阶段。 

对于火车票项目,经过长期的迭代过程导致模块众多工程庞大,优化前一次干净的全量编译时间可达到10m39s,造成开发和测试都需要长时间等待编译出包,严重影响到开发和测试的效率。因此对火车票 App 进行编译速度优化是件亟待解决的事情。

本次编译速度优化采用的方案是模块AAR方案, 优化目标为: 优化后一次干净的全量编译时间缩减为原来编译时间的50%以下。

二、 模块AAR方案介绍

Google 官网提供了优化构建速度的几种方案,但基本上效果都不明显。业内常用的编译加速方案一般有模块aar、控制增量编译、transform 优化、dexBuilder 优化等,其中有些方案侵入性太强或者 ROI 不高,模块aar方案侵入性低,预计收益显著,并且在大厂已经成为标配*方案,可以作为本次编译优化的主方向。

AAR(Android ARchive)文件为 Android 库编译产物,其中包括 class、资源和清单文件,可用作 Android 应用模块依赖项。在Android 的构建流程中,源码编译时会将依赖的 aar 库和应用模块合并,在通过 apkbuilder 输出 apk 产物。

 图1:android的构建流程图

Android 项目大多都是多模块 Gradle 项目,构建项目时所有的子模块需要参与编译,导致全量编译时间冗长。实际上,对于大部分的开发人员来说,并不需要关注所有的模块,只需要负责功能相关的业务模块即可,因此这也提供了模块aar方案的切入点。

一般来说,aar方案都大同小异,涉及到模块aar发布,project和module依赖的切换,传递依赖的处理等几个方面。依赖切换我们采用的是 Gradle 官方文档中直接自定义依赖项的解析方案,通过定义依赖替换规则来更改依赖项;aar的发布使用的是maven-publish插件;传递依赖使用的mavenPom文件管理。

火车票项目经过多次模块化的重构后现有20个子模块,可将这些子模块独立打包aar并发布至远程maven仓库中,在需要打包时用aar替换项目的构建,就能将编译的时间给节省下来,以提高打包效率。 

图2: 模块aar开发示意图

三、改造过程和遇到的问题

3.1 构建测量指标

良好的优化基于数据的呈现,首先步需要做的是分析构建的性能。此处采用的是官方提供的Gradle --profile选项的方式统计数据。基本步骤为:

  • gradlew clean在每个build之间执行干净编译,确保剖析完整的构建流程;
  • gradlew--profile--offline--rerun-tasks assembleFlavorDebug为某个变种task启用性能剖析,并确保gradle使用的是缓存依赖项,且强制gradle重新运行所有任务;
  • 构建完成后,会生成html格式的性能剖析报告,可用于分析构建信息和前后构建对比;

3.2 模块aar的改造

因存在单个模块发布的过程,模块间需要尽量的解耦,而解耦首先要进行模块的划分;其次,存在依赖关系的模块间不能互相使用资源,这会造成编译的失败,所以理清模块间的关系是当务之急。

同时,我们需意识到当我们完成 Gradle 脚本的编写后,我们得到的应该是一个app壳+其他模块的依赖树组合,可通过以下步骤进行改造:

1)模块间依赖树重构

首先分析项目各模块依赖关系, 可使用开源脚本工具projectDependencyGraph.gradle生成依赖图,示意图如下:

 图3: 模块间依赖树混乱示意图

可直观的看出模块间依赖关系冗余,模块间的依赖不清晰,且基础库一般固定却呈现网状结构。故需对网状结构进行整理,将不经常改动的库直接发布远程,如下:

 图4: 模块间依赖树整理示意图

2)历史Gradle脚本重构

因历史原因,火车票项目的 Gradle 逻辑冗余繁琐,无统一管理略显臃肿。为提高 Gradle 的扩展性和灵活性,需要重构 Gradle 代码:

  • 对现有 Gradle 脚本按功能职责拆分成单个Gradle文件,需要使用的地方通过apply关键字应用即可;
  • 基于原插件com.android.applicationcom.android.library建立两套基准Gradle文件,统一控制不同类型的模块;
  • 抽取共有基础依赖项作为独立的 Gradle 文件,在subprojects中让所有模块都应用这些依赖项; 
  • 整理libs目录中本地的aar文件,移动至一个目录,统一依赖本地aar;
  • 建立app_bundle.json文件记录所有模块项目的信息及类型,便于模块项目是源码还是aar的切换控制;
  • 开发一套模拟参数机制,支持合并环境输入参数和local.properties参数 以便于 Gradle 功能的控制;如开启某个transform,指定哪些模块为aar等功能;
  • 重构 flavor 变体功能,动态控制 flavor 的创建。

3)发布和依赖aar版本的控制

当我们发布aar至远程仓库后,需要一个文件记录已发布模块aar的版本号,打包时通过此文件查找对应版本的aar,此处使用的是 MCD 平台采用 json 格式的 versionPath 文件。示意图如下:

 图5:通过versionPath文件控制版本

模块aar的核心在于对依赖项的控制。我们想要的是一个app壳+部分源码模块+部分aar模块+其他依赖的结构,因此下面几点是要考虑到的:

  • 在所有子模块发布 maven 成功后,本地编译使用aar模块且环境参数没有传递 versionPath 参数时,构建脚本会自动拉取Mcd新的versionPath.json链接作为默认的模块aar信息表。
  • 开发阶段需切换源码/aar,可对项目中所有模块进行分类,分别为:入口模块,源码模块,aar发布模块,可移除模块。另外,合并 gradle 环境参数和local.properties参数共同作为控制参数,然后修改app_bundle.json指定模块类型以达到灵活切换的目的。例如,定义一个sourceProject参数指定哪些模块作为源码构建。
  • 为模块之间的依赖关系建立一个json文件aar_config.json,便于查看和修改模块间依赖关系。

整个模块aar打包流程图如下:

 图6:模块aar下的打包流程

4)抽取app多余代码,使其成为真正的"壳"

App入口项目中存在的代码会影响编译效率,为尽可能降低编译时间,将app中大部分 main 目录代码移动至子模块中,保证app模块只剩一个空壳而已。

5)自定义依赖项的替换规则

通过app_bundle.json得知目标模块是aar格式后,我们可使用 Gradle 提供的自定义依赖替换规则来实现aar的切换功能。 

依赖替换规则允许项目和模块依赖项透明地替换为指定的替换项,项目和模块依赖可互换地替换。对应于源码中ResolutionStrategyApi中的DependencySubstitutions接口,此功能常用于多项目构建中灵活的组装子项目。通过允许从存储库下载而不是构建项目依赖项的子集,这对于加快大型多项目构建的开发非常有用。 

自定义依赖项替换规则如下:
//此处采用伪代码形式,正式场景佳实践应抽取到plugin中并提供extension和log功能//通过app_bundles.json信息决定是否应用替换规则gradle.ext.appBundles.each { bundleName, bundleInfo -> if (bundleInfo.buildType == 'aar') { gradle.ext.subsitituteMap[":$bundleName"] = "$specialGroupID:$bundleName:${bundleInfo.versionName}" } }//制定自定义解析规则,用模块替换项目依赖项configurations.all { conf -> resolutionStrategy.dependencySubstitution { gradle.ext.subsitituteMap.each { substitute project(it.key) with module(it.value) } }}

6)自定义pom文件管理传递依赖

此处通过maven-publish插件发布模块aar,发布aar时不可混淆以防止依赖报错。

通过声明pom文件来管理依赖及传递依赖是个很好的方式。发布模块aar时,本身项目的子模块aar不建议写入pom文件,否则在模块切换源码时会存在远程aar和源码冲突的情况,此时需要添加额外的exclude操作。

自定义pom文件如下:
//使用maven-publish插件发布aarapply plugin: 'maven-publish'...publishing{...//自定义pom文件处理传递依赖 pom { packaging = "aar" withXml { pomGenerate(it) } }}//获取配置的依赖 configuration => dependenciesMapdef ascribeDependencies(String confName) { def dependencySet = project.configurations.getByName(confName).getAllDependencies() def dependenciesMap = [:] //获取module类型依赖,待写入pom文件 dependencySet.withType(ExternalModuleDependency) .findAll { if (it.group != null && it.name != null && it.version != null) { return it } return null } .each { ... dependenciesMap.put("${it.group}:${it.name}", it) } return dependenciesMap}//拼接pom文件def pomGenerate(XmlProvider xp) { def dependenciesMap = [:] dependenciesMap.putAll(ascribeDependencies("implementation")) dependenciesMap.putAll(ascribeDependencies("api")) ... // 添加节点的闭包 def addDependencyNode = { rootNode, entry ->... Dependency target = entry.value //写入基本依赖信息 def node = rootNode.appendNode('dependency') node.appendNode('groupId', target.group) node.appendNode('artifactId', target.name) node.appendNode('version', target.version) node.appendNode('scope', 'compile') //如果有,写入classifier def artifacts = target.getArtifacts() if (!artifacts.isEmpty()) { DependencyArtifact artifact = artifacts.first() artifact.type && node.appendNode("type", artifact.type) artifact.classifier && node.appendNode("classifier", artifact.classifier) } //如果有,写入exclude规则 Set<ExcludeRule> excludes = target.getExcludeRules() if (!excludes.isEmpty()) { def exclusions = node.appendNode("exclusions") excludes.each { rule -> def exclusion = exclusions.appendNode("exclusion") rule.group && exclusion.appendNode("groupId", rule.group) rule.module && exclusion.appendNode("artifactId", rule.module) } } } //添加dependencies节点 def dependenciesNode = xp.asNode().appendNode('dependencies') dependenciesMap.each { addDependencyNode(dependenciesNode, it) }}

7)其他核心Gradle脚本代码一览

  • setting入口处合并参数

//加载local.properties属性gradle.ext.localProperties = gradle.ext.loadProperties("$rootDir/local.properties")//因为gradle版本兼容问题,startParameter.projectProperties不能注入直接注入,//类型为com.google.common.collect.ImmutableMap,需要统一。def inputProjectProperties = new HashMap()if (startParameter.projectProperties != null) { for (key in startParameter.projectProperties.keySet()) { def value = startParameter.projectProperties.get(key) if (value != null) { println "注入ProjectProperty >> $key :${utf(value)}" inputProjectProperties.put(key, value) } }}//local property 参数注入,优先级高于环境参数if (gradle.ext.localProperties) { gradle.ext.localProperties.each { k, v -> println "注入LocalProperty >> $k :${utf(v)}" inputProjectProperties.put(k, utf(v)) }}

  • 入口处更新全模块信息app_bundle.json指定模块打包类型

//解析初始模块信息app_bundles.jsongradle.ext.appBundles = gradle.ext.getConfigJson("$rootDir/app_bundles.json")//利用输入参数更新模块信息gradle.ext.appBundles.each { bundleName, bundleInfo -> ... // App混合编译打包 if (gradle.ext.sourceProjects.contains(bundleName)) { //强制指定源码编译的模块 bundleInfo.buildType = 'lib' bundleInfo.include = true } else if ((!bundleInfo.required) && gradle.ext.excludeProjects.contains(bundleName)) { //指定可移除模块 bundleInfo.buildType = 'lib' bundleInfo.include = false } else if (gradle.ext.versionPathConfig) { // AAR 混合编译,versionPath决定aar信息 def that = gradle.ext.versionPathConfig.find { item -> bundleName == item.bundleCode } if (that) { // AAR编译的模块 bundleInfo.buildType = 'aar' bundleInfo.include = true bundleInfo.versionName = that.version } else { // 其他模块 bundleInfo.buildType = 'lib' bundleInfo.include = false if (bundleInfo.required) { println("Build bundle missing,bundleName:" + bundleName) } } } else { // 纯源码编译 bundleInfo.buildType = 'lib' bundleInfo.include = true }}

  • 在各个模块的ext扩展中定义统一的模块依赖方法,方便修改

//ext扩展(接口为ExtraPropertiesExtension.java)中定义统一方法def compileBundle(Project project, String depName = null) { def projectName = depName ?: project.name def dependenciesHandler = project.configurations.implementation if (projectName != project.name) { //指定单个模块依赖 def findProject = project.rootProject.findProject(projectName) if (findProject != null) { dependenciesHandler.dependencies.add( project.dependencies.create(project.rootProject.project(projectName)) ) println("compileBundle direct >> ${project.name} $projectName") } else { println("compileBundle direct skip >> ${project.name} $projectName") } } else { //传递模块依赖,moduleTransitives即为解析的aar_config.json对象 if (gradle.ext.moduleTransitives != null) { def depList = gradle.ext.moduleTransitives[projectName] depList?.each { def findProject = project.rootProject.findProject(it.depName) if (it.depName != null && findProject != null) { dependenciesHandler.dependencies.add( project.dependencies.create(project.rootProject.project(it.depName)) ) println("compileBundle relationship >> ${projectName} ${it.depName}") } else { println("compileBundle relationship skip >> ${projectName} ${it.depName}") } } } }}//模块gradle使用处dependencies { ... compileBundle(project)}

3.3 改造中遇到的问题

1)同名资源冲突

模块AAR改造的过程中,进行了模块依赖关系的调整,导致同名资源的覆盖关系发生了改变。如果多个 AAR 库之间发生冲突,系统会使用依赖项列表中首先列出的库(靠近dependencies块顶部)中的资源。同名资源冲突会导致两种结果:

  • File资源的冲突导致在运行时会因为同名资源中 id 找不到而 crash。
  • Value资源的冲突导致 UI 不符合预期。

要解决这个问题,首先需要了解 Android 的资源打包流程,如图所示:

 图7:android资源打包流程

Android 资源打包工具 aapt 在编译和打包资源的过程中,会对非 assets 资源做如下处理:

  • 赋予每一个非 assets 资源一个ID值,这些ID值以常量的形式定义在一个 R.java 文件中
  • 生成一个 resources.arsc 文件,用来描述那些具有ID值的资源的配置信息,它的内容就相当于是一个资源索引表

资源索引表对同名的资源只会生成一个id,因此项目中如果有同名的资源,构建工具会根据以下优先级顺序(左侧的优先级高)选择要保留的版本:

 图8:同名资源合并优先级

为了避免常用的资源 ID 发生资源冲突,主要方案有两种:

  • 使用对模块具有性(或在所有项目模块之间具有性)的前缀命名
  • 其他一致的命名方案

但是实际操作下来,发现还是存在几个问题:

  • 一是如何找出冲突的资源进行重命名处理
  • 二是对什么样的同名资源处理
  • 三方库和三方库中的资源冲突该怎么办

为了解决这些问题,我们使用了CheckResourceConflict开源插件,原理是对工程做一个扫描,使用BaseVariantImpl.allRawAndroidResources.files接口可以在编译期间获取到所有的资源文件,然后检测同名资源并分类输出到一个可视化的html文件中,如图:

 图9:同名资源冲突的分析

现在,我们可以看到哪些资源有重复以及重复资源所在的位置,可以用性命名来解决同名资源冲突的问题了。

针对第二个问题,如果是布局 ID 相差较大的File资源和会影响UI展示的 value 资源可通过性命名来解决冲突;对于虽然同名但内容相同的资源可记录不做处理。

第三个问题较为复杂,如果同名资源相差较大,可以考虑源码的方式引入,或者直接在变体中覆盖。

2)aar发布相关 

将模块源码发布成远程aar需要注意不能混淆aar的代码,否则开发阶段依赖aar时不能识别其中的代码,混淆阶段放在app打包阶段即可。

3)依赖替换中依赖标记不可添加classifier ,否则会抛出UnsupportedNotationException

dependencySubstitution声明的module依赖标记只能有groupId,artifactId,version这三者,如果多于或者少于3,会直接抛出不支持的标记异常,抛出异常的标记转换类为ModuleSelectorStringNotationConverter
//ModuleSelectorStringNotationConverter.java@Overrideprotected ComponentSelector parseType(String notation) { ... String[] split = notation.split(":"); if (split.length < 2 || split.length > 3) { throw new UnsupportedNotationException(notation); } ... return DefaultModuleComponentSelector.newSelector(moduleIdentifierFactory.module(group, name), DefaultImmutableVersionConstraint.of(version));}

4)在应用依赖替换规则后遇到的 java.lang.StackOverflowError

此处用到的依赖替换规则为用模块标记替换项目依赖。官网介绍在大型的多项目构建中,这个规则允许项目依赖的子集是从仓库中下载而不是构建项目,对加速开发效率是很有用的;被替换的project必须在setting文件中被include,且为了去解析依赖配置,构建被替换依赖项的任务将不会执行。 

意思是被替换的project中的任务将不会执行,然而实际操作中,被替换Project的 dependencies 闭包中 api 动态方法会导致StackOverflowError异常,如果用implementation会导致Could not find method xxx on object DefaultDependencyHandler编译异常。

为了查找原因和对Gradle有个形象的认知,当我们分析了 Gradle 依赖过程会发现:

  • Gradle 存在一个ServiceRegistry的注册中心, 且 封装了一系列类似于JavaSpi机制的Services类,其中一个DependencyServices类提供了DependencyFactory依赖工厂的构建方法;
  • 默认的DependencyFactory工厂实现类,包含通过静态工厂方法创建的DependencyNotationParser对象,其中组装了我们常用的StringNotation和ProjectNotation,返回一个组合的标记解析类NotationParser;
- 我们常用的group:artifact:version即为StringNotation;而project(':xxx')即为ProjectNotation;
  • 当我们通过 api或implement 声明依赖时,实际上是通过Project中的DependencyHandler对象来进行处理的,这个对象就是ServiceRegistry提供的,这就和 DependencyServices 等功能类建立了联系;
  • DependencyHandler 类中并没有 implement和api 方法,因为Groovy是一个动态语言,允许运行时去catch未定义方法的调用。我们只需知道如果遇到这种找不到的方法会交给此类中的DynamicAddDependencyMethods对象去处理,终会使用 DependencyNotationParser 中构建的标记解析类去解析。

至此,上述报错的原因很有可能是因为依赖替换之后,改变了原本的DependencyHandlerNotationParser的联系,导致原本正确的动态方法在替换后的功能类中找不到对应的方法或重复动态方法调用栈出错。因此需要对模块 Gradle 逻辑做一个跳过处理,不进入project的脚本逻辑。类似于:
//提供方法跳过替换后的模块gradle逻辑def isSubstitute() { return gradle.ext.subsitituteMap.containsKey(":${project.name}".toString())}

5)aar 自动化构建方案的选择

就构建这块来说,aar编译方式相比于之前,需要构建的模块大大增加,以前不管代码改动多少,只需要构建工程模块即可;现在如果改动了模块的代码,需要先构建所有有变动的模块,在这些模块构建完成之后才能构建主包。如果这个过程全部由人手动来控制和触发,其繁琐程度不言而喻。

于是我们使用了webhook来实现自动化构建,在代码 commit 的时候触发相应的 webhook,来通知打包服务器执行自动构建的流程,打包服务器在接受到请求以后,先分析出发生改动的模块是哪些,然后构建变动模块的依赖树,从依赖树的底层开始从下而上触发模块构建,后打包完成后通知相关人员。这样一来,开发只需提交下代码,剩下的就交给机器去做了。 

 图10:自动化构建示意图

但也因此出现了aar自动化的两种方案:

  • 提交并同时构建所有的aar;此种方案优点是发布aar迅速,但因aar依赖顺序问题会出现打包失败,此时只能再打一次包解决此问题;
  • 提交并按依赖顺序构建aar;此种方案能解决打包失败的问题,但总的aar发布时间缓慢,导致整个打包流程的时间剧增,特定情况下会大幅超过源码构建时间。此种情况可对模块进行细粒度的拆分,减少开发业务时频繁的进行aar模块的传递性打包。

一般来说,对于不经常变动的模块使用方案一,对于业务改动频繁的模块使用方案二。

6)构建过程的特征化标记或资源应放在App壳中

一般 CI/CD 系统中对构建的安装包都有特征标记和资源注入,这些过程都应该放在app壳的Gradle脚本中,否则会出现资源不及时更新的问题。举个例子:

问题描述:测试的时候发现,aar模式打的包拉不到新发布的rn。对打出来包进行分析后发现,包内的rn是工程里的原始rn,版本很老,服务那边无法识别这么老的版本,导致rn增量下发失败。

原因:gradle脚本下载rn资源的目录为模块aar的assets目录,因此在aar模式下,总包rn的版本取决于模块aar中的rn版本,而发布模块aar打包脚本未更新rn,导致使用的rn为旧版。示意图如下。

解决方案:修改下载rn资源的目录为app壳的assets目录,避免aar发布阶段处理需要即时更新的逻辑。类似的问题还有AndroidMainifest清单文件中使用gradle参数,需要即时更新的资源或者参数需要放于app壳中执行,保证其实时性。

 图11:动态资源未更新示意图

3.4 其他优化

1)IDE插件的开发

为了方便开发同学本地开发时能够灵活的进行 aar/源码 的切换,我们引入了一个有图形化界面的AS插件。基本原理是根据项目中的 json配置文件可视化的展示项目中的所有模块,提供拖拽的功能方便模块类型的变换,保存时改变本地的local.properties文件,达到控制参数的目的。 

另外,也可定制参数化打包,因为测试阶段需要频繁出包,这时如果要等aar包发布在打总包整个流程会变长,所以传入sourceProject参数指定源码模块的参数化打包在测试阶段尤为重要。

 图12:AS插件灵活切换AAR/源码

3.5 结果一览

经过如上所述的改造后,对基于模块aar方案的编译时间进行统计。统计基于本地机器的干净编译时间,可得到比较的打包时间。

1)本地机器干净编译时间统计

操作步骤:

  • 开始次编译
– as 进行一次 invalidate cache 清除缓存 + 进行一次 clean project
– 执行干净编译保证所有task都会执行gradlew--profile--offline--rerun-tasks assembleXXFlavorRelease

  • 开始第二次编译
– as 进行一次 clean project
– 执行干净编译保证所有task都会执行gradlew--profile--offline--rerun-tasks assembleXXFlavorRelease

测试结果对照表:

 表1:干净编译耗时统计表

 表2:干净编译耗时对比

结果如上图表所示,可直观的得出结论:

  • 本地电脑测试去除干扰项后,次构建源码平均耗时为 8m47s,aar平均耗时为 4m24s, 总包时间缩减率为 50%;第二次构建源码平均耗时为 5m50s,aar平均耗时为 3m15s,总包时间缩减率为 44%。 
 
综上,对基于模块AAR的编译优化方案可得出如下结论:

  • 从总包构建时间上来看,通过模块AAR方案改造后 release 总包时间缩减率可达50%左右,时间缩减明显。开发阶段关闭transform等耗时操作且支持部分模块以AAR引入,编译时间会进一步缩减,可有效提高开发效率。
  • 但从整个流程上来看,打包流程从传统的源码构建分成了发布aar和构建aar总包两部分。其中发布aar的平均时间为2m左右,如果发布一个aar后再打总包,此时整个打包流程的时间缩减率在13%~27%之间,而自动化的aar打包会有依赖问题,存在多个aar顺序打包后再打总包的情况,故整个流程上的时间缩减并不明显并大有可能会超过源码编译时间。

四、总结与展望

本次主要对项目的工程结构和 gradle 脚本进行一定的优化,通过 gradle 参数支持模块 aar/源码 的切换,可在一定程度上提高开发的效率,全aar下可节省出包时间,同时模块化也是其他优化方案的基础。

通过对项目进行模块aar的改造后,编译速度上收益明显,尤其通过as插件可视化的切换更增加了开发的灵活度,开发阶段不用长时间的等待编译完成。为了保持较快的编译速度,后续还可以做到以下几点:

  • 项目在保持良好的工程结构的同时,对业务模块进行适当粒度的拆分,可以让项目结构更清晰,更加有利于项目的更新;
  • 可对高耗时的task,如 dexBuilder,单独做优化; 
  • 添加工程构建监控机制,及时对编译过程分析和处理;

相关文章