一个关于Gradle构建缓存的问题

Posted 萤火虫技术

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了一个关于Gradle构建缓存的问题相关的知识,希望对你有一定的参考价值。

问题


在微店android项目开发和构建的过程中,我遇到了这样一个需求:


在编译过程中,将最终要打到 APK 包里的 jar 库全都合并到一个 jar 文件里。


于是,我新建了一个合并 jar 包的任务,插入到了 Gradle Tasks 的有向非循环图里,但是这个过程中遇到这样一个问题:


在 Gradle 构建脚本里,调用 ApplicationVariant#apkLibraries 方法,我发现输出的结果时而正确,时而不正确。


这段代码我是这样写的:

afterEvaluate {
   android.applicationVariants.all { ApkVariant variant ->
       def buildTypeName = variant.buildType.name
       def task = project.tasks.create "jar${buildTypeName.capitalize()}", Jar
       def packageTask = project.tasks.findByName("package${buildTypeName.capitalize()}")
       task.archiveName = 'base.jar'
       task.dependsOn packageTask
       packageTask.finalizedBy task
       task.outputs.upToDateWhen { false }
       variant.apkLibraries.each {
           logger.info('apkLibraries ===> ' + it.absolutePath)
           task.from zipTree(it)
       }
       task.destinationDir
= file(project.buildDir.absolutePath + "/outputs/jar")
       artifacts.add('archives', task)
   }
}

其实这个问题困扰我好久了,一直悬而未决,不过好在这一切都发生在编译期。如果结果不正确的话,一切错误都可以在编译期暴露出来,而不会影响发版。

因此,在解决方案一直苦求而不得的情况下,我并没有把这个问题的优先级列的很高。直到前些天,我实在受不了了(此处省略了 N 多无奈纠结),只好硬着头皮硬上了,结果就有了这篇文章。


一些准备工作


首先,我解释一下这段代码的作用。其实对于习惯于 Java 语言的人而言,Gradle之所以难懂,是因为大量的语法糖省略了许多代码,而 IDE 本身对于智能补全的支持又相当的鸡肋,不管是读还是写,都特别费劲。

我们来把这段代码补全:

project.afterEvaluate(new Action<Project>() {
   @Override
   void execute(Project project) {
       com.android.build.gradle.AppExtension android = project.findProperty('android')
       android.getApplicationVariants().all(new Action<com.android.build.gradle.api.ApplicationVariant>() {
           @Override
           void execute(com.android.build.gradle.api.ApplicationVariant applicationVariant) {
               String buildTypeName = applicationVariant.buildType.name
               Task task = project.tasks.create("jar${buildTypeName.capitalize()}", Jar)//创建任务
               Task packageTask = project.tasks.findByName("package${buildTypeName.capitalize()}")//确定需要依赖的任务
               task.archiveName = 'base.jar'//输出文件名
               task.dependsOn(packageTask)//设置任务依赖的现有构建任务
               packageTask.finalizedBy(task)//设置任务在构建中的执行时机
               task.outputs.upToDateWhen { false }
               Collection<File> apkLibraries = applicationVariant.getApkLibraries()
               for (File file : apkLibraries) {
                   project.getLogger().lifecycle('apkLibraries ===> ' + file.getAbsolutePath())
                   task.from zipTree(file)
               }
               task.destinationDir
= file(project.buildDir.absolutePath + "/outputs/jar")//输出目录
               artifacts.add('archives', task)
           }
       })
   }
})

这样再看,是不是就容易理解多了?在每个 Gradle 工程里都有一个 project 对象,在所有的 build.gradle 文件里都可以直接调用 project 的所有共有方法和变量(所以说,所谓闭包、lambda 绝对不是学会 Gradle 的关键)。这里, afterEvaluate  logger 都是 project 对象的方法。

这段代码的目的就是为了输出所有最终打进 APK 包里的依赖,也就是在 build.gradle  文件中 dependencies 结点 compile 的所有依赖(未来的 Gradle Plugin 版本可能会变);顺便说一句,如果要获取 dependencies 结点下面所有的依赖,将 apkLibraries 换成 compileLibraries 即可。这两个 API 在com.android.build.gradle.api.ApkVariant 里,而ApplicationVariant 继承自 ApkVariant

现象描述


1.  先从日志输出入手,将正确的结果和不正确的结果放在一起对比后发现,正确的结果里包含两种类型的文件路径,一种是工程build/intermediates/exploded-aar 目录下的临时缓存,另一种是.gradle.m2  SDK 目录下的永久缓存(除非手动删除);它们还有另外一个特点,前者全都是 aar,后者都是 jar:

一个关于Gradle构建缓存的问题

2. 我们再看一下不正确的结果,很容易发现,这结果里只剩下了.gradle.m2  SDK 目录下的缓存

一个关于Gradle构建缓存的问题

也就是说,工程 build 目录下的临时缓存丢掉了,然后我先执行以下gradle clean,将 build 目录删掉,再重新编译,结果复现了!到了这里,我们可以确认这是个必现的 bug,而不是一开始想的时而正确,时而不正确。

但是我的打包命令里明明包含了 clean 任务,为什么在 1 里的结果还是正确的呢?其实这时候,如果对 Gradle 的生命周期有了解的话,就可以猜得到结果了,这说明这段日志打印一定是在 Gradle 生命周期的 Configuration 阶段输出的,而 clean 任务的执行必然是在 Configuration 之后的,这也就解释了为什么单独执行 clean 任务,再执行 assembleDebug 任务,得到的结果是错误的,而把 clean  assembleDebug 放在一起执行得到的结果是正确的。我们将打包命令后面加上 —info 参数,就可以发现确实是在Configuration 过程中打印的日志输出。下图是 Gradle 生命周期的图示。一个关于Gradle构建缓存的问题3.  再接下来,我新建了个 demo,然后继续之前的操作,一个完全干净的工程,没有 buildDir,结果出现了下面的结果

