Room & Kotlin 符號的處理

△ 圖片來自 Unsplash 由 Marc Reichelt 提供

Jetpack Room 庫在 SQLite 上提供了一個抽象層,能夠在沒有任何樣板代碼的情況下,提供編譯時驗證 SQL 查詢的能力。它通過處理代碼註解和生成 Java 源代碼的方式,實現上述行爲。

註解處理器非常強大,但它們會增加構建時間。這對於用 Java 寫的代碼來說通常是可以接受的,但對於 Kotlin 而言,編譯時間消耗會非常明顯,這是因爲 Kotlin 沒有一個內置的註解處理管道。相反,它通過 Kotlin 代碼生成了存根 Java 代碼來支持註解處理器,然後將其輸送到 Java 編譯器中進行處理。

由於並不是所有 Kotlin 源代碼中的內容都能用 Java 表示,因此有些信息會在這種轉換中丟失。同樣,Kotlin 是一種多平臺語言,但 KAPT 只在面向 Java 字節碼的情況下生效。

認識 Kotlin 符號處理

隨着註解處理器在 Android 上的廣泛使用,KAPT 成爲了編譯時的性能瓶頸。爲了解決這個問題,Google Kotlin 編譯器團隊開始研究一個替代方案,來爲 Kotlin 提供一流的註解處理支持。當這個項目誕生之初,我們非常激動,因爲它將幫助 Room 更好地支持 Kotlin。從 Room 2.4 開始,它對 KSP 有了實驗性的支持,我們發現編譯速度提高了 2 倍,特別是在全量編譯的情況下。

本文內容重點不在註解的處理、Room 或者 KSP。而在於重點介紹我們在爲 Room 添加 KSP 支持時所面臨的挑戰和所做的權衡。爲了理解本文您並不需要了解 Room 或者 KSP,但必須熟悉註解處理。

注意: 我們在 KSP 發佈穩定版之前就開始使用它了。因此,尚不確定之前做的一些決策是否適用於現在。

本篇文章旨在讓註解處理器的作者們在爲項目添加 KSP 支持前,充分了解需要注意的問題。

Room 工作原理簡介

Room 的註解處理分爲兩個步驟。有一些 "Processor" 類,它們遍歷用戶的代碼,驗證並提取必要的信息到 "值對象" 中。這些值對象被送到 "Writer" 類中,這些類將它們轉換爲代碼。和其他諸多的註解處理器一樣,Room 非常依賴 Auto-Commonjavax.lang.model 包 (Java 註解處理 API 包) 中頻繁引用的類。

爲了支持 KSP,我們有三種選擇:

  1. 複製 JavaAP 和 KSP 的每個 "Processor" 類,它們會有相同的值對象作爲輸出,我們可以將其輸入到 Writer 中;
  2. 在 KSP/Java AP 之上創建一個抽象層,以便處理器擁有一個基於該抽象層的實現;
  3. 用 KSP 代替 JavaAP,並要求開發者也使用 KSP 來處理 Java 代碼。

選項 C 實際上是不可行的,因爲它會對 Java 用戶造成嚴重的干擾。隨着 Room 使用數量的增加,這種破壞性的改變是不可能的。在 "A" 和 "B" 兩者之間,我們決定選擇 "B",因爲處理器具有相當數量的業務邏輯,將其分解並非易事。

認識 X-Processing

在 JavaAP 和 KSP 上創建一個通用的抽象並非易事。Kotlin 和 Java 可以互操作,但模式卻不相同,例如,Kotlin 中特殊類的類型如 Kotlin 的值類或者 Java 中的靜態方法。此外,Java 類中有字段和方法,而 Kotlin 中有屬性和函數。

我們決定實現 "Room 需要什麼",而不是嘗試去追求完美的抽象。從字面意思來看,在 Room 中找到導入了 javax.lang.model 的每一個文件,並將其移動到 X-Processing 的抽象中。這樣一來,TypeElement 變成了 XTypeElementExecutableElemen 變成了 XExecutableElemen 等等。

