21、Intent注解处理:自动生成Activity跳转代码、Bundle参数注入
说实话,做Android开发这么多年,最让我烦的就是写Activity跳转和传参。你想想看,每次都要写一堆Intent.putExtra,然后目标Activity里又要写一堆getStringExtra、getIntExtra。代码量倒是不大,但就是烦——而且特别容易出错。
我记得有一次,项目里有个页面改了参数名,结果忘了改接收方。测试的时候没发现,上线后用户反馈说页面数据不对。排查了半天,最后发现是key写错了。这种低级错误,说白了就是手写Intent传参的锅。
那怎么解决?用注解处理器自动生成跳转代码和Bundle注入代码。今天我们就来聊聊这个方案。
21.1 核心思路:编译期生成,运行时零反射
这里我要强调一点:我们用的是注解处理器(APT),不是运行时反射。很多人一听到「注解」就想到反射,其实不是一回事。注解处理器在编译期就把代码生成了,运行时根本不需要反射,性能完全没影响。
整个方案的核心流程是这样的:
// 1. 在源Activity上标记注解
@Router(path = "app/user/profile")
class UserProfileActivity : AppCompatActivity() {
@Arg
lateinit var userId: String
@Arg
var age: Int = 0
}
// 2. 编译期自动生成跳转辅助类
// 生成文件:UserProfileActivityRouter.kt
object UserProfileActivityRouter {
fun createIntent(context: Context, userId: String, age: Int): Intent {
return Intent(context, UserProfileActivity::class.java).apply {
putExtra("userId", userId)
putExtra("age", age)
}
}
fun inject(activity: UserProfileActivity) {
activity.userId = activity.intent?.getStringExtra("userId") ?: ""
activity.age = activity.intent?.getIntExtra("age", 0) ?: 0
}
}
// 3. 使用时一行代码搞定
UserProfileActivityRouter.createIntent(this, "123", 25).let { startActivity(it) }
嗯,这里要注意:生成的代码里,inject方法是在目标Activity的onCreate里调用的。我个人习惯在基类里统一处理,这样每个子类都不用写重复代码。
21.2 注解定义:@Router 和 @Arg
我们先定义两个注解。一个标记路由路径,一个标记需要注入的参数。
// @Router 注解
@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.SOURCE) // 编译期就够了
annotation class Router(val path: String)
// @Arg 注解
@Target(AnnotationTarget.FIELD)
@Retention(AnnotationRetention.SOURCE)
annotation class Arg(val key: String = "") // 空字符串表示使用字段名作为key
为什么Retention用SOURCE?因为注解处理器在编译期就处理完了,生成的代码里没有注解信息,所以SOURCE就够了,不用浪费空间。
21.3 注解处理器:核心逻辑
接下来是重头戏——注解处理器。我直接贴核心代码,然后逐段解释。
@AutoService(Processor::class)
@SupportedSourceVersion(SourceVersion.RELEASE_8)
@SupportedOptions("moduleName")
class RouterProcessor : AbstractProcessor() {
private lateinit var filer: Filer
private lateinit var messager: Messager
override fun init(processingEnv: ProcessingEnvironment) {
super.init(processingEnv)
filer = processingEnv.filer
messager = processingEnv.messager
}
override fun getSupportedAnnotationTypes(): Set<String> {
return setOf(Router::class.java.canonicalName, Arg::class.java.canonicalName)
}
override fun process(annotations: Set<TypeElement>, roundEnv: RoundEnvironment): Boolean {
// 获取所有被@Router标记的类
val routerElements = roundEnv.getElementsAnnotatedWith(Router::class.java)
for (element in routerElements) {
if (element.kind != ElementKind.CLASS) continue
val classElement = element as TypeElement
val router = classElement.getAnnotation(Router::class.java)
// 获取该类中所有被@Arg标记的字段
val argFields = mutableListOf<Pair<String, String>>() // (fieldName, fieldType)
for (enclosed in classElement.enclosedElements) {
if (enclosed.kind != ElementKind.FIELD) continue
val arg = enclosed.getAnnotation(Arg::class.java) ?: continue
val fieldName = enclosed.simpleName.toString()
val fieldType = enclosed.asType().toString()
argFields.add(fieldName to fieldType)
}
// 生成代码
generateRouterClass(classElement, router.path, argFields)
}
return true
}
private fun generateRouterClass(
classElement: TypeElement,
path: String,
argFields: List<Pair<String, String>>
) {
val packageName = processingEnv.elementUtils.getPackageOf(classElement).qualifiedName.toString()
val className = classElement.simpleName.toString()
val routerClassName = "${className}Router"
// 构建代码字符串
val code = buildString {
appendLine("package $packageName")
appendLine()
appendLine("import android.content.Context")
appendLine("import android.content.Intent")
appendLine()
appendLine("object $routerClassName {")
appendLine()
// createIntent 方法
appendLine(" fun createIntent(context: Context, ${argFields.joinToString(", ") { "${it.first}: ${it.second}" }}): Intent {")
appendLine(" return Intent(context, $className::class.java).apply {")
for ((fieldName, _) in argFields) {
appendLine(" putExtra(\"$fieldName\", $fieldName)")
}
appendLine(" }")
appendLine(" }")
appendLine()
// inject 方法
appendLine(" fun inject(activity: $className) {")
for ((fieldName, fieldType) in argFields) {
val getter = when {
fieldType == "String" -> "getStringExtra(\"$fieldName\") ?: \"\""
fieldType == "Int" || fieldType == "kotlin.Int" -> "getIntExtra(\"$fieldName\", 0)"
fieldType == "Boolean" || fieldType == "kotlin.Boolean" -> "getBooleanExtra(\"$fieldName\", false)"
fieldType == "Long" || fieldType == "kotlin.Long" -> "getLongExtra(\"$fieldName\", 0L)"
fieldType == "Float" || fieldType == "kotlin.Float" -> "getFloatExtra(\"$fieldName\", 0f)"
fieldType == "Double" || fieldType == "kotlin.Double" -> "getDoubleExtra(\"$fieldName\", 0.0)"
else -> "getSerializableExtra(\"$fieldName\") as? $fieldType"
}
appendLine(" activity.$fieldName = activity.intent?.$getter")
}
appendLine(" }")
appendLine("}")
}
// 写入文件
try {
val file = filer.createSourceFile("$packageName.$routerClassName")
file.openWriter().use { writer ->
writer.write(code)
}
} catch (e: Exception) {
messager.printMessage(Diagnostic.Kind.ERROR, "生成文件失败: ${e.message}")
}
}
}
这段代码的核心逻辑其实不复杂:
- 遍历所有被@Router标记的类
- 找出该类中被@Arg标记的字段
- 根据字段类型生成对应的getter方法
- 生成createIntent和inject两个方法
我在项目中遇到过一个问题:如果字段类型是自定义的Parcelable对象,上面的代码就处理不了了。后来我加了个判断,如果是Parcelable就用putParcelable,如果是Serializable就用putSerializable。嗯,这块其实可以做得更通用一些,但实际项目中大部分场景就是基本类型加String,所以够用了。
21.4 使用方式:简洁到极致
生成完代码后,使用起来就非常简单了。
// 发送方
class MainActivity : AppCompatActivity() {
fun goToProfile() {
// 一行代码跳转,参数自动传过去
UserProfileActivityRouter.createIntent(this, "user_001", 28).let {
startActivity(it)
}
}
}
// 接收方
class UserProfileActivity : AppCompatActivity() {
@Arg
lateinit var userId: String
@Arg
var age: Int = 0
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// 注入参数
UserProfileActivityRouter.inject(this)
// 直接用,不用再写getStringExtra了
Log.d("Profile", "userId=$userId, age=$age")
}
}
你看,接收方只需要在onCreate里调一行inject,所有字段就自动赋值了。而且类型安全——如果传参类型不对,编译期就报错了,不会等到运行时崩溃。
21.5 避坑指南
我曾经踩过一个坑:Kotlin的lateinit var和可空类型混用的时候,注解处理器生成的代码可能会出问题。比如:
@Arg
lateinit var name: String // 没问题
@Arg
var name: String? = null // 这个也能用,但生成的代码要小心
对于可空类型,生成的getter应该用as?来安全转换,而不是直接强制解包。上面代码里我已经处理了这种情况,用as?加上默认值,保证不会NPE。
还有一个坑:如果字段名和Intent的key不一致怎么办?比如字段叫userName,但传参的key是"name"。我建议在@Arg注解里加一个key参数,允许自定义key名:
@Arg(key = "name")
var userName: String = ""
这样生成的代码就会用"name"作为key来读写Intent。这个小功能在实际项目中非常实用,尤其是对接第三方SDK或者老项目改造的时候。
21.6 整体架构图
下面这张图展示了整个注解处理流程:
21.7 扩展思考
这套方案其实可以做得更强大。比如:
- 支持Fragment传参:原理一样,用Bundle代替Intent
- 支持startActivityForResult:生成带requestCode的重载方法
- 支持路由拦截:在跳转前检查登录状态、权限等
我在实际项目中就把这套方案扩展成了完整的路由框架,支持模块化开发、动态加载、拦截器链等功能。但核心思想没变——就是编译期生成代码,运行时零反射。
好了,关于Intent注解处理的内容就聊到这里。这套方案我用了好几年,从最初的简单跳转,到后来支持参数注入、路由拦截、模块化通信,一步步完善。说白了,核心就是「编译期生成,运行时零反射」这八个字。你想想看,如果能用编译期解决的问题,为什么要拖到运行时呢?