Android編譯優化系列-kapt篇

一、背景

本文是編譯優化系列文章之 kapt 優化篇,後續還會有 build cache, kotlin, dex 優化等文章,敬請期待。本文由Client Infra->Build Infra團隊出品,powered by 王龍海,封光,蘭軍健

相信 android 開發對於 kapt 並不陌生,之前也有很多文章在編譯優化過程中談及過 Kapt,主要是針對增量編譯場景。

抖音火山版同學在接入 hilt 過程中,遇到了更嚴重的問題: 在 16G 內存的電腦上觸發 OOM。例如火山項目在執行 kapt 的過程中,不論採用 aar 依賴,還是全源碼編譯,均無法編譯通過,可以認爲 Kapt 會對內存產生比較大的影響。

在分析這個問題之前,先介紹下 kapt 的原理。

二、 Kapt 原理

  1. 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``otlinkapt``${variant}``Kotlin兩個Task。所以要最小化引入原則,按需引入,避免帶來較大的編譯耗時影響。

  1. 原理分析

上文說到引入了 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引發的內存問題

  1. 問題描述

這裏再簡單的描述下本文背景中提到的問題。

火山項目在接入 Hilt 的過程中,在 16G 的 mac 上打包無法通過,頻繁報 OOM,對應的堆棧如下:

初看堆棧是由編譯器內部報出來的問題,看起來是內存爆掉了,但是從堆棧上看不出明顯的突破點。

  1. 排查及分析過程

既然是內存問題,我們先想辦法復現下,推薦用 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 的使用不引入大的編譯相關負向收益,我們有以下幾點建議:

  1. 收斂 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 庫
  1. 接入優化工具

本文只闡述了kapt關於內存問題的一個相關優化,其實 kapt 及 kotlin 編譯還有很多的問題值得去優化。目前在字節內部,我們團隊開發了一系列優化工具來無感知地解決此類問題來加快增量編譯速度。受限於篇幅原因,這裏不進行展開說明,後續會有單獨的文章來闡述相關內容。

  1. 儘量尋找 kapt 的替代方案

在項目中使用 kapt 無非是需要一個通用的代碼生成邏輯,減少重複代碼的編寫,能實現類似效果的方案不僅僅只有 kapt :

  • 可以使用 google 官方提供的 transform api ,在 java 代碼編譯成字節碼後直接修改創建字節碼,而且公司內已經有 byteX , any-register 等 transform 框架,可以很方便的基於這些框架寫字節碼插樁邏輯,同時利用這些框架的 io 複用能力,也能進一步的提升編譯速度。
  • 可以在 debug 打包時用反射方案,在 release 打包時繼續用 kapt ,這樣可以兼顧開發體驗和運行效率。
  1. 期待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萬條事件量。

👉 點擊這裏,立即申請

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章