从原理到实践:使用 Kotlin KSP 仿写 ButterKnifeLite

ButterKnife 黄油刀(Github 仓库)主要用于消除 findViewById 的调用和 setOnClick 的匿名类写法。它还能绑定资源、消除资源的查询。

虽然它已经过时了,有了更好的解决方法------ViewBinding,但我们还是可以学习它的思想。

回顾 ButterKnife 原理

在现代项目中临时适配 ButterKnife 太麻烦,所以这里直接展示 MainActivity 的代码:

java 复制代码
@SuppressLint("NonConstantResourceId")
public class MainActivity extends AppCompatActivity {

    // 绑定控件,不用 findViewById
    @BindView(R.id.tv_msg)
    TextView tvMsg;

    @BindView(R.id.btn_click)
    Button btnClick;

    Unbinder mUnbinder;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        // 绑定当前 Activity
        mUnbinder = ButterKnife.bind(this);
        tvMsg.setText("黄油刀绑定成功");
    }

    // 点击事件注解,不用 setOnClickListener
    @OnClick(R.id.btn_click)
    void onBtnClick() {
        tvMsg.setText("按钮被点击了");
    }

    @Override
    protected void onDestroy() {
        super.onDestroy();
        mUnbinder.unbind();
    }
}

它的实现原理是在编译时使用 apt 注解处理器生成辅助代码,运行时完成 findViewById、setOnClickListener 的调用。因为没有运行时通过反射解析注解,所以几乎没有性能损耗。

以上面的代码为例,生成的辅助类为 MainActivity_ViewBinding,伪代码如下:

java 复制代码
public class MainActivity_ViewBinding implements Unbinder {
  private MainActivity target;

  public MainActivity_ViewBinding(MainActivity target) {
    this.target = target;
    View view = target.getWindow().getDecorView().findViewById(android.R.id.content);
    // 控件绑定,原生 findViewById 强转赋值
    target.tvMsg = Utils.findRequiredViewAsType(view, R.id.tv_msg, "field 'tvMsg'", TextView.class);
    target.btnClick = Utils.findRequiredViewAsType(view, R.id.btn_click, "field 'btnClick'", Button.class);

    // 设置点击监听器,直接回调 onBtnClick
    View btnClickView = Utils.findRequiredView(view, R.id.btn_click, "method 'onBtnClick'");
    btnClickView.setOnClickListener(v -> target.onBtnClick());
  }

  @Override
  public void unbind() {
    MainActivity target = this.target;
    if (target == null) return;

    target.tvMsg = null;
    target.btnClick = null;
    this.target = null;
  }
}

我们在 MainActivity 调用的 ButterKnife.bind(this);,实际上会去创建 MainActivity_ViewBinding 对象,并传入当前 Activity。以控件绑定为例,构造过程中会通过 findViewById 给 Activity 中的成员变量赋值。

仿写 ButterKnifeLite

简单了解后,我们就来仿写一个 ButterKnife,名字叫做 ButterKnifeLite。

搭建 KSP 项目结构

首先创建一个 View 项目,然后创建对应的 Kotlin 模块:

  • annotations 模块,包名是 com.butterknifelite.annotations。
  • compiler 模块,包名是 com.butterknifelite.compiler。

在根 build.gradle.kts 中声明 KSP 插件:

kotlin 复制代码
// Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins {
    alias(libs.plugins.android.application) apply false
    alias(libs.plugins.jetbrains.kotlin.jvm) apply false

    // KSP 
    id("com.google.devtools.ksp") version "2.3.13" apply false
}

定义注解

在 annotations 模块中创建注解:

BindView.kt

kotlin 复制代码
package com.butterknifelite.annotations

@Target(AnnotationTarget.PROPERTY)
@Retention(AnnotationRetention.SOURCE) // 只在编译时使用
annotation class BindView(val value: String)

@Target(AnnotationTarget.FUNCTION)
@Retention(AnnotationRetention.SOURCE)
annotation class OnClick(val value: String)

编写处理器骨架

有了可被识别到的标签后,我们在 compiler 模块中处理有这些注解的情况。

引入注解(Annotation)模块,引入注解处理器依赖和 KotlinPoet:

