一个关于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:
2. 我们再看一下不正确的结果,很容易发现,这结果里只剩下了.gradle
、.m2
和 SDK
目录下的缓存:
也就是说,工程 build
目录下的临时缓存丢掉了,然后我先执行以下gradle clean
,将 build
目录删掉,再重新编译,结果复现了!到了这里,我们可以确认这是个必现的 bug,而不是一开始想的时而正确,时而不正确。
但是我的打包命令里明明包含了 clean
任务,为什么在 1 里的结果还是正确的呢?其实这时候,如果对 Gradle 的生命周期有了解的话,就可以猜得到结果了,这说明这段日志打印一定是在 Gradle 生命周期的 Configuration
阶段输出的,而 clean
任务的执行必然是在 Configuration
之后的,这也就解释了为什么单独执行 clean
任务,再执行 assembleDebug
任务,得到的结果是错误的,而把 clean
和 assembleDebug
放在一起执行得到的结果是正确的。我们将打包命令后面加上 —info
参数,就可以发现确实是在Configuration
过程中打印的日志输出。下图是 Gradle 生命周期的图示。3. 再接下来,我新建了个 demo,然后继续之前的操作,一个完全干净的工程,没有 buildDir
,结果出现了下面的结果:
看到这样的结果,我直接就懵逼了…
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
方法如下图:
2. 然后是 VariantConfiguration
里的 getAllPackagedJars
方法:
3. 接着调到了 Dependency
类里的 getClasspathFile
和getAdditionalClasspath
方法,而 Dependency
是一个接口,我们看它的其中一个实现类(其他的也类似)AndroidDependency
,AndroidDependency
的构造方法是私有的,同时它提供了静态的共有 create 方法,因此创建AndroidDependency
都只能调用这些 create 方法,我们只要找到这些 create 方法调用的地方就可以了。
4. 然后我们可以发现在 DependencyManager
里调用了 create 初始化Dependency
的方法:
到这里,一切就变得很明朗了。Gradle 构建过去缓存目录有两种方式,用哪种方式,取决于PrepareLibraryTask.shouldUseBuildCache(buildCache.isPresent(), mavenCoordinates)
的值,也就是说 buildCache.isPresent()
为 true,同时依赖版本号里含有 -SNAPSHOT
,而前者需要 buildCache
不为空:
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 的逻辑。
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 构建