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。这样修改即可:
kotlinjetbrainsKotlinJvm = "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
}