kotlin 复制代码
dependencies {
    implementation(project(":annotations"))
    // 注解处理器 API
    implementation("com.google.devtools.ksp:symbol-processing-api:2.3.13")

    // 方便 Xxx_ViewBinding.kt 代码文件的生成
    implementation("com.squareup:kotlinpoet:2.4.0")
    implementation("com.squareup:kotlinpoet-ksp:2.4.0")
}

当然你可以直接通过 FileWriter 去生成整个代码文件,不过这会比较麻烦。

然后创建处理器的骨架,在 process 方法中打印一些东西来进行验证:

kotlin 复制代码
class ButterKnifeLiteProcessor(
    private val environment: SymbolProcessorEnvironment
) : SymbolProcessor {
    override fun process(resolver: Resolver): List<KSAnnotated> {
        environment.logger.warn("The ButterKnifeLite processor is running.")
        return emptyList()
    }
}

// 注册 SymbolProcessorProvider,提供 ButterKnifeLiteProcessor 实例
class ButterKnifeLiteProvider : SymbolProcessorProvider {
    override fun create(environment: SymbolProcessorEnvironment): SymbolProcessor =
        ButterKnifeLiteProcessor(environment)
}

其中 ButterKnifeLiteProcessor 的 process() 会由 KSP 在编译期多次调用,ButterKnifeLiteProvider 是一个工厂,由 KSP 调用、管理实例。

ButterKnifeLiteProvider 创建后,还需要注册。创建文件:

bash 复制代码
compiler/src/main/resources/META-INF/services/com.google.devtools.ksp.processing.SymbolProcessorProvider

内容填写 ButterKnifeLiteProvider 的全类名:com.butterknifelite.compiler.ButterKnifeLiteProvider。

引入并测试 KSP 插件

最后在 app 模块中,引入注解依赖,以及引入 compiler 模块:

kotlin 复制代码
plugins {
    alias(libs.plugins.android.application)
    id("com.google.devtools.ksp")
}

dependencies {
    // ...
    implementation(project(":annotations"))
    ksp(project(":compiler"))
}

现在执行 .\gradlew.bat :app:assembleDebug,应该能够看到:

arduino 复制代码
> Task :app:kspDebugKotlin
w: [ksp] The ButterKnifeLite processor is running.

注意:Kotlin 编译器的版本要和依赖中的版本保持一致,这里是 2.4.0。这样修改即可:

kotlin 复制代码
jetbrainsKotlinJvm = "2.4.20"

jetbrains-kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "jetbrainsKotlinJvm" }

完善代码生成逻辑

这个壳跑通后,接下来要做的是往 process 里加实现。

首先在 process 打印基本的信息:并在 finish() 中测试写入结果:

kotlin 复制代码
package com.butterknifelite.compiler

import com.butterknifelite.annotations.BindView
import com.google.devtools.ksp.processing.Dependencies
import com.google.devtools.ksp.processing.Resolver
import com.google.devtools.ksp.processing.SymbolProcessor
import com.google.devtools.ksp.processing.SymbolProcessorEnvironment
import com.google.devtools.ksp.symbol.KSAnnotated
import com.google.devtools.ksp.symbol.KSDeclaration
import com.squareup.kotlinpoet.FileSpec
import com.squareup.kotlinpoet.FunSpec
import com.squareup.kotlinpoet.TypeSpec
import com.squareup.kotlinpoet.ksp.writeTo

class ButterKnifeLiteProcessor(
    private val environment: SymbolProcessorEnvironment
) : SymbolProcessor {

    override fun process(resolver: Resolver): List<KSAnnotated> {
        // 获取标记 @BindView 注解的所有地方
        val bindViewAnnotationName = BindView::class.java.name
        val symbols = resolver.getSymbolsWithAnnotation(bindViewAnnotationName).toList()
        environment.logger.warn("Get ${symbols.size} @BindView symbols")

        symbols.forEach { symbol ->
            // 从所有的注解实例中找到 @BindView 注解
            val annotation = symbol.annotations.firstOrNull {
                it.annotationType.resolve()
                    .declaration.qualifiedName?.asString() == bindViewAnnotationName
            }
            // 打印名称,以及注解的值
            val declaration = symbol as? KSDeclaration
            val idName = annotation?.arguments?.firstOrNull()?.value as? String
            environment.logger.warn("  ${declaration?.simpleName?.asString()} -> R.id.$idName")
        }
        return emptyList()
    }

    override fun finish() {
        // 1. 创建代码文件
        val file = FileSpec.builder("com.example", "HelloGenerated")
            .addType(
                // 2. 增加类
                TypeSpec.classBuilder("HelloGenerated")
                    // 3. 增加函数
                    .addFunction(
                        FunSpec.builder("hello")
                            .addCode("println(\"hello world from generated code\")\n")
                            .build()
                    )
                    .build()
            )
            .build()

        file.writeTo(environment.codeGenerator, Dependencies(aggregating = false))
    }
}

