19、数据库注解处理:Room注解的深度解析、自定义@Table注解处理器
说实话,Room 这个库刚出来的时候,我内心是拒绝的。那时候项目里用的还是 GreenDAO,自己写 SQLite 辅助类也写习惯了。但后来在一个日活千万级的项目里,我被 GreenDAO 的编译速度折磨得不行——每次改个实体类,编译要等两分钟。换 Room 之后,编译时间直接砍半。嗯,真香。
今天咱们就来聊聊 Room 的注解体系,以及如何自己动手写一个类似 @Table 的注解处理器。你想想看,理解了这些底层原理,以后遇到 ORM 框架的坑,你就能一眼看穿。
Room 注解体系概览
Room 的注解其实就三大类:实体注解、关系注解、数据库注解。我习惯把它们画成一张图,方便理解整体脉络。
@Entity 注解深度解析
先看最常用的 @Entity。说白了,它就是告诉 Room:「这个类对应数据库里的一张表」。但很多人只用了它的皮毛。
@Entity(
tableName = "users",
indices = [Index(value = ["email"], unique = true)],
foreignKeys = [
ForeignKey(
entity = Address::class,
parentColumns = ["id"],
childColumns = ["address_id"],
onDelete = ForeignKey.CASCADE
)
]
)
data class User(
@PrimaryKey(autoGenerate = true)
val id: Long = 0,
@ColumnInfo(name = "full_name")
val name: String,
@ColumnInfo(name = "email_address")
val email: String,
@ColumnInfo(name = "address_id")
val addressId: Long? = null,
@Ignore
val tempField: String = ""
)
这里有几个点我特别想强调:
- tableName:不指定的话默认用类名。但我建议显式指定,因为重构类名时不会影响数据库结构。
- indices:加索引能大幅提升查询速度。我在项目中遇到过,一个没加索引的表,查询 10 万条数据要 3 秒,加了索引后降到 50 毫秒。
- foreignKeys:级联删除很好用,但要注意性能开销。
- @Ignore:标记不需要持久化的字段。我见过有人把 Bitmap 直接放在实体类里,然后编译报错——嗯,这种字段必须加 @Ignore。
@Dao 与 @Query 注解
Dao 层是 Room 的精髓。它把 SQL 写成了注解,但本质上还是 SQL。
@Dao
interface UserDao {
@Query("SELECT * FROM users WHERE email = :email")
suspend fun getUserByEmail(email: String): User?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertUser(user: User)
@Update
suspend fun updateUser(user: User)
@Delete
suspend fun deleteUser(user: User)
@Transaction
@Query("SELECT * FROM users")
suspend fun getUsersWithAddress(): List<UserWithAddress>
}
我个人习惯把复杂的 SQL 查询都写在 @Query 里,而不是用 @Insert 或 @Update 的默认行为。为什么?因为 SQL 更直观,你能精确控制要查什么字段、怎么关联。
自定义 @Table 注解处理器
理解了 Room 的注解原理,咱们就可以自己动手写一个简化版的 @Table 处理器。说实话,这比你想的要简单。
先定义注解:
@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.SOURCE)
annotation class Table(
val name: String = "",
val primaryKey: String = "id",
val autoGenerate: Boolean = true
)
然后写注解处理器:
@AutoService(Processor::class)
@SupportedSourceVersion(SourceVersion.RELEASE_8)
@SupportedOptions("tablePackage")
class TableProcessor : 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(Table::class.java.canonicalName)
}
override fun process(
annotations: Set<TypeElement>,
roundEnv: RoundEnvironment
): Boolean {
val tableElements = roundEnv
.getElementsAnnotatedWith(Table::class.java)
.filterIsInstance<TypeElement>()
for (element in tableElements) {
val tableAnnotation = element.getAnnotation(Table::class.java)
val tableName = tableAnnotation.name.ifEmpty { element.simpleName }
val packageName = processingEnv.elementUtils
.getPackageOf(element)
.qualifiedName
generateTableHelper(element, tableName, packageName)
}
return true
}
private fun generateTableHelper(
element: TypeElement,
tableName: String,
packageName: String
) {
val className = "${element.simpleName}TableHelper"
val fields = element.enclosedElements
.filterIsInstance<VariableElement>()
.filter { it.getAnnotation(Transient::class.java) == null }
val code = buildString {
appendLine("package $packageName")
appendLine()
appendLine("object $className {")
appendLine(" const val TABLE_NAME = \"$tableName\"")
appendLine()
appendLine(" const val CREATE_TABLE_SQL = \"\"\"")
append(" CREATE TABLE IF NOT EXISTS $tableName (")
fields.forEachIndexed { index, field ->
val fieldName = field.simpleName
val fieldType = field.asType().toString()
val sqlType = when {
fieldType == "kotlin.String" -> "TEXT"
fieldType == "kotlin.Long" || fieldType == "kotlin.Int" -> "INTEGER"
fieldType == "kotlin.Boolean" -> "INTEGER"
fieldType == "kotlin.Double" -> "REAL"
else -> "TEXT"
}
val comma = if (index < fields.size - 1) "," else ""
appendLine(" $fieldName $sqlType$comma")
}
appendLine(" )")
appendLine(" \"\"\"")
appendLine("}")
}
try {
val file = filer.createSourceFile(
"$packageName.$className",
element
)
file.openWriter().use { writer ->
writer.write(code)
}
} catch (e: Exception) {
messager.printMessage(Diagnostic.Kind.ERROR, e.message)
}
}
}
这个处理器做了三件事:
- 扫描所有加了 @Table 注解的类
- 提取类中的字段,映射成 SQL 类型
- 生成一个 TableHelper 对象,包含表名和建表 SQL
你想想看,Room 的源码其实也是类似的逻辑,只不过它生成的代码更复杂——包括 CRUD 方法、游标转换、类型转换器等等。
注册注解处理器
别忘了在 META-INF 里注册处理器。用 AutoService 注解可以自动生成,但如果你手动写,路径是这样的:
// META-INF/services/javax.annotation.processing.Processor
com.example.processor.TableProcessor
核心要点:
- 注解处理器在编译期运行,不能操作运行时类
- 生成的代码要放在与原始类相同的包下,避免包名冲突
- 用 Filer 创建文件,不要手动写 FileOutputStream
- 处理器的 init() 方法里获取 Filer 和 Messager,process() 里做实际工作
实战中的注意事项
我在项目中用自定义注解处理器做过一个轻量级的 ORM 框架,踩了不少坑。这里分享几个经验:
| 问题 | 原因 | 解决方案 |
|---|---|---|
| 生成的代码找不到类 | 包名写错了 | 用 processingEnv.elementUtils.getPackageOf() 获取包名 |
| 字段类型映射错误 | Kotlin 的 Long 和 Java 的 long 类型不同 | 统一用 asType().toString() 判断,不要硬编码 |
| 增量编译报错 | 处理器没有正确处理增量编译 | 实现 getSupportedOptions() 返回空集合,或添加 @SupportedOptions |
| 生成的代码有语法错误 | 字符串拼接时少了空格或分号 | 用 JavaPoet 或 KotlinPoet 生成代码,不要手写字符串 |
最后说一句,理解注解处理器的原理,不仅能帮你用好 Room,还能让你在遇到其他编译期框架(如 Dagger、Glide、ButterKnife)时,快速定位问题。说白了,这些框架的底层都是类似的套路——扫描注解、生成代码、编译期完成依赖注入或绑定。
嗯,今天就聊到这儿。记住,注解处理器是编译期的「魔法」,它不增加运行时开销,但能大大减少你的样板代码。下次遇到重复的数据库操作代码,不妨想想:能不能写个注解处理器来自动生成?