深入淺出 Compose Compiler(2) 編譯器前端檢查

語言: CN / TW / HK

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 代碼是如何生成的。