Android Gradle 同步优化

6,513 阅读11分钟

背景

年初开始我们就开始了关于Gradle Sync阶段的优化。之前和大家都简单的介绍过工程相关的背景情况了,我们大概有400+的Module,然后一次的同步时间就非常的慢,我们迫切的需要对这个问题进行优化。大部分工作都是和团队内的同学一起完成的,我也只出了一点点力而已。

这次写文章真的很倒霉,之前忘了保存导致要重新开始写了。如果不是白嫖了掘金的端午礼盒,拿人手短啊,我已经打算鸽了这篇文章了。

方法论

很多人听到方法论三个字,就觉得我要开始pua,说我阿里味,但是我觉得这个查问题的方式可能会对大家有点帮助。

很多人都会有这样的困扰,给你的一个工作内容是一个你完全陌生的东西,第一选择是逃避然后开始摆烂。我记得前一阵子和一个网友聊天,他有一次面试的时候也问了这样的问题。这次同步优化其实也相似的问题,是一个对我来说相对比较陌生的东西。

我就是想说下我们是如何来拆解这个问题的。首先需要一些对应相关的基础知识,我去官网查看了些对应的文档资料,仔细的了解了Gradle生命周期相关的,看看能不能对我们后续有所帮助,这个对于后续优化其实是非常重要的。

image.png

然后我通过我们的一个monitor插件,我看了大概一个礼拜的同步相关的编译日志,发现了一蛛丝马迹的。monitor就是一个通过BuildOperationNotificationListenerRegistrar把编译信息都记录到一个本地文件夹下的html中,然后把这些信息都发布都远端,方便后续排查问题。

这个monitor插件我在github上进行了一次kotlin翻译

问题大概如下:

  1. 遍历工程文件夹速度过慢,耗时大概1分钟左右
  2. 所有依赖全部切换成源码之后因为工程太多,所以展开速度过慢
  3. Configuration之后竟然有个很慢的东西,占据了大量的耗时

这个就是我的方法论,通常碰到一个比较大的问题,我会把一个问题先尝试拆解成几个不同的小问题,然后列出一个优先级和难易度,之后从易到难的逐步解决问题。一般情况下当你的leader发现问题有缓解之后才会逐步的更多的投入人力资源。而想要一步登天改完所有问题还是有点异想天开的。

其中我之前在哔哩哔哩Android编译优化的独立编译单元中,有介绍过对于所有依赖全部切换成源码之后因为工程太多,所以展开速度过慢的优化思路。

简单的说我们将一个的大的工程结构拆分成若干小的而且独立的部分,然后业务同学在各自小的独立的编译单元中进行自己的工作流,之后大家不会改动到的模块就会自动的切换成aar产物,避免了无效工程结构的展开。最后的编译阶段由我们的大的工程结构来进行接管,这样就能同时保证代码的更快速展开和代码的稳定性了。

数据结构缓存

因为工程目录结构太复杂了,导致获取工程模块数据结构的速度偏慢,大概耗时需要1分钟左右的时间。但是我们认为工程结构本身是处于比较稳定的状态,并没有必要每次都使用文件展开的方式进行数据结构的生成。

所以打算结合当前的工程分支信息以及各个子git工程的信息等,将这部分数据缓存复用,从而绕开这个文件展开过程,已达到对这部分提速的能力。

因为知道当前工程含有几个git工程,但是并不是所有人都有工程的权限的,然后会判断该git工程是否存在,以及文件夹下是否存在有一个settings.gradle或者build.gradle,如果都符合则认为该子仓是一个符合标准的工程仓库,需加入作为缓存唯一key值的计算中,不符合的工程就会跳过。

val rootDir = FileTools.rootProjectDir
val resolves = mutableListOf<XXX>()
val cacheKey by lazy {
    localCacheKey()
}

init {
    resolves.add(rootDir.getLog().resolve())
    allBabels.forEach {
        val file = File(rootDir, it)
        val hasSettings = file.walkTopDown()
            .firstOrNull { walkFile -> walkFile.name == "settings.gradle" || walkFile.name == "build.gradle" } != null
        if (file.exists() && hasSettings) {
            resolves.add(file.getLog().resolve())
        }
    }
}