构建结果:

ruby 复制代码
> Task :app:kspDebugKotlin
w: [ksp] Get 1 @BindView symbols
w: [ksp]   tvHello -> R.id.tv_hello

代码生成的结果(app/build/generated/ksp/debug/kotlin/com/example/HelloGenerated.kt):

kotlin 复制代码
package com.example

public class HelloGenerated {
  public fun hello() {
    println("hello world from generated code")
  }
}

收集注解信息与代码生成

现在的重点是完善 finish 中的生成代码过程。

我们要在 process 收集信息,在 finish 中根据这些信息来生成。首先处理 @BindView:

kotlin 复制代码
/** View字段 */
private class ViewField(
    /** 字段名 */
    val property: String,
    /** 资源ID */
    val idName: String
)

class ButterKnifeLiteProcessor(
    private val environment: SymbolProcessorEnvironment
) : SymbolProcessor {
    /** 所有字段按照所属类进行分组 */
    private val fieldsByClass = LinkedHashMap<String, MutableList<ViewField>>()

    override fun process(resolver: Resolver): List<KSAnnotated> {
        val bindViewAnnotationName = BindView::class.java.name
        val symbols = resolver.getSymbolsWithAnnotation(bindViewAnnotationName).toList()

        /** 获取注解的属性值 */
        fun KSDeclaration.idArgument(annotationName: String): String? {
            val annotation = this.annotations.firstOrNull {
                it.annotationType.resolve()
                    .declaration.qualifiedName?.asString() == annotationName
            }
            return annotation?.arguments?.firstOrNull()?.value as? String
        }

        symbols.forEach { symbol ->
            // 必须是属性
            val property = symbol as? KSPropertyDeclaration
            if (property == null) {
                environment.logger.error("@BindView can only annotate attributes", symbol)
                return@forEach
            }

            // 且是类成员
            val owner = property.parentDeclaration as? KSClassDeclaration
            if (owner == null) {
                environment.logger.error("@BindView must be written on a class member.", property)
                return@forEach
            }

            val idName = property.idArgument(bindViewAnnotationName) ?: return@forEach
            val key = owner.qualifiedName!!.asString()
            fieldsByClass.getOrPut(key) { mutableListOf() }
                .add(
                    // 属性名 -> 资源ID
                    ViewField(property.simpleName.asString(), idName)
                )
        }
        return emptyList()
    }

    override fun finish() {
        // 为每一个类都生成对应的Binding类
        fieldsByClass.forEach { (qualifiedName, fields) ->
            // 获取包名和类名
            val packageName = qualifiedName.substringBeforeLast('.')
            val targetName = qualifiedName.substringAfterLast('.')

            // 要生成的类名
            val generatedName = "${targetName}_ViewBinding"
            // R.id
            val rId = ClassName(packageName, "R", "id")

            val body = CodeBlock.builder()
            fields.forEach { field ->
                // 为目标类的字段赋值
                body.addStatement(
                    "target.%N = target.findViewById(%T.%N)",
                    field.property, rId, field.idName,
                )
            }

            val type = TypeSpec.classBuilder(generatedName)
                .primaryConstructor(
                    // 主构造函数接收目标类对象
                    FunSpec.constructorBuilder()
                        .addParameter("target", ClassName(packageName, targetName)).build()
                )
                .addInitializerBlock(body.build())
                .build()

            FileSpec.builder(packageName, generatedName)
                .addType(type)
                .build()
                .writeTo(environment.codeGenerator, Dependencies(aggregating = false))
        }
    }
}

生成结果:

kotlin 复制代码
package com.example.ksp_bind_demo

public class MainActivity_ViewBinding(
  target: MainActivity,
) {
  init {
    target.tvHello = target.findViewById(R.id.tv_hello)
  }
}

在 app 模块调用测试一下,首先增加外部触发的入口:

kotlin 复制代码
// 在同一个文件中,生成外部调用的扩展函数
// 例如:fun MainActivity.bindViews() = MainActivity_ViewBinding(this)
val bindFun = FunSpec.builder("bindViews")
    .receiver(ClassName(packageName, targetName))
    .returns(ClassName(packageName, generatedName))
    .addCode("return %T(this)\n", ClassName(packageName, generatedName))
    .build()

FileSpec.builder(packageName, generatedName)
    .addType(type)
    .addFunction(bindFun)
    .build()
    .writeTo(environment.codeGenerator, Dependencies(aggregating = false))    

如果不用扩展函数,可以对外暴露方法,传入目标类:方法内部通过反射,找到目标类对应的 Xxx_ViewBinding 类并实例化。

生成的文件内容:

kotlin 复制代码
package com.example.ksp_bind_demo

public class MainActivity_ViewBinding(
  target: MainActivity,
) {
  init {
    target.tvHello = target.findViewById(R.id.tv_hello)
  }
}

public fun MainActivity.bindViews(): MainActivity_ViewBinding = MainActivity_ViewBinding(this)

如果需要触发,只需在 MainActivity 中调用 bindViews() 即可,比如 onCreate 中。

顺手加上 @OnClick 的处理

现在可以正常跑了,接下来加上 @OnClick 的处理。为了不漏掉某个类,我们需要把涉及到的类名合并、去重。

diff 复制代码
+ private class ClickMethod(
+     val function: String,        // 函数名
+     val idName: String,          // 资源ID名
+     val parameterCount: Int,     // 参数个数
+ )

