19、数据库注解处理:Room注解的深度解析、自定义@Table注解处理器

说实话,Room 这个库刚出来的时候,我内心是拒绝的。那时候项目里用的还是 GreenDAO,自己写 SQLite 辅助类也写习惯了。但后来在一个日活千万级的项目里,我被 GreenDAO 的编译速度折磨得不行——每次改个实体类,编译要等两分钟。换 Room 之后,编译时间直接砍半。嗯,真香。

今天咱们就来聊聊 Room 的注解体系,以及如何自己动手写一个类似 @Table 的注解处理器。你想想看,理解了这些底层原理,以后遇到 ORM 框架的坑,你就能一眼看穿。

Room 注解体系概览

Room 的注解其实就三大类:实体注解、关系注解、数据库注解。我习惯把它们画成一张图,方便理解整体脉络。

Room 注解体系 实体注解 关系注解 数据库注解 @Entity @PrimaryKey @ColumnInfo @Ignore @ForeignKey @Index @Embedded @Relation @Database @Dao @Query @Insert / @Update / @Delete 核心流程: 1. 编译期扫描 @Entity、@Dao、@Database 注解 2. 生成对应的实现类(如 UserDao_Impl、AppDatabase_Impl) 3. 运行时通过生成的代码操作 SQLite

@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。
⚠️ 我曾经踩过的坑: 在 @Entity 中同时使用 @PrimaryKey(autoGenerate = true) 和 Kotlin 的默认参数值,会导致插入时主键冲突。因为 Room 会认为你传了 0 值,而不是「未指定」。解决方案是使用 Long? 类型,默认值设为 null。

@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 更直观,你能精确控制要查什么字段、怎么关联。

💡 小技巧: 用 @Transaction 注解标记需要事务的方法。比如同时插入用户和地址时,加上 @Transaction 能保证数据一致性。Room 会自动帮你开启和提交事务。

自定义 @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)
        }
    }
}

这个处理器做了三件事:

  1. 扫描所有加了 @Table 注解的类
  2. 提取类中的字段,映射成 SQL 类型
  3. 生成一个 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 生成代码,不要手写字符串
⚠️ 我曾经踩过的坑: 在处理器中直接使用 Kotlin 的协程或挂起函数。注解处理器运行在 Java 编译环境中,不支持 Kotlin 协程。所有处理逻辑必须是同步的。如果你需要异步,可以考虑用 Java 的 CompletableFuture,但说实话,没必要——处理器的逻辑通常很快。

最后说一句,理解注解处理器的原理,不仅能帮你用好 Room,还能让你在遇到其他编译期框架(如 Dagger、Glide、ButterKnife)时,快速定位问题。说白了,这些框架的底层都是类似的套路——扫描注解、生成代码、编译期完成依赖注入或绑定。

嗯,今天就聊到这儿。记住,注解处理器是编译期的「魔法」,它不增加运行时开销,但能大大减少你的样板代码。下次遇到重复的数据库操作代码,不妨想想:能不能写个注解处理器来自动生成?