遺憾的是,javax.lang.model API 在 Room 中的應用非常廣泛。一次性創建所有這些 X 類,會給審閱者帶來非常嚴重的心理負擔。因此,我們需要找到一種方法來迭代這一實現。

另一方面,我們需要證明這是可行的。所以我們首先對其做了 原型 設計,一旦驗證這是一個合理的選擇,我們就用他們自己的測試 逐一重新實現了所有 X 類

關於我說的實現 "Room 需要什麼",有一個很好的例子,我們可以在關於類的字段 更改 中看到。當 Room 處理一個類的字段時,它總是對其所有的字段感興趣,包括父類中的字段。所以我們在創建相應的 X-Processing API 時,添加了獲取所有字段的能力。

interface XTypeElement {
  fun getAllFieldsIncludingPrivateSupers(): List<XVariableElement>
}

如果我們正在設計一個通用庫,這樣可能永遠不會通過 API 審查。但因爲我們的目標只是 Room,並且它已經有一個與 TypeElement 具有相同功能的輔助方法,所以複製它可以減少項目的風險。

一旦我們有了基本的 X-Processing API 和它們的測試方法,下一步就是讓 Room 來調用這個抽象。這也是 "實現 Room 所需要的東西" 獲得良好回報的地方。Room 在 javax.lang.model API 上已經擁有了用於基本功能的擴展函數/屬性 (例如獲取 TypeElement 的方法)。我們首先更新了這些擴展,使其看起來與 X-Processing API 類似,然後在 1 CL 中將 Room 遷移到 X-Processing。

改進 API 可用性

保留類似 JavaAP 的 API 並不意味着我們不能改進任何東西。在將 Room 遷移到 X-Processing 之後,我們又實現了一系列的 API 改進。

例如,Room 多次調用 MoreElement/MoreTypes,以便在不同的 javax.lang.model 類型 (例如 MoreElements.asType) 之間進行轉換。相關調用通常如下所示:

val element: Element ...
if (MoreElements.isType(element)) {
  val typeElement:TypeElement = MoreElements.asType(element)
}

我們把所有的調用放到了 Kotlin contracts 中,這樣一來就可以寫成:

val element: XElement ...
if (element.isTypeElement()) {
  // 編譯器識別到元素是一個 XTypeElement
}

另一個很好的例子是在一個 TypeElement 中找尋方法。通常在 JavaAP 中,您需要調用 ElementFilter 類來獲取 TypeElement 中的方法。與此相反,我們直接將其設爲 XTypeElement 中的一個屬性。

// 前
val methods = ElementFilter.methodsIn(typeElement.enclosedElements)
// 後
val methods = typeElement.declaredMethods

最後一個例子,這也可能是我最喜歡的例子之一,就是可分配性。在 JavaAP 中,如果您要檢查給定的 TypeMirror 是否可以由另一個 TypeMirror 賦值,則需要調用 Types.isAssignable

val type1: TypeMirror ...
val type2: TypeMirror ...
if (typeUtils.isAssignable(type1, type2)) {
  ...
}

這段代碼真的很難讀懂,因爲您甚至無法猜到它是否驗證了類型 1 可以由類型 2 指定,亦或是完全相反的結果。我們已經有一個擴展函數如下:

fun TypeMirror.isAssignableFrom(
  types: Types,
  otherType: TypeMirror
): Boolean

在 X-Processing 中,我們能夠將其轉換爲 XType 上的常規函數,如下方所示:

interface XType {
  fun isAssignableFrom(other: XType): Boolean
}

爲 X-Processing 實現 KSP 後端

這些 X-Processing 接口每個都有自己的測試套件。我們編寫它們並非是用來測試 AutoCommon 或者 JavaAP 的,相反,編寫它們是爲了在有了它們的 KSP 實現時,我們就可以運行測試用例來驗證它是否符合 Room 的預期。

由於最初的 X-Processing API 是按照 avax.lang.model 建模,它們並非每次都適用於 KSP,所以我們也改進了這些 API,以便在需要時爲 Kotlin 提供更好的支持。

