Kotlin 中注解的主要实现方式

Android 开发人员在构建复杂的应用程序时, 可能会在整个应用程序中散布注解, 例如 Dagger 中的 @ProvidesAndroidX 中的 @ColorRes, 但他们并不完全了解这些注解是如何工作的; 他们几乎可以感觉到注解就像魔法一样. 本文将通过探索处理注解的三种主要机制, 揭开其中一些神奇之处: 注解处理 , 反射lint.

什么是 Kotlin 注解?

注解是一种为代码附加元数据的手段. 要声明注解, 请在类的前面加上注解修饰符:

kotlin 复制代码
annotation class CustomAnnotation

我们可以用元注解来提供更多细节:

less 复制代码
@Target(AnnotationTarget.FUNCTION, AnnotationTarget.TYPE_PARAMETER)
@Retention(AnnotationRetention.SOURCE)
annotation class CustomAnnotation

@Target指定了可以使用注解的元素种类(如类, 函数, 参数), 而@Retention则指定了注解是否存储在二进制输出中, 以及在运行时是否可见以供反射. 在其默认值 RUNTIME中, 两者均为 true.

注解处理和反射属于元编程的范畴, 元编程是一种将其他程序(如 Kotlin 源代码)作为数据使用的技术. 这两种机制都利用注解来减少模板代码并自动执行常见任务. 例如, 大多数依赖注入(如 Dagger)和序列化(如 Moshi)库都使用其中一种或两者兼用. 注解处理和反射可以实现类似的行为--Moshi 在生成 JSON 适配器时支持这两种机制. Lint是一个不同的用例, 它使用注解来检查源文件中的问题. 但 Lint 算不算元编程尚不清楚.

所有这三种机制都提供了强大而灵活的 API, 因此不同的注解可以实现截然不同的效果, 这取决于代码中如何处理它们. 开发人员很少需要自己编写这些代码, 因为我们更经常使用已经处理过注解的库中的注解, 而不是创建自己的注解.

注解处理

注解处理器是编译器插件, 可在编译时根据注解生成代码. 当第三方库要求使用 annotationProcessor, kaptksp 代替 implementation 作为其 build.gradle 依赖关系配置时, 你就知道它包含了注解处理器. 依赖注解处理的流行库包括 Dagger(@Provides, @Inject), Moshi(@Json)和 Room (@Entity, @Dao).

注解处理器必须向编译器注册, 才能在编译过程中运行. 最常见的注册方式是通过 Google 的 AutoService 库, 只需用 @AutoService(Processor.class)注解处理器即可.

有三种主要的 API 可用于创建注解处理器: 注解处理器工具(APT), Kotlin 注解处理器工具(kapt)和 Kotlin 符号处理(KSP). 使用 APT 和 kapt 创建的处理器会扩展相同的基础AbstractProcessor类, 而 KSP 则有一个单独的SymbolProcessor类.

注解处理有时候需要进行多轮. 在每一轮中, 编译器都会在生成的源文件中搜索注解, 并调用相应的处理器. 如果处理器生成了任何新文件, 则以生成的文件为输入开始新一轮处理. 这个过程一直持续到没有新文件生成为止.

注解处理器工具

APT 是 Kotlin Android之前世界中唯一的 API. 它由 Java 编译器 (javac) 使用, 并与 annotationProcessor 依赖关系配置相对应. 由于它不支持 Kotlin 文件, 如今在 Android 项目中很少见到它.

APT 注解处理器适用于.java源代码文件. 要创建 APT 注解处理器, 我们需要创建一个扩展了 AbstractProcessor 的类. 需要实现的两个主要函数是 getSupportedAnnotationTypes()(返回可以处理的注解)和 process()(每轮处理都会调用). 在这里, 我们必须将源代码视为结构化文本, 而不是可执行的东西--类似于 JSON 文件. 源代码以Element树的形式表示, Element代表程序元素, 包括包, 类或函数.

