美文网首页
深入浅出 Compose Compiler(2) 编译器前端检查

深入浅出 Compose Compiler(2) 编译器前端检查

作者: _Jun | 来源:发表于2022-11-02 15:15 被阅读0次

    作者:fundroid
    链接:https://juejin.cn/post/7155668796655534093

    前一篇文章最后提到了 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 方法的相关实现:

    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 函数:

    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 源码中也已经揭示了相关解决方案:

    //获取 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 源码的含义

    MAP.put(
        ComposeErrors.CAPTURED_COMPOSABLE_INVOCATION,
        "Composable calls are not allowed inside the {0} parameter of {1}",
        Renderers.NAME,
        Renderers.COMPACT
    )
    

    @ReadOnlyComposable

    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 函数:

    object MaterialTheme {
        val colors: Colors
            @Composable
            @ReadOnlyComposable
            get() = LocalColors.current
        //...
    }
    

    之前大家可能很少留意到 @DisallowComposableCalls,@ReadOnlyComposable 等注解的存在,而现在通过阅读 Compiler 源码,加深了我们对 Compose 的掌握程度。

    ComposableCallChecker 里还很多检查逻辑,相信有了前面的介绍,剩余的源码大家应该又能去自行阅读了。

    ComposableDeclarationChecker

    ComposableDeclarationChecker 主要检查 @Composable 出现的位置是否合法。

    @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 上下文;

     //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 难以协调:

    //suspend 不能添加 @Composable
    if (descriptor.isSuspend && hasComposableAnnotation) {
        context.trace.report(
            COMPOSABLE_SUSPEND_FUN.on(declaration.nameIdentifier ?: declaration)
        )
    }
    

    当函数有重写时,还需要检查与被重写函数是否一致,即 Composable 函数的重写实现也必须是 Composable 函数,反之普通函数的重写函数必须是普通函数。不一致时会报下面的错误:

    相关 check 代码如下:

    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

    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() 方法,但是此时不允许次属性有幕后字段

    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

    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 ,此时编译报错

    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 注解来屏蔽报错:

    @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

    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 指定,否则会报错:

    Named arguments are not allowed for function types.
    

    这就是所谓的 NAMED_ARGUMENTS_NOT_ALLOWED

    如果 foo 的函数类型添加是 @Composable ,则不再报这个错误。

    Composable 函数编译期原本就需要修改函数签名,可以处理对 named arguments 的调用,而且 Compose 的 DSL 语法中类似的基于 name 的参数指定出现的频率更高,因此屏蔽此类错误有利于提升开发效率。

    本文带大家简单了解了 Compose Compiler 在前端主要做了哪些事情,更多前端的逻辑大家有兴趣可以自行去阅读。下一篇文章起我们进入到编译器后端的领域,看一下 Compose 代码是如何生成的。

    相关文章

      网友评论

          本文标题:深入浅出 Compose Compiler(2) 编译器前端检查

          本文链接:https://www.haomeiwen.com/subject/leujtdtx.html