注解:给代码打上“标签”的艺术

说实话,刚学 Kotlin 那会儿,我觉得注解这东西挺玄乎的。不就是个 @Override 嘛,有啥好讲的?直到后来我在一个大型项目里,被一堆重复的模板代码折磨得不行,才真正意识到——注解这玩意儿,用好了简直是生产力神器,用不好嘛……嗯,就是给自己挖坑。

今天咱们就来聊聊 Kotlin 里的注解。我会从定义、使用,到元注解、注解处理器,一步步带你吃透它。

注解到底是什么?

说白了,注解就是一种“元数据”。它不直接影响代码逻辑,但能给编译器、框架、工具提供额外的信息。你可以把它想象成给代码贴的标签——比如“这个函数已废弃”、“这个字段需要序列化”、“这个方法会在测试前执行”。

我个人习惯把注解分成两类:

  • 编译时注解:编译器会读取并处理,比如 @Deprecated@Suppress
  • 运行时注解:通过反射在运行时读取,比如 @Test@Retrofit 里的注解

Kotlin 的注解语法和 Java 很像,但更简洁,也更强大。

定义注解:从零开始造一个

先看个最简单的例子。假设我想定义一个注解,用来标记哪些函数需要做日志记录:

annotation class Loggable

就这么简单?对,就一行。但实际项目中,我们往往需要给注解加点“料”——也就是参数。

annotation class Loggable(
    val level: String = "INFO",
    val tag: String = "Default"
)

这里要注意,注解的参数类型是有限制的:只能使用基本类型、String、枚举、Class、其他注解,以及它们的数组。我在项目中就踩过这个坑——想把一个自定义对象传进去,结果编译器直接报错。

另外,Kotlin 允许注解的参数有默认值。这个设计很贴心,你想想看,如果大部分场景都用默认值,调用方就不用每次都写一堆参数了。

使用注解:贴标签的艺术

定义好了,怎么用?直接往目标上一贴就行:

class UserService {

    @Loggable(level = "DEBUG", tag = "UserService")
    fun getUser(id: Long): User {
        // 业务逻辑
        return User(id, "张三")
    }

    @Loggable
    fun saveUser(user: User) {
        // 默认使用 INFO 级别
    }
}

你看,getUser 用了自定义参数,saveUser 用了默认值。灵活又清晰。

注解可以贴在哪些地方?Kotlin 支持的目标比 Java 更多:

  • 类、接口、对象
  • 函数、属性
  • 构造函数
  • Lambda 表达式
  • 甚至类型(这个 Java 没有)

举个例子,如果你想给一个 Lambda 表达式加注解:

val action: @Loggable () -> Unit = {
    println("执行操作")
}

嗯,这个用法比较少见,但确实存在。我一般只在需要精细控制类型检查时才会这么用。

元注解:注解的注解

元注解,就是用来定义注解的注解。听起来有点绕,但理解起来不难。

Kotlin 提供了几个内置的元注解,我挑几个最常用的说说:

元注解 作用 我的经验
@Target 指定注解可以贴在哪些地方 不指定的话,默认几乎所有地方都能贴,容易误用
@Retention 指定注解的生命周期 编译时处理用 SOURCE,运行时反射用 RUNTIME
@Repeatable 允许同一个目标贴多个相同注解 我用来做权限检查,一个函数可以贴多个角色注解
@MustBeDocumented 让注解出现在 API 文档中 库作者建议加上,方便使用者查阅

看个实际例子。我想定义一个只能用在函数上、只在编译期生效的注解:

@Target(AnnotationTarget.FUNCTION)
@Retention(AnnotationRetention.SOURCE)
annotation class DebugOnly

这样,如果有人试图把这个注解贴在类上,编译器就会报错。我曾经在一个团队里见过有人把 @Override 贴到变量上——虽然 Kotlin 不允许,但 Java 早期版本确实可以,结果就是代码逻辑完全不对。所以,@Target 一定要用好。

注解处理器:让注解真正干活

定义和使用注解只是第一步。真正让注解发挥威力的,是注解处理器。

Kotlin 里处理注解主要有两种方式:

  1. 编译时处理:用 KSP(Kotlin Symbol Processing)或 KAPT(Kotlin Annotation Processing Tool)
  2. 运行时处理:通过反射读取注解信息

先看运行时处理。这个比较简单,适合小规模场景:

fun processLoggable(target: Any) {
    target::class.members.forEach { member ->
        member.annotations.forEach { annotation ->
            if (annotation is Loggable) {
                println("函数 ${member.name} 需要日志记录,级别:${annotation.level}")
            }
        }
    }
}

但说实话,反射性能开销不小。我在项目中一般只在框架代码里用运行时注解,业务代码里尽量不用。

编译时处理才是重头戏。以 KSP 为例,你可以写一个处理器,在编译阶段生成代码:

class LoggableProcessor : SymbolProcessor {
    override fun process(resolver: Resolver): List<KSAnnotated> {
        val symbols = resolver.getSymbolsWithAnnotation("com.example.Loggable")
        symbols.filterIsInstance<KSFunctionDeclaration>().forEach { function ->
            // 生成日志代码
            println("为函数 ${function.simpleName} 生成日志代码")
        }
        return emptyList()
    }
}

这样做的好处是:运行时零开销,所有代码都在编译期生成好了。像 Dagger、Room 这些库,底层都是这么干的。

核心要点:注解本身不干活,干活的是注解处理器。处理器在编译期或运行时读取注解信息,然后执行相应的逻辑。

避坑指南

这些年用注解,我踩过不少坑。挑几个典型的说说:

我曾经犯过的错:

  • @Retention 设成 SOURCE,却想在运行时通过反射读取——结果啥也读不到,排查了半天
  • 注解参数用了可变列表 vararg,结果 Kotlin 不支持,编译报错
  • 在 Lambda 表达式上贴注解,但忘了加 @Target(AnnotationTarget.EXPRESSION),编译器直接忽略

我的建议:

  • 能用编译时处理就别用运行时反射,性能差距很大
  • 注解参数尽量用默认值,减少调用方的负担
  • 团队里统一注解命名规范,别搞出 @Log@Logger@Loggable 这种混用的情况

知识体系总览

下面这张图,帮你把今天讲的内容串起来:

Kotlin 注解知识体系 注解 定义注解 使用注解 元注解 annotation class 语法 参数类型限制 默认值 类/函数/属性/类型 Lambda 表达式 构造函数 @Target / @Retention @Repeatable @MustBeDocumented

注解这东西,说白了就是给代码加标签。定义好标签,贴对位置,再配上处理器——一套完整的代码增强方案就出来了。我刚开始用注解时总觉得它很“魔法”,后来写多了才发现,它其实就是一种约定,一种让工具和框架理解你意图的方式。

好了,注解这块就聊到这儿。记住:注解是工具,不是目的。别为了用注解而用注解,那样只会让代码变得复杂难懂。该用时果断用,不该用时别硬上。