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

问题

我们的项目构建中,有这样一个需求,在编译过程中,将最重要打到 APK 包里的 jar 库全都合并到一个 jar 里。于是,我新建了一个合并 jar 包的任务,插入到了 Gradle Tasks 的有向非循环图里,但是这个过程中遇到这样一个问题:在 Gradle 构建脚本里,调用 ApplicationVariant#apkLibraries 方法,我发现输出结果时而正确,时而不正确。这段代码我是这样写的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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 多无奈纠结),只好硬着头皮硬上了。结果就有了这篇文章。

一些准备工作

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
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)
}
})
}
})

这样再看,是不是就容易理解多了?其中 afterEvaluatelogger 都是 project 的方法,在每个 Gradle 工程里都有一个 project 对象,在所有的 build.gradle 文件里都可以直接调用 project 的所有共有方法和变量(所以说,所谓闭包、lambda 绝对不是学会 Gradle 的关键)。这段代码的目的就是为了输出所有最终打进 APK 包里的依赖,也就是在 build.gradledependencies 结点下面 compile (未来的 Gradle Plugin 版本可能会变)的所有依赖;顺便说一句,如果要获取 dependencies 结点下面所有的依赖,将 apkLibraries 换成 compileLibraries 即可。这两个 API 在 com.android.build.gradle.api.ApkVariant 里,而 ApplicationVariant 继承自 ApkVariant

现象描述

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

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

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

    但是我的打包命令里明明包含了 clean 任务,为什么在 1 里的结果还是正确的呢?其实这时候,如果对 Gradle 的生命周期有了解的话,就可以猜得到结果了,这说明这段日志打印一定是在 Gradle 生命周期的 Configuration 阶段输出的,而 clean 任务的执行必然是在 Configuration 之后的,这也就解释了为什么单独执行 clean 任务,再执行 assembleDebug 任务,得到的结果是错误的,而把 cleanassembleDebug 放在一起执行得到的结果是正确的。我们将打包命令后面加上 —info 参数,就可以发现确实是在 Configuration 过程中打印的日志输出。下图是 Gradle 生命周期的图示。

  1. 再接下来,我新建了个 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 方法即可得到 apkLibrariesAndroidBuild 里的 getAllPackagedJars 方法如下图:

  2. 然后是 VariantConfiguration 里的 getAllPackagedJars 方法:

  3. 接着调到了 Dependency 类里的 getClasspathFilegetAdditionalClasspath 方法,而 Dependency 是一个接口,我们看它的其中一个实现类(其他的也类似)AndroidDependencyAndroidDependency 的构造方法是私有的,同时它提供了静态的共有 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 阶段打印。在明确了这一点之后,另外一个更合适的解决方案就呼之欲出了,一点点修改就可以解决。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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 执行的最开始先去获取需要处理的文件即可。折腾这么久,这样的结果看上去居然与过程几乎没什么关系…不过这过程才是学习最大的意义所在吧。