Room & Kotlin 符號的處理

Android開發者發表於2021-11-10

△ 圖片來自 Unsplash 由 Marc Reichelt 提供

△ 圖片來自 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 貢獻者。

更多資源

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

相關文章