AbstractProcessor.process() 的第二个参数是 roundEnv , 这是一个 RoundEnvironment 对象, 包含当前和之前处理轮的信息. 它提供了对 Element 树的访问, 以及检查与 Element 关联的注解的多种方法, 例如 getElementsAnnotatedWith().

我们可以在process()中根据注解的Element生成代码. 处理器的 ProcessingEnvironment 中的 Filer 可以让我们创建新文件, 然后像写入其他文件一样写入这些文件. 生成的文件将出现在项目的 <module>/build/generated/source/ 目录下. Dagger 是这样生成其 Something_FactorySomething_MembersInjector 类的, 而 Moshi 则是这样生成其 SomethingAdapter 类的. JavaPoet是一个用于编写 Java 源代码的流行库.

我将不再赘述自定义 AbstractProcessor 的实现, 因为已经有很多相关资源, 我将在本文末尾链接到这些资源. 在下一篇文章中, 我将探讨 Moshi 的 JsonClassCodegenProcessor 是如何实现的.

Kotlin 注解处理器工具

Kotlin 一经流行, Kapt 就成了最常用的注解处理器 API. 它是构建在 APT 之上的编译器插件, 同时支持 Kotlin 和 Java 源代码. 它与 kapt 依赖关系配置相对应. 它使用与 APT 相同的 AbstractProcessor, 因此上一节关于创建 APT 注解处理器的信息也适用于 kapt. Kapt 可以运行任何AbstractProcessor, 无论它是以 Kotlin 或 Java 支持为目的编写的. KotlinPoet是一个流行的库, 用于在处理过程中生成 Kotlin 源代码.

Kapt 的主要缺点是编译速度慢. 它像 APT 一样运行在 javac 上, 通过从 Kotlin 文件生成 Java 存根来处理 Kotlin, 然后处理器可以读取这些存根. 生成存根是一项昂贵的操作, 对编译速度有很大影响.

Kotlin 符号处理

KSP 是 2021 年推出的 Kapt 的 Kotlin 优先替代方案. KSP 与 Kotlin 编译器(kotlinc)直接集成, 可直接分析 Kotlin 代码, 无需生成 Java 存根, 速度比 kapt 快达 2 倍. 它还能更好地理解 Kotlin 的语言结构.

如果一个模块还有 kapt 处理器, 它仍会在编译过程中生成 Java 存根. 这意味着, 只有从模块中移除所有 kapt 的使用, 我们才能获得 KSP 的性能提升.

要创建 KSP 注解处理器, 我们需要实现 SymbolProcessor. KSP 也将源代码表示为语法树, 并提供了与 Kapt 类似的 API. KSP 中的 KsDeclarationKsDeclarationContainer 与 Kapt 中的 Element 相对应. 与 Kapt 的RoundEnvironment一样, ResolverSymbolProcessor.process()中的参数, 用于访问语法树. Resolver.getSymbolsWithAnnotation()可以让我们检查带有我们感兴趣的自定义注解的符号. 最后, 与 Filer 一样, SymbolProcessorEnvironmentCodeGenerator 可以让我们在处理过程中生成代码.

反射

反射被定义为一种语言在运行时检查其类, 接口, 字段和方法的能力, 而无需在编译时知道它们的名称. 它允许程序动态地实例化新对象和调用方法, 即在运行时修改它们的结构和行为. 使用反射修改程序行为的流行库包括 Moshi (@Json) 和 Retrofit (@GET, @POST).

标准的Java 反射库 可在 Kotlin 中使用. Kotlin 也有自己的反射 API, 可通过 kotlin-reflect 包获得, 它提供了一些额外的功能, 包括访问属性和可归零类型.

下面是一个在运行时通过反射调用方法的示例:

kotlin 复制代码
class HelloPrinter {
  fun printHello() {
    println("Hello, world!!!")
  }
}

val printer = HelloPrinter()
val methods = printer::class.java.methods
val helloFunction = methods.find { it.name == "printHello" }
helloFunction?.invoke(printer)