class ButterKnifeLiteProcessor(
    private val environment: SymbolProcessorEnvironment
) : SymbolProcessor {
    // ...

+    private val clicksByClass = LinkedHashMap<String, MutableList<ClickMethod>>()

    override fun process(resolver: Resolver): List<KSAnnotated> {
        // ... @BindView 的处理

+        val onClickAnnotationName = OnClick::class.java.name
+        resolver.getSymbolsWithAnnotation(onClickAnnotationName).forEach { symbol ->
+            val function = symbol as? KSFunctionDeclaration ?: return@forEach
+            val owner = function.parentDeclaration as? KSClassDeclaration ?: return@forEach
+            val idName = function.idArgument(onClickAnnotationName) ?: return@forEach
+
+            if (function.parameters.size > 1) {
+                environment.logger.error(
+                    "The @OnClick function can accept at most one parameter.",
+                    function
+                )
+                return@forEach
+            }
+
+            val key = owner.qualifiedName?.asString() ?: return@forEach
+            clicksByClass.getOrPut(key) { mutableListOf() }
+                .add(
+                    ClickMethod(
+                        function.simpleName.asString(),
+                        idName,
+                        function.parameters.size,
+                    )
+                )
+        }
        return emptyList()
    }

    override fun finish() {
+       // 合并去重,保证单独只写 @OnClick 的类也能生成
+       val allClasses = (fieldsByClass.keys + clicksByClass.keys).toSet()

-       fieldsByClass.forEach { (qualifiedName, fields) ->
+       allClasses.forEach { qualifiedName ->
+           val fields = fieldsByClass[qualifiedName] ?: emptyList()
+           val clicks = clicksByClass[qualifiedName] ?: emptyList()

            // ... (拆分包名、生成 body 和 findViewById 的逻辑)

+            val view = ClassName("android.view", "View")
+            clicks.forEach { method ->
+                if (method.parameterCount == 0) {
+                    body.addStatement(
+                        "target.findViewById<%T>(%T.%N).setOnClickListener { target.%N() }",
+                        view, rId, method.idName, method.function,
+                    )
+                } else {
+                    body.addStatement(
+                        "target.findViewById<%T>(%T.%N).setOnClickListener { target.%N(it) }",
+                        view, rId, method.idName, method.function,
+                    )
+                }
+            }

           // ... (后续的类构建和写入文件)
        }
    }
}

最终生成的代码:

kotlin 复制代码
package com.example.ksp_bind_demo

import android.view.View

public class MainActivity_ViewBinding(
  target: MainActivity,
) {
  init {
    target.tvHello = target.findViewById(R.id.tv_hello)
    target.findViewById<View>(R.id.btn_get_info).setOnClickListener { target.getInfo() }
  }
}

public fun MainActivity.bindViews(): MainActivity_ViewBinding = MainActivity_ViewBinding(this)

收尾处理与增量编译优化

最后的一些收尾处理。关于 process() 返回值,我们一直返回的是空,这表示符号都已经处理了。

但如果要处理的注解,标在了一个类型还未解析完成的符号上,直接处理,它内部的一些属性会得到空。所以需要把无法处理的符号返回,像这样:

kotlin 复制代码
val deferred = mutableListOf<KSAnnotated>()

symbols.forEach { symbol ->
    if (!symbol.validate(enableNewFeatures = false)) {
        deferred += symbol   // 这一轮处理不了,留到下一轮
        return@forEach
    }
    
    // ...
}

return deferred

在增量构建时,writeTo 的第二个参数可以控制"改动代码是否更新生成物"。

Dependencies(aggregating = true) ==> 聚合模式,表示任何改动都会重新生成,效率较低。 Dependencies(aggregating = false, 源文件数组) ==> 独立模式,只有这些文件的改动,才会重新生成

我们生成的所有 Xxx_ViewBinding.kt 都只和 Xxx 对应,所以只需在收集时获取其对应的文件,并在写入时传入源文件数组,即可提高编译速度:

kotlin 复制代码
// 在收集信息时记录原始文件
val origins = mutableSetOf<KSAnnotated>()
property.containingFile?.let { origins += it }

// 在 writeTo 时传入
file.writeTo(environment.codeGenerator, Dependencies(aggregating = false, sources = origins.toTypedArray()))

最后,还可以加上 R.id 的校验,通过解析 R 类让id拼写错误提前在编译期暴露:

kotlin 复制代码
private val idsCache = mutableMapOf<String, Set<String>>()

private fun idsOf(resolver: Resolver, rPackage: String): Set<String>? {
    idsCache[rPackage]?.let { return it }

    // 获取 R.id 类
    val declaration = resolver.getClassDeclarationByName(
        resolver.getKSNameFromString("$rPackage.R.id")
    ) ?: return null

    val ids = declaration.declarations
        .filterIsInstance<KSPropertyDeclaration>()
        .map { it.simpleName.asString() }
        .toSet()

    if (ids.isEmpty()) return null   // 空结果不缓存,下一轮再试

    idsCache[rPackage] = ids
    return ids
}

fun KSDeclaration.idArgument(    annotationName: String,    resolver: Resolver,    owner: KSClassDeclaration
): String? {
    val annotation = this.annotations.firstOrNull {
        it.annotationType.resolve()
            .declaration.qualifiedName?.asString() == annotationName
    }
    val value = annotation?.arguments?.firstOrNull()?.value as? String
    if (value.isNullOrBlank()) {
        environment.logger.error("You need to pass in the control's ID name.", this)
        return null
    }

    val rPackage = owner.packageName.asString()
    val known = idsOf(resolver, rPackage)
    if (known != null && value !in known) {
        environment.logger.error(
            "The value \"$value\" is missing from $rPackage.R.id. Please check.",
            this
        )
    }

    return value
}
相关推荐
疯华整猫1 小时前
把二十七个 init 从首帧前面搬走:用 Trae 把启动优化钉进 CI
android·性能优化·ai编程
灿飞1 小时前
Kotlin与Java虚拟机十一:类与属性:构造顺序、backing field 与显式 backing field
java·后端·kotlin
传奇开心果2 小时前
【探秘 Jetpack Compose 框架深水区】 第8课:rememberSaveable 的边界在哪里?——Bundle、Saver 与什么值得跨进程存活
android·kotlin
Frankie_stand3 小时前
测试自动化实战指南:从脚本化、自动判据到 CI 集成的完整落地路径
android·ci/cd·自动化
恋猫de小郭4 小时前
Flutter 大小核优化又被回滚,然后又开新提案?
android·前端·flutter
Kapaseker4 小时前
领导问我 Kotlin 的 Nothing 是什么?
android·kotlin
韩曙亮5 小时前
【错误记录】安卓 adb 调试 5037 端口占用排查 ( adb 的三层结构 | 找出本机占用 5037 的进程 )
android·数据库·adb·安卓·错误记录
三少爷的鞋6 小时前
不改变异步本质,只改变代码组织:Telegram 项目的 Kotlin 协程实践
android
好的佩奇15 小时前
蓝牙之广播与扫描
android·flutter·蓝牙