這樣產生了一個新問題。現有的 Room 代碼庫是爲了處理 Java 源代碼而寫的。當應用是由 Kotlin 編寫時,Room 只能識別該 Kotlin 在 Java 存根中的樣子。我們決定在 X-Processing 的 KSP 實現中保持類似行爲。

例如,Kotlin 中的 suspend 函數在編譯時生成如下簽名:

// kotlin
suspend fun foo(bar:Bar):Baz
// java
Object foo(bar:Bar, Continuation<? extends Baz>)

爲保持相同的行爲,KSP 中的 XMethodElement 實現爲 suspend 方法合成了一個新參數,以及新的返回類型。(KspMethodElement.kt)

注意: 這樣做效果很好,因爲 Room 生成的是 Java 代碼,即使在 KSP 中也是如此。當我們添加對 Kotlin 代碼生成的支持時,可能會引起一些變化。

另一個例子與屬性有關。Kotlin 屬性也可能具有基於其簽名的合成 getter/setter (訪問器)。由於 Room 期望找到這些訪問器作爲方法 (參見: KspTypeElement.kt),因此 XTypeElement 實現了這些合成方法。

注意 : 我們已有計劃更改 XTypeElement API 以提供屬性而非字段,因爲這纔是 Room 真正想要獲取的內容。正如您現在猜到的那樣,我們決定 "暫時" 不這樣做來減少 Room 的修改。希望有一天我們能夠做到這一點,當我們這樣做時,XTypeElement 的 JavaAP 實現將會把方法和字段作爲屬性捆綁在一起。

在爲 X-Processing 添加 KSP 實現時,最後一個有趣的問題是 API 耦合。這些處理器的 API 經常相互訪問,因此如果不實現 XField / XMethod,就不能在 KSP 中實現 XTypeElement,而 XField / XMethod 本身又引用了 XType 等等。在添加這些 KSP 實現的同時,我們爲它們的實現部分寫了單獨的測試用例。當 KSP 的實現變得更加完整時,我們逐漸通過 KSP 後端啓動全部的 X-Processing 測試。

需要注意的是,在此階段我們只在 X-Processing 項目中運行測試,所以即使我們知道測試的內容沒問題,我們也無法保證所有的 Room 測試都能通過 (也稱之爲單元測試 vs 集成測試)。我們需要通過一種方法來使用 KSP 後端運行所有的 Room 測試,"X-Processing-Testing" 就應運而生。

認識 X-Processing-Testing

註解處理器的編寫包含 20% 的處理器代碼和 80% 的測試代碼。您需要考慮到各種可能的開發者錯誤,並確保如實報告錯誤消息。爲了編寫這些測試,Room 已經提供一個輔助方法如下:

fun runTest(
  vararg javaFileObjects: JavaFileObject,
  process: (TestInvocation) -> Unit
): CompilationResult

runTest 在底層使用了 Google Compile Testing 庫,並允許我們簡單地對處理器進行單元測試。它合成了一個 Java 註解處理器並在其中調用了處理器提供的 process 方法。

val entitySource : JavaFileObject //示例 @Entity 註釋類
val result = runTest(entitySource) { invocation ->
  val element = invocation.processingEnv.findElement("Subject")
  val entityValueObject = EntityProcessor(...).process(element)
  // 斷言 entityValueObject
}
// 斷言結果是否有誤,警告等

糟糕的是,Google Compile Testing 僅支持 Java 源代碼。爲了測試 Kotlin 我們需要另一個庫,幸運的是有 Kotlin Compile Testing,它允許我們編寫針對 Kotlin 的測試,而且我們爲該庫貢獻了對 KSP 支持。

注意 : 我們後來用 內部實現 替換了 Kotlin Compile Testing,以簡化 AndroidX Repo 中的 Kotlin/KSP 更新。我們還添加了更好的斷言 API,這需要我們對 KCT 執行 API 不兼容的修改操作。

作爲能讓 KSP 運行所有測試的最後一步,我們創建了以下測試 API:

fun runProcessorTest(
  sources: List<Source>,
  handler: (XTestInvocation) -> Unit
): Unit

