深入淺出 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 程式碼是如何生成的。