private fun localCacheKey(): String {
    var key = ""
    resolves.forEach {
        key += it.commitSha + "_"
    }
    val file = rootDir
    return "${GitUtils.currentBranch(file.path).replace("\\/", "_")}_${key.hashCode()}"
}

然后我们在数据结构获取的时候会先判断本地是否存在改缓存key的文件夹,文件夹下面是否有对应的文件,之后基于这个来重新反序列化出对应的数据结构。如果没有则按照原来的文件访问操作进行数据结构获取了。

另外在数据结构中本身是还有父类,子类对应文件的信息的,但是这部分数据并没有办法进行缓存,因为缓存下来之后重新反序列化出来的就是新的一个对象。这部分需要我们重新通过自己的遍历方法,补充这部分数据机构的关系。

另外的一部分边界情况就是我们要判断当前的git status中是否存在新增的对应的数据结构存在,如果有则需要单独添加一份数据结构。因为我们绕开了文件访问,所以需要对这部分进行补充。

从本地测试结果来看,第一次展开情况下耗时60s时间,如果从缓存内读取则时间压缩到9s左右就完成数据结构还原了。所以这个算是我们加快工程同步速度的第二步了。

最有意思但最难的问题

先说结论,我们发现同步阶段的后期耗时是android jetifier,会在aar或者jar资源下载完毕之后会执行jetifier的清洗androidx的操作。

为什么jetifier会选择在这个时机,而不是在打包流程进行对应的替换呢?其实在于他们并不仅仅要完成字节码上的转化操作,另外还要对资源文件也进行同样的清洗,比如layout文件中的。

所以jetifier在后续的AGP源码中就替换了原来的方式,进而对工程内所有的aarjar产物进行替换操作,也就是Gradle官方提供的TransformAction相关的api。

官方文档 As described in different kinds of configurations, there may be different variants for the same dependency. For example, an external Maven dependency has a variant which should be used when compiling against the dependency (java-api), and a variant for running an application which uses the dependency (java-runtime). A project dependency has even more variants, for example the classes of the project which are used for compilation are available as classes directories (org.gradle.usage=java-api, org.gradle.libraryelements=classes) or as JARs (org.gradle.usage=java-api, org.gradle.libraryelements=jar).

@CacheableTransform
abstract class JetifyTransform : TransformAction<JetifyTransform.Parameters> {
}

这个是从agp源码中抠出来的,我看了下4.0.0和7.0+版本的agp,都已经是TransformAction写法了。另外没有扫描前是不确定当前输入aar或者jar是否含有非androidx的代码的,就需要对所有的aarjar进行一次扫描,之后重新生成一个新的aar或者jar

但是也正是因为TransformAction写法,导致了jetifier操作被放在了同步阶段完成了。而且因为我们的module数量太多以及我们的快编等等,更导致了这个问题被放大了好几倍。

动态修改gradle配置

android.useAndroidX=true
android.enableJetifier=true

因为jetifier的开关设置在gradle.properties中,所以我们打算在插件内判断是否是同步操作,如果是同步则主动关闭jetifier,从而绕开TransformAction的耗时。

我尝试通过添加android.enableJetifier=falseandroid.useAndroidX=false参数到gradle.startParameter.projectProperties或者gradle.startParameter.systemPropertiesArgs中去,这两个配置是gradle的全局配置参数。

但是尝试重新通过setProjectPropertiessetSystemPropertiesArgs函数去重新赋值,但是测试下来发现没有生效。这个值已经在内存中被Gradle持有,重新设置是无效的。然后我们尝试了下通过反射去修改这个值,最后发现个更尴尬的事情,这个值是在AGP内通过ProjectsServices来进行读取的,所以我们只能放弃这个方案了。

hook agp ProjectsServices

当发现这个值是在AGP中去进行读取的。后续就决定从修改AGPProjectsServices进行入手,从而达到关闭jetifier。有了上一次的反射经验,然后我们也顺利的沿用到了这次。

因为AGP相关的时机其实并不是特别靠前,而是在Android插件被执行之后的afterEvaluateapi中,所以我们只要在这个执行之前通过反射去修改projectServices就行了。

这里因为我们的插件需要判断当前的Project内是否存在agp插件,并在他的 afterEvaluate执行之前调用,所以我们选择了 project.plugins.withType这个api来执行。

