深入淺出 Compose Compiler(2) 編譯器前端檢查
theme: vuepress highlight: androidstudio
本文為稀土掘金技術社群首發簽約文章,14天內禁止轉載,14天后未獲授權禁止轉載,侵權必究!
前一篇文章最後提到了 Compose Compiler 中的眾多 Extension,其中一些是編譯期前端的各種 Checker ,他們負責對 Compose 程式碼進行編譯期檢查:
- ComposableCallChecker:檢查是否可以呼叫 @Composable 函式
- ComposableDeclarationChecker:檢查 @Composable 的位置是否正確
- ComposeDiagnosticSuppressor:遮蔽不必要的編譯診斷錯誤
ComposableCallChecker
ComposableCallChecker
負責檢查 Composable 的呼叫是否合法。Compose Compiler 的 Checker 目前還不支援 FIR ,需基於 PSI 進行檢查。
Compiler 基於訪問者模式深度遍歷每個 PSI 節點。ComposableCallChecker 繼承自 CallChecker,後者在 PSI 訪問過程中,當遇到 CALL_EXPRESSION
時 check 方法會被回撥,我們可以在此處通過向上遍歷 Parent 看呼叫是否合理。
上圖的 Case 中,當我們遇到 CALL_EXPRESSION 節點時,判斷它是否是一個 Composable 呼叫,我們向上查詢父節點,當 Parent 中出現 FUN 時,檢查它有沒有攜帶 @Composable ,如果沒有攜帶則報錯。
簡單看一下 check 方法的相關實現:
```kotlin open class ComposableCallChecker : CallChecker, AdditionalTypeChecker, StorageComponentContainerContributor {
//...
override fun check(
resolvedCall: ResolvedCall<*>,
reportOn: PsiElement,
context: CallCheckerContext
) {
if (!resolvedCall.isComposableInvocation()) {
//如果當前不是 Composable 呼叫,則停止檢查
return
}
//...
loop@while (node != null) {
//遍歷父節點,對呼叫處的合法性進行檢查
when (node) {
//...
is KtFunction -> {
val descriptor = bindingContext[BindingContext.FUNCTION, node]
if (descriptor == null) {
illegalCall(context, reportOn)
return
}
val composable = descriptor.isComposableCallable(bindingContext)
if (!composable) {
illegalCall(context, reportOn, node.nameIdentifier ?: node)
}
//...
return
}
//...
}
node = node.parent as? KtElement
}
//...
}
//...
} ```
KtFunction
是 PsiElement 中 FUN 對應的節點型別,這裡出現了前一篇文章中介紹過的 bindingContext 。我們可以從 BindingContext 獲取當前 node 對應的 Descriptor。 isComposableCallable
中判斷節點是否添加了 @Composable 註解,如果不是一個 Composable 函式,即出現了非法呼叫,使用 illegalCall
編譯報錯;若是一個合法呼叫則正常 return。
再看一下當 node 為 KtLambdaExpression
的 case,即在 Lambda 中呼叫 Composable 函式:
```kotlin loop@while (node != null) { when (node) { //... is KtLambdaExpression -> {
//...
// 檢查是否是 @Composable
val composable = descriptor.isComposableCallable(bindingContext)
if (composable) return
//...
// 如果不是 @Composable ,則判斷是否是 inline
val isInlined = isInlinedArgument(
node.functionLiteral,
bindingContext,
true
)
if (!isInlined) {
//如果不是 inline 報錯退出
illegalCall(context, reportOn)
return
} else {
// 如果是 inline 在 BindingContext 做記錄,然後繼續向上查詢
context.trace.record(
ComposeWritableSlices.LAMBDA_CAPABLE_OF_COMPOSER_CAPTURE,
descriptor,
true
)
}
}
//...
}
node = node.parent as? KtElement
} ```
這裡有一個值得注意的檢查邏輯,判斷 lambda 是否為 inline。對於 inline lambda 可以不新增 @Composable ,只要呼叫 lambda 的地方是 @Composable 即可。
用下面的例子闡釋這個檢查效果:
Bar 接收一個 lambda 引數 block,由於 Bar 是一個 inline 函式,即使 block 本身沒有 @Composable,但是當在 @Composable 的 Foo 中呼叫 inline 的 lambda 時,lambda 內部對 Composable 的呼叫不會出錯,所以可以正常呼叫 Composable Baz。
程式碼中出現了 context.trace.record
,它用來在 BindingContext 中為 descriptor 新增一些上下文資訊。PSI 的遍歷基於訪問者模式,因此獲取距離當前節點較遠的資訊是比較麻煩的。通過 context.trace
可以對訪問過的節點資訊記錄後更大範圍使用,比如這裡對訪問過的 inline lambda 做了標記 LAMBDA_CAPABLE_OF_COMPOSER_CAPTURE
,表示這個 lambda 中可以呼叫 Composable ,在後續訪問其他節點時,就可以快速對這個 node 進行這方面的判斷。
@DisallowComposableCalls
到這裡也許有人會問,我如果就是不想 inline lambda 中呼叫 Composable 怎麼辦?原來 Compiler 原始碼中也已經揭示了相關解決方案:
```kotlin //獲取 lambda 引數的資訊 val arg = getArgumentDescriptor(node.functionLiteral, bindingContext)
//檢查 lambda 引數是否有 @DisallowComposableCalls 註解 if (arg?.type?.hasDisallowComposableCallsAnnotation() == true) { context.trace.record( ComposeWritableSlices.LAMBDA_CAPABLE_OF_COMPOSER_CAPTURE, descriptor, false ) context.trace.report( ComposeErrors.CAPTURED_COMPOSABLE_INVOCATION.on( reportOn, arg, arg.containingDeclaration ) ) return } ```
這段邏輯會獲取 lambda 作為引數定義時的資訊,判斷 lambda 引數是否添加了 @DisallowComposableCalls
註解。添加了此註解的 lambda 即使是 inline 的也不允許內部呼叫 Composable。因此這裡使用 context.trace.report
報了編譯錯誤,同時用 context.trace.record
為 node 做了記錄 LAMBDA_CAPABLE_OF_COMPOSER_CAPTURE
為 false。
context.trace.report
報錯時的具體文案定義在 ComposeErrorMessages 中,有時這些 messages 可以幫助我們理解 Compiler 原始碼的含義
kotlin
MAP.put(
ComposeErrors.CAPTURED_COMPOSABLE_INVOCATION,
"Composable calls are not allowed inside the {0} parameter of {1}",
Renderers.NAME,
Renderers.COMPACT
)
@ReadOnlyComposable
```kotlin is KtFunction -> { // 檢查 @Composable 註解 val composable = descriptor.isComposableCallable(bindingContext) if (!composable) { illegalCall(context, reportOn, node.nameIdentifier ?: node) }
// 檢查 @ReadOnlyComposable 註解
if (descriptor.hasReadonlyComposableAnnotation()) {
// enforce that the original call was readonly
if (!resolvedCall.isReadOnlyComposableInvocation()) {
illegalCallMustBeReadonly(
context,
reportOn
)
}
}
return
} ```
當 node 是 KtFunction 時,除了 @Composable,還對另一個註解 @ReadOnlyComposable
進行了檢查,即 @ReadOnlyComposable 函式只能在 @ReadOnlyComposable 內呼叫。那麼 @ReadOnlyComposable 是做什麼的呢?
我們知道新增 @Composable 註解的函式內部在編譯期會生成 startXXGroup/endXXGroup
等程式碼,Group 可以理解為 Composition 的節點,函式在執行時,通過這些生成的程式碼將建立 Group 並寫入 Composition ,最終實現整個 UI 樹的構建和更新。某些情況下 Composable 函式並不需要建立 Group,所以也無需生成這些程式碼,此時通過新增 @ReadOnlyComposable 註解,有助於節省一些 Compose 編譯和執行時的開銷。
一個常見的 @ReadOnlyComposable 的使用場景是對 MaterialTheme 的 colors, typography, shapes 等的訪問,此時我們僅僅是需要訪問 CompositionLocal,並不會呼叫其他 Composable 函式:
kotlin
object MaterialTheme {
val colors: Colors
@Composable
@ReadOnlyComposable
get() = LocalColors.current
//...
}
之前大家可能很少留意到 @DisallowComposableCalls,@ReadOnlyComposable 等註解的存在,而現在通過閱讀 Compiler 原始碼,加深了我們對 Compose 的掌握程度。
ComposableCallChecker 裡還很多檢查邏輯,相信有了前面的介紹,剩餘的原始碼大家應該又能去自行閱讀了。
ComposableDeclarationChecker
ComposableDeclarationChecker
主要檢查 @Composable 出現的位置是否合法。
```kotlin @Retention(AnnotationRetention.BINARY) @Target( // function declarations // @Composable fun Foo() { ... } // lambda expressions // val foo = @Composable { ... } AnnotationTarget.FUNCTION,
// type declarations
// var foo: @Composable () -> Unit = { ... }
// parameter types
// foo: @Composable () -> Unit
AnnotationTarget.TYPE,
// composable types inside of type signatures
// foo: (@Composable () -> Unit) -> Unit
AnnotationTarget.TYPE_PARAMETER,
// composable property getters and setters
// val foo: Int @Composable get() { ... }
// var bar: Int
// @Composable get() { ... }
AnnotationTarget.PROPERTY_GETTER
) annotation class Composable ```
從註解本身的定義可知,@Composable 可以修飾函式、函式型別、函式型別的引數以及 Custom-get 等場所。對於 AnnotationTarget 不正確的情況,無需 Compose Compiler,常規 Kotlin Compiler 就能發現錯誤。但即使 AnnotationTarget 符合上述幾種型別,也不代表就一定可以新增 @Composable 註解,此時需要藉助 Compose Compiler 的 ComposableDeclarationChecker 進行進一步檢查。
checkFunction
當 @Composable 修飾了函式時,並非所有的函式都可以變身為 Composable 函式。 例如 main 函式不能成為 Composable 函式,因為 main 需要被系統呼叫,還無法提供 Composer 上下文;
kotlin
//main 不能新增 @Composable
if (hasComposableAnnotation &&
descriptor.name.asString() == "main" &&
MainFunctionDetector(
context.trace.bindingContext,
context.languageVersionSettings
).isMain(descriptor)
) {
context.trace.report(
COMPOSABLE_FUN_MAIN.on(declaration.nameIdentifier ?: declaration)
)
}
再比如,suspend 函式也不能成為 Composable 函式,suspend 自身在編譯期有大量的 codegen 產生,這與 Compose 的 codegen 難以協調:
kotlin
//suspend 不能新增 @Composable
if (descriptor.isSuspend && hasComposableAnnotation) {
context.trace.report(
COMPOSABLE_SUSPEND_FUN.on(declaration.nameIdentifier ?: declaration)
)
}
當函式有重寫時,還需要檢查與被重寫函式是否一致,即 Composable 函式的重寫實現也必須是 Composable 函式,反之普通函式的重寫函式必須是普通函式。不一致時會報下面的錯誤:
相關 check 程式碼如下:
kotlin
if (descriptor.overriddenDescriptors.isNotEmpty()) {
//找到當前函式重寫的父函式
val override = descriptor.overriddenDescriptors.first()
//檢查父子函式的一致性
if (override.hasComposableAnnotation() != hasComposableAnnotation) {
context.trace.report(
ComposeErrors.CONFLICTING_OVERLOADS.on(
declaration,
listOf(descriptor, override)
)
)
}
//...
}
checkType
kotlin
private fun checkType(
type: KotlinType,
element: PsiElement,
context: DeclarationCheckerContext
) {
if (type.hasComposableAnnotation() && type.isSuspendFunctionType) {
context.trace.report(
COMPOSABLE_SUSPEND_FUN.on(element)
)
}
}
上面 checkType 方法可以對函式型別的引數進行檢查,不能同時是 suspend 和 Composable
但是令人不解的是,當函式作為變數型別時,沒有呼叫 checkType 進行檢查,個人感覺應該是 Compiler 的 bug,期待後續修正。
checkProperty
@Composable 可以修飾屬性的 get() 方法,但是此時不允許次屬性有幕後欄位
kt
val initializer = declaration.initializer
val name = declaration.nameIdentifier
//property 如果有初始化值,意味著有預設幕後欄位,其 get 不能是 Composable 函式
if (initializer != null && name != null) {
context.trace.report(COMPOSABLE_PROPERTY_BACKING_FIELD.on(name))
}
//property 如果是 var 的,意味著有幕後欄位,get 不能是 Composable 函式
if (descriptor.isVar && name != null) {
context.trace.report(COMPOSABLE_VAR.on(name))
}
上述檢查邏輯的效果如下:
ComposeDiagnosticSuppressor
DiagnosticSuppressor
與其他 Checker 不同,它不是發現錯誤,而是遮蔽一些不必要的檢查。有些 Kotlin Compiler 預設的診斷檢查對於 Compose 的場景並不適用。
ComposeDiagnosticSuppressor
繼承自 DiagnosticSuppressor,重寫 isSuppressed
方法,引數 diagnostic 獲得當前發現的錯誤,返回 true 則可以遮蔽這個錯誤
NON_SOURCE_ANNOTATION_ON_INLINED_LAMBDA_EXPRESSION
```kt open class ComposeDiagnosticSuppressor : DiagnosticSuppressor {
//...
override fun isSuppressed(diagnostic: Diagnostic, bindingContext: BindingContext?): Boolean {
if (diagnostic.factory == Errors.NON_SOURCE_ANNOTATION_ON_INLINED_LAMBDA_EXPRESSION) {
for (
entry in (
diagnostic.psiElement.parent as KtAnnotatedExpression
).annotationEntries
) {
if (bindingContext != null) {
val annotation = bindingContext.get(BindingContext.ANNOTATION, entry)
if (annotation != null && annotation.isComposableAnnotation) return true
}
else if (entry.shortName?.identifier == "Composable") return true
}
}
//...
return false
}
} ```
上面邏輯中遮蔽了 NON_SOURCE_ANNOTATION_ON_INLINED_LAMBDA_EXPRESSION
,當遇到 @Composable 時不報錯。通常什麼情況下報這種錯呢?
上面的例子中 foo 是一個接受 lambda 引數的 inline 函式。我們在 foo 呼叫處為 lambda 新增 @MyAnnotation ,此時編譯報錯
text
The lambda expression here is an inlined argument so this annotation cannot be stored anywhere
這就是所謂的 NON_SOURCE_ANNOTATION_ON_INLINED_LAMBDA_EXPRESSION。這並非是說註解新增錯了地方,AnnotationTarget.FUNCTION
可以修飾 lambda ,無論是宣告處還是呼叫處。錯誤的原因是因為 @MyAnnotation 沒有新增 @Retention(AnnotationRetention.SOURCE)
,這意味著註解需要在編譯後被保留,而 inline lambda 在編譯後就不存在了,為了避免註解失效,編譯期報錯。
可以通過將註解宣告為 AnnotationRetention.SOURCE
來解決此問題,當然,也可以通過新增 @Suppress 註解來遮蔽報錯:
kt
@Suppress("NON_SOURCE_ANNOTATION_ON_INLINED_LAMBDA_EXPRESSION")
那麼 Compose Compiler 為什麼不需要這個檢查呢?
如上,ComposeDiagnosticSuppressor 的作用下, @Composable 並非 AnnotationRetention.SOURCE,但是同樣修飾 inline lambda 沒有報錯。因為 inline 函式的呼叫方是 Composale,所以即使 inline lambda 的 @Composable 在編譯後丟失也不影響整個內部的 codegen。
但是個人感覺對 inline lambda 診斷遮蔽意義不大,這本身就不是常見 case,而且如果 inline 函式的呼叫方不是 @Composable 函式時,編譯期沒有提醒可能會造成執行時異常。
NAMED_ARGUMENTS_NOT_ALLOWED
另一個遮蔽的錯誤是 NAMED_ARGUMENTS_NOT_ALLOWED
kt
if (diagnostic.factory == Errors.NAMED_ARGUMENTS_NOT_ALLOWED) {
if (bindingContext != null) {
val call = (diagnostic.psiElement.parent.parent.parent.parent as KtCallExpression)
.getCall(bindingContext).getResolvedCall(bindingContext)
if (call != null) {
return call.isComposableInvocation()
}
}
}
return false
Kotlin 中允許使用“命名引數”, 即在呼叫函式時可以基於 name 指定引數,不必拘泥於原本引數在函式簽名中的位置。但這有個例外,即當函式作為型別使用時,函式的引數不能通過 name 指定,否則會報錯:
text
Named arguments are not allowed for function types.
這就是所謂的 NAMED_ARGUMENTS_NOT_ALLOWED
。
如果 foo 的函式型別新增是 @Composable ,則不再報這個錯誤。
Composable 函式編譯期原本就需要修改函式簽名,可以處理對 named arguments 的呼叫,而且 Compose 的 DSL 語法中類似的基於 name 的引數指定出現的頻率更高,因此遮蔽此類錯誤有利於提升開發效率。
本文帶大家簡單瞭解了 Compose Compiler 在前端主要做了哪些事情,更多前端的邏輯大家有興趣可以自行去閱讀。下一篇文章起我們進入到編譯器後端的領域,看一下 Compose 程式碼是如何生成的。
- Android Studio Electric Eel 起支援手機投屏
- Compose 為什麼可以跨平臺?
- 一看就懂!圖解 Kotlin SharedFlow 快取系統
- 深入淺出 Compose Compiler(2) 編譯器前端檢查
- 深入淺出 Compose Compiler(1) Kotlin Compiler & KCP
- Jetpack MVVM七宗罪之三:在 onViewCreated 中載入資料
- 為什麼說 Compose 的宣告式程式碼最簡潔 ?Compose/React/Flutter/SwiftUI 語法對比
- Compose 型別穩定性註解:@Stable & @Immutable
- Fragment 這些 API 已廢棄,你還在使用嗎?
- 告別KAPT!使用 KSP 為 Kotlin 編譯提速
- 探索 Jetpack Compose 核心:深入 SlotTable 系統
- 盤點 Material Design 3 帶來的新變化
- Compose 動畫邊學邊做 - 夏日彩虹
- Google I/O :Android Jetpack 最新變化(二) Performance
- Google I/O :Android Jetpack 最新變化(一) Architecture
- Google I/O :Android Jetpack 最新變化(四)Compose
- Google I/O :Android Jetpack 最新變化(三)UI
- 一文看懂 Jetpack Compose 快照系統
- 聊聊 Kotlin 代理的“缺陷”與應對
- AAB 扶正!APK 再見!