一个关于Gradle构建缓存的问题

看到这样的结果,我直接就懵逼了… 

build/intermediates/exploded-aar 居然消失了!!!跟上面的截图对比之后,发现取而代之的是$HOME/.android/build-cache 目录,而且即便没有 build 临时缓存目录,得到的结果也是正确的。新建的 demo 构建环境跟我们的业务工程唯一的区别就是 Gradle 版本了,我把 demo 的 Gradle 版本降级到跟业务工程一样,结果完全一样了。看来,Google 也意识到了这个 bug,其实也好理解,工程 buildDir 本就是临时目录,每次 clean 之后都会删除,而如果使用系统级的缓存目录,执行 clean 任务就不影响了。

追本溯源


接下来我们来看看最新版本的 Android Gradle Plugin 源码里是怎么处理的吧。

1. 我们在上面的截图里可以看到,调用androidBuilder.getAllPackagedJars 方法即可得到apkLibraries AndroidBuild 里的 getAllPackagedJars 方法如下图

一个关于Gradle构建缓存的问题

2. 然后是 VariantConfiguration 里的 getAllPackagedJars 方法

一个关于Gradle构建缓存的问题

3. 接着调到了 Dependency 类里的 getClasspathFile getAdditionalClasspath 方法,而 Dependency 是一个接口,我们看它的其中一个实现类(其他的也类似)AndroidDependencyAndroidDependency 的构造方法是私有的,同时它提供了静态的共有 create 方法,因此创建AndroidDependency 都只能调用这些 create 方法,我们只要找到这些 create 方法调用的地方就可以了

一个关于Gradle构建缓存的问题

4. 然后我们可以发现在 DependencyManager 里调用了 create 初始化Dependency 的方法

一个关于Gradle构建缓存的问题

到这里,一切就变得很明朗了。Gradle 构建过去缓存目录有两种方式,用哪种方式,取决于PrepareLibraryTask.shouldUseBuildCache(buildCache.isPresent(), mavenCoordinates) 的值,也就是说 buildCache.isPresent() 为 true,同时依赖版本号里含有 -SNAPSHOT,而前者需要 buildCache 不为空:

一个关于Gradle构建缓存的问题

5. 接下来我们可以看com.android.build.gradle.AndroidGradleOptions 这个类,这里我们可以看到只要 isBuildCacheEnabled 这个方法返回 true,上一步里的 buildCache 就不为空,而 DEFAULT_ENABLE_BUILD_CACHE 这个默认值恰恰就是 true,而且我们也可以看到在 getBuildCacheDir方法里,拼接的 dir 里刚好就有我们上面截图里的 build-cache,也就是说在新版本的 Gradle Plugin 里,走了步骤 4 里的 if 逻辑,而之前版本 Gradle Plugin 走的是 else 的逻辑

一个关于Gradle构建缓存的问题6. 这时候我们再看步骤 4 里的 else 逻辑,里面其实就是一个给explodedDir 的赋值操作,有两个常量字符串,他们的值如下图:

拼在一起,刚好就是 build/intermediates/exploded-aar,这也就是为什么在新版本的 Gradle Plugin 里这个目录消失了的原因

7. 然而,难道说要解决这问题,只能升级 Gradle 版本?当然不是,而且我们还可以发现,这样的输出结果并不完全正确,我们想要的是某一个变种(variant)的输出,不同的变种,文件目录是不一样的,而这结果是无论我们是哪一个变种,结果都是所有的变种输出。

解决方案


我们有必要再重新审阅一下我们的需求,『在编译过程中,将最重要打到 APK 包里的 jar 库全都合并到一个 jar 里』,也就是说这些日志输出应该在 Gradle 生命周期的 Execution 阶段去打印,而不是 Configuration 阶段打印。在明确了这一点之后,另外一个更合适的解决方案就呼之欲出了,一点点修改就可以解决。

afterEvaluate {
   android.applicationVariants.all { ApkVariant variant ->
       def buildTypeName = variant.buildType.name
       def task = project.tasks.create "jar${buildTypeName.capitalize()}", Jar
       def packageTask = project.tasks.findByName("package${buildTypeName.capitalize()}")
       task.archiveName = 'base.jar'
       task.dependsOn packageTask
       packageTask.finalizedBy task
       task.outputs.upToDateWhen { false }
       task.doFirst {
           variant.apkLibraries.each {
               logger.info('apkLibraries ===> ' + it.absolutePath)
               task.from zipTree(it)
           }
       }
       task.destinationDir
= file(project.buildDir.absolutePath + "/outputs/jar")
       artifacts.add('archives', task)
   }
}

仅仅是加了个 task.doFirst 就彻底解决了这个问题,在 task 执行的最开始先去获取需要处理的文件即可。折腾这么久,这样的结果看上去居然与过程几乎没什么关系…不过这过程才是学习最大的意义所在吧。

以上是关于一个关于Gradle构建缓存的问题的主要内容,如果未能解决你的问题,请参考以下文章

Docker 中缓慢的 gradle 构建。缓存 gradle 构建

如何让 Intellij IDEA 使用并行和 gradle 构建缓存进行构建

Android Gradle 构建因缓存文件失败

未为任务启用 Gradle 构建缓存

Gradle缓存问题

可视化Android Gradle插件的构建缓存的内容