问题
我们的项目构建中,有这样一个需求,在编译过程中,将最重要打到 APK 包里的 jar 库全都合并到一个 jar 里。于是,我新建了一个合并 jar 包的任务,插入到了 Gradle Tasks 的有向非循环图里,但是这个过程中遇到这样一个问题:在 Gradle 构建脚本里,调用 ApplicationVariant#apkLibraries
方法,我发现输出结果时而正确,时而不正确。这段代码我是这样写的:
1 | afterEvaluate { |
其实这个问题困扰我好久了,一直悬而未决,不过好在这一切都发生在编译期,如果结果不正确的话,一切错误都可以在编译期暴露出来,而不会影响发版。因此,在解决方案一直苦求而不得的情况下,我并没有把这个问题的优先级列的很高。直到前些天,我实在受不了了(此处省略了 N 多无奈纠结),只好硬着头皮硬上了。结果就有了这篇文章。
一些准备工作
首先,我解释一下这段代码的作用,其实 Gradle 对于没接触过的人和习惯于 Java 的人之所以难懂,是因为大量的语法糖省略了许多代码,而 IDE 本身对于智能不全的支持又相当的鸡肋,不管是读还是写,都特别费劲,我们把这段代码补全之后就容易理解多了。
1 | project.afterEvaluate(new Action<Project>() { |
这样再看,是不是就容易理解多了?其中 afterEvaluate
和 logger
都是 project
的方法,在每个 Gradle 工程里都有一个 project
对象,在所有的 build.gradle
文件里都可以直接调用 project
的所有共有方法和变量(所以说,所谓闭包、lambda 绝对不是学会 Gradle 的关键)。这段代码的目的就是为了输出所有最终打进 APK 包里的依赖,也就是在 build.gradle
的 dependencies
结点下面 compile
(未来的 Gradle Plugin 版本可能会变)的所有依赖;顺便说一句,如果要获取 dependencies
结点下面所有的依赖,将 apkLibraries
换成 compileLibraries
即可。这两个 API 在 com.android.build.gradle.api.ApkVariant
里,而 ApplicationVariant
继承自 ApkVariant
。
现象描述
先从日志输出入手,将正确的结果和不正确的结果放在一起对比后发现,正确的结果里包含两种类型的文件路径,一种是工程
build/intermediates/exploded-aar
目录下的临时缓存,另一种是.gradle
、.m2
和SDK
目录下的永久缓存(除非手动删除);它们还有另外一个特点,前者全都是 aar,后者都是 jar:我们再看一下不正确的结果,很容易发现,这结果里只剩下了
.gradle
、.m2
和SDK
目录下的缓存:也就是说,工程
build
目录下的临时缓存丢掉了,然后我先执行以下gradle clean
,将build
目录删掉,再重新编译,结果复现了!到了这里,我们可以确认这是个必现的 bug,而不是一开始想的时而正确,时而不正确。但是我的打包命令里明明包含了
clean
任务,为什么在 1 里的结果还是正确的呢?其实这时候,如果对 Gradle 的生命周期有了解的话,就可以猜得到结果了,这说明这段日志打印一定是在 Gradle 生命周期的Configuration
阶段输出的,而clean
任务的执行必然是在Configuration
之后的,这也就解释了为什么单独执行clean
任务,再执行assembleDebug
任务,得到的结果是错误的,而把clean
和assembleDebug
放在一起执行得到的结果是正确的。我们将打包命令后面加上—info
参数,就可以发现确实是在Configuration
过程中打印的日志输出。下图是 Gradle 生命周期的图示。
再接下来,我新建了个 demo,然后继续之前的操作,一个完全干净的工程,没有
buildDir
,结果出现了下面的结果:看到这样的结果,我直接就懵逼了…
build/intermediates/exploded-aar
居然消失了!!!跟上面的截图对比之后,发现取而代之的是$HOME/.android/build-cache
目录,而且即便没有build
临时缓存目录,得到的结果也是正确的。新建的 demo 构建环境跟我们的业务工程唯一的区别就是 Gradle 版本了,我把 demo 的 Gradle 版本降级到跟业务工程一样,结果完全一样了。看来,Google 也意识到了这个 bug,其实也好理解,工程buildDir
本就是临时目录,每次clean
之后都会删除,而如果使用系统级的缓存目录,执行clean
任务就不影响了。
追本溯源
接下来我们来看看最新版本的 Android Gradle Plugin 源码里是怎么处理的吧。
我们在上面的截图里可以看到,调用
androidBuilder.getAllPackagedJars
方法即可得到apkLibraries
,AndroidBuild
里的getAllPackagedJars
方法如下图:然后是
VariantConfiguration
里的getAllPackagedJars
方法:接着调到了
Dependency
类里的getClasspathFile
和getAdditionalClasspath
方法,而Dependency
是一个接口,我们看它的其中一个实现类(其他的也类似)AndroidDependency
,AndroidDependency
的构造方法是私有的,同时它提供了静态的共有 create 方法,因此创建AndroidDependency
都只能调用这些 create 方法,我们只要找到这些 create 方法调用的地方就可以了。然后我们可以发现在
DependencyManager
里调用了 create 初始化Dependency
的方法:到这里,就很明朗了。Gradle 构建过去缓存目录有两种方式,用哪种方式,取决于
PrepareLibraryTask.shouldUseBuildCache(buildCache.isPresent(), mavenCoordinates)
的值,也就是说buildCache.isPresent()
为 true,同时依赖版本号里含有-SNAPSHOT
,而前者需要buildCache
不为空:接下来我们可以看
com.android.build.gradle.AndroidGradleOptions
这个类,这里我们可以看到只要isBuildCacheEnabled
这个方法返回 true,上一步里的buildCache
就不为空,而DEFAULT_ENABLE_BUILD_CACHE
这个默认值恰恰就是 true,而且我们也可以看到在getBuildCacheDir
方法里,拼接的 dir 里刚好就有我们上面截图里的build-cache
,也就是说在新版本的 Gradle Plugin 里,走了步骤 4 里的 if 逻辑,而之前版本 Gradle Plugin 走的是 else 的逻辑。这时候我们再看步骤 4 里的 else 逻辑,里面其实就是一个给
explodedDir
的赋值操作,有两个常量字符串,他们的值如下图:拼在一起,刚好就是
build/intermediates/exploded-aar
,这也就是为什么在新版本的 Gradle Plugin 里这个目录消失了的原因。然而,难道说要解决这问题,只能升级 Gradle 版本?当然不是,而且我们还可以发现,这样的输出结果并不完全正确,我们想要的是某一个变种(variant)的输出,不同的变种,文件目录是不一样的,而这结果是无论我们是哪一个变种,结果都是所有的变种输出。
解决方案
我们有必要再重新审阅一下我们的需求,『在编译过程中,将最重要打到 APK 包里的 jar 库全都合并到一个 jar 里』,也就是说这些日志输出应该在 Gradle 生命周期的 Execution
阶段去打印,而不是 Configuration
阶段打印。在明确了这一点之后,另外一个更合适的解决方案就呼之欲出了,一点点修改就可以解决。
1 | afterEvaluate { |
仅仅是加了个 task.doFirst
就彻底解决了这个问题,在 task 执行的最开始先去获取需要处理的文件即可。折腾这么久,这样的结果看上去居然与过程几乎没什么关系…不过这过程才是学习最大的意义所在吧。