一個關於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教程 或 經驗心得?
※今天開始 Scala
※2016年讀過的Android好書推薦
※Bugly多渠道熱更新解決方案