override fun apply(project: Project) {
       project.plugins.withType(BasePlugin::class.java) {
           val service = it.getProjectService() ?: return@withType
           val service = it.getProjectService() ?: return@withType
val projectOptions = service.projectOptions
val projectOptionsReflect = Reflect.on(projectOptions)
val optionValueReflect = Reflect.onClass(
        "com.android.build.gradle.options.ProjectOptions\$OptionValue",
        projectOptions.javaClass.classLoader
)
val defaultProvider = DefaultProvider() { false }
val optionValueObj = optionValueReflect.create(projectOptions, BooleanOption.ENABLE_JETIFIER).get<Any>()
Reflect.on(optionValueObj)
        .set("valueForUseAtConfiguration", defaultProvider)
        .set("valueForUseAtExecution", defaultProvider)
val map = getNewMap(projectOptionsReflect, optionValueObj)
projectOptionsReflect.set("booleanOptionValues", map)
      }
}
private fun BasePlugin<*, *, *>?.getProjectService() =
        Reflect.on(this)
                .field("projectServices")
                .get<ProjectServices?>()

在这个阶段上,我们能获取到getProjectService,然后就可以为所欲为了。虽然听起来挺离谱的,但是貌似也雀食是可以。

这次我们雀食成功了,这种方式确实能在同步阶段自动的去把jetifier给关闭掉,然后我们就打算尝试性的在工程内进行实验了。

allProject{
  apply plguins:"jetifier_closs.class"
}

最后我们还是失败了,以前介绍过项目内含有很多个复合构建的项目,然后我们是通过所有子工程apply from根的build.gradle的方式完成这部分配置同步的。但是前面说到jetifier读取的时机实在afterEvaluate。但是好巧不巧,这次所有复合构建的工程因为apply from的缘故,导致了时机触发都在afterEvaluate,导致了反射修改的值没有生效。所以我们又失败了。

方法签名检查是否存在support包

最后我们仔细想了想,这种修改还是太过于黑魔法了,万一后面AGP有修改我们也要跟随一起改动。最后决定移除项目内所有的support库,主动关闭同步和编译阶段的jetifier,这样既能同时加快打包速度也可以让同步速度变得更快,一举两得。

这次移除操作就大部分是人力堆叠了,通过dependcies把所有依赖了support都进行移除,另外比如微博这种jar包内的,则采取在一个开启了jetifier的工程中,先完成转化之后再拿到jar包之后二次上传我们的私有maven,从而完成项目内所有库的support移除。

另外作为一个工程师,我们不能只看到眼前的苟且。移除所有support一时间我们可能可以解决这个问题,但是作为一个巨大无比的工程,你不开启jetifier的时候,后续的新增接入的代码都需要确保剔除了support库,否则最后上线就是会出各种问题。另外有个小注意的点就是在support整改之后,需要在Configuration的时候去把support的依赖全部进行移除。这样就能保证以后所有的support包就算新增了也不会被带到apk中。

allprojects {
    configurations.all { Configuration c ->
        if (c.state == Configuration.State.UNRESOLVED) {
            exclude group: 'androidx.lifecycle', module: "lifecycle-extensions"
        }
    }
}

项目需要一个长期有效的手段去确定新增的依赖库已经没有用到support。最后采取了之前说的方法签名验证,因为已经移除了所有support库,所以最后apk产物内必然是缺失对应的依赖的,这样在方法签名校验的过程中就会出现异常。我们的A8检查会加载android.jar以及所有的dex文件,如果调用的方法找不到的情况下则会报错。这样就能确保后续引入的新的aar或者jar中如果调用了support则无法完成代码合入。

(R8 class check)[mp.weixin.qq.com/s/rDvOQWcfC…] 有兴趣的可以看看这部分,我们这部分检查就是基于R8来完成的。

总结

之后可能文章更新的频率估计也就类似现在这样了呢,大部分时间都是在一个修修补补的状态,其实挺难做一些0-1的优化的,更多的时候是做一些1-100的努力。

看起来本文的内容不多,但是其实我们从年初就开始定位问题以及做一些尝试性的修复了。发现问题的时间以及基于工程去解决当下的困扰都是挺费时费力的。