這個和原始版本之間的主要區別在於,它同時通過 KSP 和 JavaAP (或 KAPT,取決於來源) 運行測試。因爲它多次運行測試且 KSP 和 JavaAP 兩者的判斷結果不同,因此無法返回單個結果。

因此,我們想到了一個辦法:

fun XTestInvocation.assertCompilationResult(
  assertion: (XCompilationResultSubject) -> Unit
}

每次編譯後,它都會調用結果斷言 (如果沒有失敗提示,則檢查編譯是否成功)。我們把每個 Room 測試重構爲如下所示:

val entitySource : Source //示例 @Entity 註釋類
runProcessorTest(listOf(entitySource)) { invocation ->
  // 該代碼塊運行兩次,一次使用 JavaAP/KAPT,一次使用 KSP
  val element = invocation.processingEnv.findElement("Subject")
  val entityValueObject = EntityProcessor(...).process(element)
  //  斷言 entityValueObject
  invocation.assertCompilationResult {
    // 結果被斷言爲是否有 error,warning 等
    hasWarningContaining("...")
  }
}

接下來的事情就很簡單了。將每個 Room 的編譯測試遷移到新的 API,一旦發現新的 KSP / X-Processing 錯誤,就會上報,然後實施臨時解決方案;這一動作反覆進行。由於 KSP 正在大力開發中,我們確實遇到了很多 bug。每一次我們都會上報 bug,從 Room 源鏈接到它,然後繼續前進 (或者進行修復)。每當 KSP 發佈之後,我們都會搜索代碼庫來找到已修復的問題,刪除臨時解決方案並啓動測試。

一旦編譯測試覆蓋情況較好,我們在下一步就會使用 KSP 運行 Room 的 集成測試。這些是實際的 Android 測試應用,也會在運行時測試其行爲。幸運的是,Android 支持 Gradle 變體,因此使用 KSP 和 KAPT 來運行我們 Kotlin 集成測試 便相當容易。

下一步

將 KSP 支持添加到 Room 只是第一步。現在,我們需要更新 Room 來使用它。例如,Room 中的所有類型檢查都忽略了 nullability,因爲javax.lang.modelTypeMirror 並不理解 nullability。因此,當調用您的 Kotlin 代碼時,Room 有時會在運行時觸發 NullPointerException。有了 KSP,這些檢查現在可在 Room 中創建新的 KSP bug (例如 b/193437407)。我們已經添加了一些臨時解決方案,但理想情況下,我們仍希望 改進 Room 以正確處理這些情況。

同樣,即使我們支持 KSP,Room 仍然只生成 Java 代碼。這種限制使我們無法添加對某些 Kotlin 特性的支持,比如 Value Classes。希望在將來,我們還能對生成 Kotlin 代碼提供一些支持,以便在 Room 中爲 Kotlin 提供一流的支持。接下來,也許更多 :)。

我能在我的項目上使用 X-Processing 嗎?

答案是還不能;至少與您使用任何其他 Jetpack 庫的方式不同。如前文所述,我們只實現了 Room 需要的部分。編寫一個真正的 Jetpack 庫有很大的投入,比如文檔、API 穩定性、Codelabs 等,我們無法承擔這些工作。話雖如此,Dagger 和 Airbnb (ParisDeeplinkDispatch) 都開始用 X-Processing 來支持 KSP (並貢獻了他們需要的東西🙏)。也許有一天我們會把它從 Room 中分解出來。從技術層面上講,您仍然可以像使用 Google Maven 庫 一樣使用它,但是沒有 API 保證可以這樣做,因此您絕對應該使用 shade 技術。

總結

我們爲 Room 添加了 KSP 支持,這並非易事但絕對值得。如果您在維護註解處理器,請添加對 KSP 的支持,以提供更好的 Kotlin 開發者體驗。

特別感謝 Zac SweersEli Hart 審校這篇文章的早期版本,他們同時也是優秀的 KSP 貢獻者。

更多資源

歡迎您 點擊這裏 向我們提交反饋,或分享您喜歡的內容、發現的問題。您的反饋對我們非常重要,感謝您的支持!

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