反射并不总是涉及注解, 但它为使用注解提供了强大的 API, 而且注解可以使其使用更安全, 更强大.

例如, 我们可以更新前面的代码, 调用所有注解为 @Greeter 的方法, 而无需用硬编码字符串指定它们的名称:

kotlin 复制代码
@Target(AnnotationTarget.FUNCTION)
annotation class Greeter

class HelloPrinter {
  @Greeter
  fun printHello() {
    println("Hello, world!!!")
  }
}

val printer = HelloPrinter()
val methods = printer::class.java.methods
val helloFunctions = methods.filter {
  it.isAnnotationPresent(Greeter::class.java)
}
helloFunctions.forEach {
  it.invoke(printer)
}

Kotlin 的反射 API 中与注解相关的其他函数包括 KAnnotatedElement.findAnnotations()KAnnotatedElement.hasAnnotation(). 要使注解与反射配合使用, 其 @Retention(保留)策略必须是 RUNTIME(默认保留).

在决定通过反射还是注解处理来处理注解时, 主要有以下权衡:

  1. 反射意味着运行时间较慢, 而注解处理意味着编译时间较慢
  2. 反射在运行时失效, 注解处理在编译时失效

大多数库更喜欢注解处理, 因为它的编译时间较慢, 不会影响最终用户的体验, 而且编译时出错的速度也很快, 而反射的运行时出错更容易被忽略, 直到最终用户发现. 出于这些原因, Dagger 在从 v1 迁移到 v2 时完全摆脱了反射.

流行的 HTTP 客户端库 Retrofit 仍然完全依赖反射. 它使用 Proxy 生成注解接口的实例. 反射在这里是合理的, 因为网络请求的延迟远远超过反射带来的延迟, 而且运行时故障相对容易识别; 我们可以通过触发相应的 API 调用来测试新的 Retrofit 代码.

Lint

Lint 是一款代码扫描工具, 用于检查 Android 项目源文件中的潜在错误, 并对安全, 性能, 可访问性, 国际化等方面进行改进. 为简洁起见, 我只讨论第一方 Lint 工具, 而不会涉及任何第三方工具, 如Detekt.

Lint 已深度集成到 Android Studio 中, 并在集成开发环境中以警告或错误的形式突出显示问题.

包括上述 "不必要的安全调用"在内的许多 Lint 检查都不涉及注解. 不过, 注解可以帮助 lint 检测到更细微的代码问题. 由于 Java 不像 Kotlin 那样有内置的空检查, 所以 @Nonnull@Nullable 可以让 lint 为 Kotlin 提供相同的空安全性警告. 另一个例子是 @ColorRes, 它让 lint 知道 Int 参数应该是一个颜色资源引用(例如 android.R.color.black).

与注解处理和反射不同, Lint 只执行静态代码分析, 不能影响程序行为. 与其他两种注解通常来自第三方库的情况不同, 大多数常用的 Lint 相关注解都来自 androidx.annotations 包, 并由 Android Studio 的内置检查机制处理.

当我们想添加自定义的 Lint 检查(无论是我们自己的还是来自第三方库的)时, 就必须使用 lintCheckslintPublish 依赖关系配置. lintChecks使 lint 规则仅适用于该模块, 而 lintPublish 则使其适用于所有上游模块.

要添加新的 lint 检查, 我们必须创建一个 Detector 的子类. 检测器可以在不同的文件类型上运行, 这取决于它实现了哪种 FileScanner. 例如, SourceCodeScanner 可以分析 Kotlin 或 Java 源文件, GradleScanner 可以分析 Gradle 文件.

SourceCodeScanner是处理注解的相关扫描器. 它提供两个由 Jetbrains 开发的抽象语法树(AST)API, 用于分析源代码. 通用抽象语法树(UAST) API 以相同的方式表示多种语言, 这使得编写单一分析器成为可能, 该分析器可 "通用 "于 UAST 支持的所有语言, 包括 Kotlin 和 Java.

