Android編譯優化系列-kapt篇
作者:字節跳動終端技術———王龍海 封光 蘭軍健
一、背景
本文是編譯優化系列文章之 kapt 優化篇,後續還會有 build cache, kotlin, dex 優化等文章,敬請期待。本文由Client Infra->Build Infra團隊出品,powered by 王龍海,封光,蘭軍健
相信 android 開發對於 kapt 並不陌生,之前也有很多文章在編譯優化過程中談及過 Kapt,主要是針對增量編譯場景。
抖音火山版同學在接入 hilt 過程中,遇到了更嚴重的問題: 在 16G 記憶體的電腦上觸發 OOM。例如火山項目在執行 kapt 的過程中,不論採用 aar 依賴,還是全源碼編譯,均無法編譯通過,可以認為 Kapt 會對記憶體產生比較大的影響。
在分析這個問題之前,先介紹下 kapt 的原理。
二、 Kapt 原理
-
kapt的來源及使用
kapt 可以理解為就是在 kotlin 開發場景下進行註解處理的工具。至於作用可以完全等效於 java 的 apt。因為 java 的 apt 處理不了 kotlin 源碼文件,所以才出現了kapt,來實現混合工程或者純 kotlin 工程的 apt 任務。
使用起來非常簡單:
你只需要引入 kapt 插件,將原來的
annotationProcessor
換成 kapt
,即可讓 kotlin 幫你完成原來 apt 的工作。kapt “groupId:artifactId:version”
apply plugin: ‘kotlin-kapt’
當你在某個 module 下引入了 ‘kotlin-kapt’,相應的模組構建過程中就會自動生成
kaptGenerateStu
b
s
${variant}k
otlin
和kapt
${variant}
Kotlin
兩個Task。所以要最小化引入原則,按需引入,避免帶來較大的編譯耗時影響。-
原理分析
上文說到引入了 kapt 的模組會相應的增加兩個 Task,這兩個 Task 會完成處理註解生成類的功能。接下來我們簡單的看一下這兩個 Task 的工作原理。
這裡可以看到,整個 kapt 的處理過程分為了兩個步驟:”生成 Stub 文件”及”調用 apt 處理註解”。可以非常清晰的看到,其實kapt並沒有新的東西,底層依然是調用的 java apt 來完成的整個任務。這裡多說一句,
kotlin 團隊為什麼這麼設計呢?
Java 的 apt 是通過實現 JSR269 來實現的。JSR269 為 apt 插件定義了 api,Java apt 實現了這套 api。
那麼作為後起之秀,想要實現類似的功能可以很容易想到如下兩種方式:
- 重寫一套 JSR269 api。同時實現對於 kotlin 文件和 java 文件的 apt
- 想辦法復用 java 的 apt
顯然,第二種路徑更簡單且更成熟,再加上在 kotlin 考慮這件事之前,業界已有先例,比如 groovy 對於 apt 的支援也是這麼乾的。這就不難理解 kotlin 的設計思路了,只要想辦法把 kotlin 的源碼轉成 java 源碼即可。
到這裡就不難理解 kapt 的處理為什麼分為了兩個步驟:”生成 Stub 文件”及”調用 apt 處理註解”了。
下面說一下這兩個步驟的大致流程。
生成Stub文件
這個過程由
kaptGenerateStubs${variant}kotlin
承擔。 如上圖所示,A.kt 和 B.kt 經過處理後生成了 A.java 和 B.java。我們來看一下產物和我們想像的是否有不同之處。左邊是一個 .kt 文件,右邊是 kaptGenetateStub 生成的 .java 文件,聰明的你應該知道 kotlin 想幹嘛了吧?
可以看到,這裡並不是將 kotlin 源碼生成與之等效的 java 源碼,只是生成了類似 abi 形式的 java 源碼,只要保證能找到對應的方法和欄位的描述符即可,無需處理方法體的實現內容。
調用apt處理註解
這個過程的大致流程:
- KaptTask 找到 kapt 註冊的 kapt 插件,找到所有的 processors。
- KaptTask 會調用 jdk 的方法,對源文件進行解析並生成對應的 AST(抽象語法樹)。
- KaptTask 調用 jdk 進行 annotation processing,jdk 內部會 回調 #1 中找到的 processors。
- 業務方的 processors 裡面會完成寫入新 java 文件的邏輯,這時候,jdk 會帶上新的 java 文件去進行第二輪、第三輪 process。(因為新 java 文件裡面也可能引用了 processor 註冊的註解)。
整個 kapt 的原理就介紹到這裡。接下來我們來分析一下 kapt 可能帶來的問題。這裡會花一部分篇幅來講述下背景中提到的問題的解決過程。
三、kapt引發的記憶體問題
-
問題描述
這裡再簡單的描述下本文背景中提到的問題。
火山項目在接入 Hilt 的過程中,在 16G 的 mac 上打包無法通過,頻繁報 OOM,對應的堆棧如下:
初看堆棧是由編譯器內部報出來的問題,看起來是記憶體爆掉了,但是從堆棧上看不出明顯的突破點。
-
排查及分析過程
既然是記憶體問題,我們先想辦法復現下,推薦用 VisuaxlVM 進行分析,不了解該工具的同學可以點擊鏈接學習下,算是比較好用的JVM問題排查工具了。
-
記憶體分析
我們用 VisualVM 對 Gradle daemon 進程進行了記憶體分析。發現在 kapt 過程中,記憶體確實一直在往上漲。
為了能知道這些記憶體突然上漲的地方在程式碼里究竟發生了什麼,我們得想辦法進行程式碼調試。
-
準備工作
kotlin 的 debug 比 gradle 稍微麻煩一些,kotlin compiler 在運行的時候,有三種模式。
- in-process: 會在當前啟動的進程里調用 kotlin compiler 的入口,這時候 gradle 和 kotlin 在同一個進程里。
- out-process: 通過命令行工具單獨起一個進程進行編譯,主進程會等待獨立的進程編譯完成。
- daemon: daemon 進程是一個長期運行在後台的守護進程,和 gradle daemon 進程一樣,如果 gradle 發現有活著的 daemon 進程,那麼就會復用它,否則就會起一個新的 daemon 進程。
默認情況下,kotlin compiler 的程式碼是運行在 kotlin 的 daemon 進程中的,這裡我們為了方便,可以直接指定為 in-process 模式。這樣一來,相當於在 gradle 的 daemon 進程中進行調試,豈不是方便很多,進行如下設置即可。
./gradlew app:assembleCnFullDebug –stacktrace -Dorg.gradle.debug=true -Dkotlin.compiler.execution.strategy=in-process
-
詳細分析
能夠斷點調試後,通過 debug kotlin,很容易就梳理出 kapt 的完整執行流程,如下圖所示:
最終確定了是在 enterTrees() 方法中發生了OOM,那隻能繼續跟進到 jdk 的程式碼中。
跟著 jdk 的程式碼走了一遍之後,我們大概知道了在 jdk 中是這樣處理 apt 的。
我們開始進行 heap dump,結果如下:
從圖中可以發現,Scope$Entry[] 對象創建了1000多萬個,顯然不正常。
但火山項目實在太龐大了,一個 heap dump 就達 10 幾 G,如果直接選擇某個 Scope$Entry[] 對象進行GC Root 分析的話,等一天也完不成。
所以採用一個接入了 hilt 的 demo 進行測試。
從第一輪開始,選擇一個 Scope$Entry[] 對象,此時它的 GC Root 如下:
此時它的 GC Root 是 Java Frame,應該是正在執行某個方法,並且要用到它,有 GC Root 是正常的。
第二輪,此時 GC Root 如下:
還沒有釋放,這其實已經有點不符合預期了。
注意到 JavacProcessingEnvironment 中有這樣一段程式碼:
/** Create a new round. */
private Round(Round prev,
Set<JavaFileObject> newSourceFiles, Map<String,JavaFileObject> newClassFiles) {
this(prev.nextContext(),
prev.number+1,
prev.compiler.log.nerrors,
prev.compiler.log.nwarnings,
null);
this.genClassFiles = prev.genClassFiles;
List<JCCompilationUnit> parsedFiles = compiler.parseFiles(newSourceFiles);
roots = cleanTrees(prev.roots).appendList(parsedFiles);
// Check for errors after parsing
if (unrecoverableError())
return;
enterClassFiles(genClassFiles);
List<ClassSymbol> newClasses = enterClassFiles(newClassFiles);
genClassFiles.putAll(newClassFiles);
enterTrees(roots);
…
}
而 cleanTrees() 的操作如下:
private static <T extends JCTree> List<T> cleanTrees(List<T> nodes) {
for (T node : nodes)
treeCleaner.scan(node);
return nodes;
}
treeCleaner 的定義如下:
private static final TreeScanner treeCleaner = new TreeScanner() {
public void scan(JCTree node) {
super.scan(node);
if (node != null)
node.type = null;
}
public void visitTopLevel(JCCompilationUnit node) {
node.packge = null;
super.visitTopLevel(node);
}
public void visitClassDef(JCClassDecl node) {
node.sym = null;
super.visitClassDef(node);
}
public void visitMethodDef(JCMethodDecl node) {
node.sym = null;
super.visitMethodDef(node);
}
public void visitVarDef(JCVariableDecl node) {
node.sym = null;
super.visitVarDef(node);
}
public void visitNewClass(JCNewClass node) {
node.constructor = null;
super.visitNewClass(node);
}
public void visitAssignop(JCAssignOp node) {
node.operator = null;
super.visitAssignop(node);
}
public void visitUnary(JCUnary node) {
node.operator = null;
super.visitUnary(node);
}
…
顯然,jdk 的設計者想通過遍歷 JCTree,將語法樹上包括符號表在內的各對象置為空,從而讓這些對象有被釋放的機會。
但是,這樣的操作並沒有釋放掉符號表的引用,比如這裡就保存在 log 的 diagFormatter 對象中。
不過如果僅僅是這樣,問題也還不嚴重,因為從 GC Root 圖可發現 log.diagFormatter 每次都只保存前一次的符號表。
第三輪,這個時候總該釋放了吧,畢竟此時 log.diagFormatter 也沒保存它了,但結果是它竟然還有 GC Root,如下:
顯然,是有某個 JNI Global Reference 持有了它,導致它無法被釋放。
到這裡可以確定,由於 jdk8 的設計,導致每一輪處理註解而創建的符號表,都會一直保留在記憶體中,一直到全部處理完才釋放,從而導致對於程式碼量大或者 processors 數量多(比如 hilt 引入了13個 processor )的項目,就很容易因為佔用記憶體過大而導致 OOM。這個鍋 jdk 得背著。
實際上,kapt 也對於這種情況有所防範,所以會在結束 annotation processing 之後,進行記憶體泄漏檢測:
想判斷 kapt 過程是否有記憶體泄漏,可配置打開 log 開關查看。
如果在 annotation processing 過程就發生了 OOM,那麼它只能拋出異常,根本都不會走到記憶體泄漏探測這一步。可見這個記憶體泄漏檢測,對於本文的排查工作起不到什麼大的作用。
解決方案
雖然定位到了問題在 jdk 裡面,但官方一時間也不可能給解決,更何況這還是一個比較老的 jdk 版本。那隻能想想別的辦法了。
由於 jdk 中進行 annotation processing,會先將輸入的 java 文件進行語法分析,構建符號表,從而新建非常多類似 Scope$Entry[] 這樣的對象。
在 debug 中發現,一個源文件對應一個JCCompilationUnit,而一個 JCCompilationUnit 中就包含一棵語法樹。
從這裡可以推斷出,annotation processing 的記憶體佔用與輸入的源文件成正比關係。
那麼是否可以通過過濾輸入的源文件減少記憶體佔用呢?
我們分析了一遍輸入的文件,發現在 app module 中有大量的 R.java 參與了 kapt 編譯,對於中大型項目而言,至少會存在幾千到上萬個,關於 R.java 在 app 編譯中的作用,在這裡就不贅述了。
其實對於 app module 來說,R.java 只是輔助編譯的作用。一般來說,app module 都比較輕量,很少會放很多程式碼,但是由於 R.java 要參與輔助編譯,所以 R.java 被 agp 塞到了
javaSourceRoots
。但是由於有非常多的 module,並且每個 module 都存了它底層 module 的 R 值,所以會導致 app module 的 R.java 非常多,非常龐大。似乎 google 官方也意識到了這一點,在 AGP 3.6.0 中,google 把 R.java 換成了 R.jar 來輔助編譯。在火山的項目中,有 95% 的輸入文件都是 R.java,並且每個 R.java 都有大幾千行的程式碼。因為 R.java 裡面都是一些沒有註解的 field。
可以說,R.java 文件是與 kapt 無關的,完全沒必要參與語法分析,增加額外的執行時間和記憶體。
所以,將 KaptTask 中 javaSourceRoots 的程式碼改為如下,過濾掉生成的 R.java。
@get:Internal
protected val javaSourceRoots: Set<File>
get() = unfilteredJavaSourceRoots.filterTo(HashSet(), ::isRootAllowed).filterTo(HashSet(),{
!(it.absolutePath.contains(“generated/not_namespaced_r_class_sources/”))
})
收益
目前該 feature 一魔改版的 kotlin 已經接入火山,今日頭條等項目。
對於火山來說,app:kapt task 從 18min 發生 OOM,變為 15s 編譯通過,不僅減少了很多編譯時間,而且節約了 13G+ 的記憶體空間。
而對於其他之前未發生 OOM 的 kapt task, 其實也一樣有收益,如下圖是在頭條進行測試前後的對比圖:
其中左邊是接入後的執行時間,可見,kapt task從 30.810s 減少到 1.431s,速度提升了 20 倍。
另外多說一句:在 debug jdk 的過程中,發現 jdk 8 無論從模組解耦,還是記憶體管理都做得並不好,不過也能理解,畢竟這主要是 2013 年完成的程式碼。所以,從編譯優化的角度看,儘快升級項目中使用的 jdk 版本也是一件收益較大的事情(事實上使用 jdk9 就能編過,雖然還是慢)。
需要注意的是,以上優化適應於AGP 3.6.0之前,在AGP 3.6.0之後,由於參與編譯的是R.jar而不是R.java, 不存在此問題,本文重點闡述的是kapt的原理,遇到相關問題的排查過程以及進行優化的思路。最後,針對 Kapt 相關優化給出幾點建議。
四、Kapt的建議與優化
要想 kapt 的使用不引入大的編譯相關負向收益,我們有以下幾點建議:
-
收斂 kapt 作用域
之前遇到很多項目組,為了方便會創建一個 library.gradle/base.gradle 這樣的文件,這個文件中定義了很多通用的 kapt 依賴,隨著項目模組化組件化的改造,項目中模組數量越來越多,一些只包含 model 類和介面、完全不需要 kapt 的 api 模組也被統一的使用到了這些 kapt 依賴,使得項目中有大量模組進行了無意義的 kapt 耗時, 因此我們建議:
- 盡量不要在類似於 library.gradle 的文件中為所有 module 添加統一的 kapt 依賴,改成具體模組按需使用。
- 或者有區分度的創建 library.gradle, library-api.gradle ,按照模組類型選擇適當的模板文件,如api 類型的模組就不需要 apply kotlin-kapt 的 plugin,也不需要依賴 kapt 庫
-
接入優化工具
本文只闡述了kapt關於記憶體問題的一個相關優化,其實 kapt 及 kotlin 編譯還有很多的問題值得去優化。目前在位元組內部,我們團隊開發了一系列優化工具來無感知地解決此類問題來加快增量編譯速度。受限於篇幅原因,這裡不進行展開說明,後續會有單獨的文章來闡述相關內容。
-
盡量尋找 kapt 的替代方案
在項目中使用 kapt 無非是需要一個通用的程式碼生成邏輯,減少重複程式碼的編寫,能實現類似效果的方案不僅僅只有 kapt :
- 可以使用 google 官方提供的 transform api ,在 java 程式碼編譯成位元組碼後直接修改創建位元組碼,而且公司內已經有 byteX , any-register 等 transform 框架,可以很方便的基於這些框架寫位元組碼插樁邏輯,同時利用這些框架的 io 復用能力,也能進一步的提升編譯速度。
- 可以在 debug 打包時用反射方案,在 release 打包時繼續用 kapt ,這樣可以兼顧開發體驗和運行效率。
-
期待KSP,及時擁抱
kapt 需要先經過 kaptGenerateStub 將 kotlin 程式碼轉換為 java 程式碼,然後再交給 jdk 處理,這樣顯然太麻煩了。那麼,是否可以直接在 kotlin compiler 中就進行 annotation processing 呢?答案是肯定的,實際上 kotlin 官方在更高的版本上已經有了這樣的方案,叫 Kotlin Symbol Processing(KSP),不過目前還處於 alpha 階段,還需要等待各大 processor 進行適配。等穩定之後我們會推出關於 KSP 的最佳實踐,幫助大家更好地進行 annotation processing 的開發。
五、加入我們
Build Infra 團隊致力於解決 android 研發體驗問題,提升 android 編譯體驗,負責保障和提升公司內各業務線的研發構建效率。如果你對技術充滿熱情,追求極致,歡迎加入我們,我們期待你與我們共同成長 。目前我們在北京、上海、杭州均有招聘需求,簡歷投遞郵箱:
[email protected] , 郵件標題是:姓名-Devops-Build Infra.
🔥 火山引擎 APMPlus 應用性能監控是火山引擎應用開發套件 MARS 下的性能監控產品。我們通過先進的數據採集與監控技術,為企業提供全鏈路的應用性能監控服務,助力企業提升異常問題排查與解決的效率。目前我們面向中小企業特別推出「APMPlus 應用性能監控企業助力行動」,為中小企業提供應用性能監控免費資源包。現在申請,有機會獲得60天免費性能監控服務,最高可享6000萬條事件量。
👉 點擊這裡,立即申請