在 UAST 之前, 还有程序结构接口 (PSI). PSI 以不同的方式表示 Kotlin 和 Java, 有时仍需要用于特定语言的细微差别. UAST 建立在 PSI 的基础之上, 因此其 API 有时会泄露 PSI 元素. 导航 AST 与导航注解处理中的 Element 树类似.

Google 的 自定义 lint 规则指南 有一个 帮助页面 介绍了如何添加可访问注解元素的 lint 检查. 如果我们想直接访问自定义注解的使用情况, 而不是只访问用其注解的元素, 我们可以覆盖 SourceCodeScannergetApplicatbleUastTypes() 以返回 UAnnotation::class.java, 然后覆盖 createUastHandler() 以返回自定义的 UElementHandler. 任何与自定义注解相关的问题检测都可以在 UElementHandlervisitAnnotation() 中进行.

神秘的自定义注解

有时, 你会在代码库中遇到一个自定义注解, 但它并没有在任何自定义注解处理器, 反射代码或内核检查中被引用, 那么它在做什么呢?

它很可能是由第三方库中的元注解注解的; 有几个流行的第三方库提供的功能都依赖于元注解. 例如, Dagger 有一个 @Qualifier 元注解, 用于仅凭类型不足以识别依赖关系的情况.

@Qualifier 允许我们创建类似这样的自定义注解:

less 复制代码
@Qualifier
@Target(AnnotationTarget.FIELD, AnnotationTarget.VALUE_PARAMETER, AnnotationTarget.FUNCTION)
annotation class Authorized

@Qualifier
@Target(AnnotationTarget.FIELD, AnnotationTarget.VALUE_PARAMETER, AnnotationTarget.FUNCTION)
annotation class Unauthorized

我们可以使用它们根据用户是否登录来注入一个类的不同实例.

less 复制代码
@Provides
@Authorized
OkHttpClient.Builder provideAuthorizedOkHttpClientBuilder(...)

@Provides
@Unauthorized
OkHttpClient.Builder provideUnauthorizedOkHttpClientBuilder(...)

你无需编写任何代码来处理这些自定义注解; Dagger 的注解处理器将搜索所有 @Qualifier 自定义注解并为你处理它们. Moshi 也有类似的 @JsonQualifier 元注解, 用于指定某些字段的类型编码方式, 而无需在所有地方更改其编码.

总结一下

今天主要介绍了 3 种 Kotlin 注解工具的基本原理和使用场景. Kotlin 注解属于元编程的范畴, 相对而言, 属于比较高级的主题. 后面我还有一篇文件, 我会借助具体的三方库分析介绍学习一下 Kotlin 注解的更多细节.

好吧, 今天的内容就分享到这里啦!

一家之言, 欢迎拍砖!

Happy Coding! Stay GOLDEN!

相关推荐
长亭外的少年7 小时前
Kotlin 编译失败问题及解决方案:从守护进程到 Gradle 配置
android·开发语言·kotlin
JIAY_WX7 小时前
kotlin
开发语言·kotlin
建群新人小猿10 小时前
会员等级经验问题
android·开发语言·前端·javascript·php
1024小神11 小时前
tauri2.0版本开发苹果ios和安卓android应用,环境搭建和最后编译为apk
android·ios·tauri
兰琛11 小时前
20241121 android中树结构列表(使用recyclerView实现)
android·gitee
Y多了个想法12 小时前
RK3568 android11 适配敦泰触摸屏 FocalTech-ft5526
android·rk3568·触摸屏·tp·敦泰·focaltech·ft5526
NotesChapter13 小时前
Android吸顶效果,并有着ViewPager左右切换
android
_祝你今天愉快14 小时前
分析android :The binary version of its metadata is 1.8.0, expected version is 1.5.
android
暮志未晚Webgl14 小时前
109. UE5 GAS RPG 实现检查点的存档功能
android·java·ue5
麦田里的守望者江15 小时前
KMP 中的 expect 和 actual 声明
android·ios·kotlin