4. Room 实体与数据库:给数据找个家
好,我们开始真正盖房子了。前面几章你知道了 Room 是什么、为什么选它。这章咱们亲手定义实体、建数据库,把代码跑起来。
我个人习惯先把数据模型画清楚,再去写结构。所以先聊 @Entity,再说 @Database,最后用 Room.databaseBuilder 把实例拿到手。这三步走完,你的数据层就稳了。
4.1 @Entity 实体类 —— 你的数据“户口本”
说白了,每个 @Entity 对应数据库里的一张表。你定义一个 Kotlin 数据类,加个注解,Room 就帮你建表。
@Entity(tableName = "tasks")
data class TaskEntity(
@PrimaryKey(autoGenerate = true)
val id: Long = 0,
val title: String,
val description: String? = null,
val isCompleted: Boolean = false,
val createdAt: Long = System.currentTimeMillis()
)
这里我用 @PrimaryKey(autoGenerate = true),自增主键,省心。id 默认 0,Room 会帮你填上。
字段类型要跟 SQLite 类型匹配。Room 会自动映射:Int→INTEGER,String→TEXT,Long→INTEGER,Boolean→INTEGER(0/1)。支持 List<String> 吗?没有,需要 TypeConverter。这个后面章节细聊。
4.1.1 表名、列名、索引
如果你不想用类名当表名,tableName 搞定。列名默认就是字段名,想改可以用 @ColumnInfo(name = "xxx")。
@Entity(
tableName = "tasks",
indices = [
Index(value = ["createdAt"], unique = false),
Index(value = ["title"], unique = false)
]
)
data class TaskEntity(
@PrimaryKey(autoGenerate = true)
val id: Long = 0,
@ColumnInfo(name = "task_title")
val title: String,
...
)
索引能加速查询,但别滥用。我见过有人全字段加索引,结果写入变慢。挑常用 where 字段加就行。
4.1.2 外键 —— 表与表的“父子关系”
如果任务属于某个用户,你可以加外键:
@Entity(
foreignKeys = [
ForeignKey(
entity = UserEntity::class,
parentColumns = ["id"],
childColumns = ["userId"],
onDelete = ForeignKey.CASCADE
)
]
)
data class TaskEntity(
...
val userId: Long
)
这里 onDelete = CASCADE 表示用户删除时,所有任务也跟着删。我曾经踩坑没写 CASCADE,结果用户删了,任务表里一堆孤儿数据。别学我。
4.2 @Database —— 数据库的入口类
实体有了,现在要告诉 Room “哪些表属于这个数据库”。
@Database(
entities = [TaskEntity::class, UserEntity::class],
version = 1,
exportSchema = true
)
abstract class AppDatabase : RoomDatabase() {
abstract fun taskDao(): TaskDao
abstract fun userDao(): UserDao
companion object {
@Volatile
private var INSTANCE: AppDatabase? = null
fun getInstance(context: Context): AppDatabase {
return INSTANCE ?: synchronized(this) {
val instance = Room.databaseBuilder(
context.applicationContext,
AppDatabase::class.java,
"app_database.db"
).build()
INSTANCE = instance
instance
}
}
}
}
@Database 注解里列出所有实体类。version 从 1 开始,升级时递增。exportSchema = true 会生成 schema 文件,方便检查数据库结构变化。
@Volatile 和 synchronized,确保多线程安全。这是标准的单例写法,记住它。
4.3 创建数据库实例 —— 什么时候?在哪创建?
数据库实例只需要一个,全局共享。我一般在 Application 类里初始化,但为了测试方便,很多人改用依赖注入(Dagger/Hilt)。这节咱先手写单例。
class MyApplication : Application() {
lateinit var database: AppDatabase
private set
override fun onCreate() {
super.onCreate()
database = AppDatabase.getInstance(this)
}
}
然后在 Activity 或 ViewModel 里通过 Application 获取:
val db = (application as MyApplication).database
val tasks = db.taskDao().getAllTasks()
4.3.1 迁移策略 —— 数据库升级别慌
如果你的 app 已经在用户手机上了,你改实体类(加字段、删表)后,Room 会抛异常“数据库版本不匹配”。这时你需要写迁移:
val MIGRATION_1_2 = object : Migration(1, 2) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("ALTER TABLE tasks ADD COLUMN dueDate INTEGER DEFAULT NULL")
}
}
// 在 builder 里加上
Room.databaseBuilder(...)
.addMigrations(MIGRATION_1_2)
.build()
简单的 ALTER TABLE 就行。复杂场景比如合并表,要写多条 SQL,甚至用事务。我建议一定在发布前用测试设备跑一遍 migration 流程,我曾经线上没测试,结果用户数据全丢了……
- 实体类等于表结构,修改后版本号+1
- 每次升级写对应的 Migration 对象
- 加字段用 ALTER TABLE,删字段只能新建表再复制数据
- 测试 migration 用
.fallbackToDestructiveMigration()做开发期降级,但不要发布时用
4.4 一张图看懂 Room 数据库结构
下面这个 SVG 把本章核心知识点串起来了。你看看实体、数据库、DAO 是怎么配合的。
4.5 本章小结 & 避坑清单
| 知识点 | 注意事项 |
|---|---|
| @Entity 实体 | 用 data class,主键自增默认 0,字段类型要匹配 SQLite |
| @Database 入口 | 列出所有实体,version 从 1 开始,exportSchema 打开 |
| 数据库实例 | 单例 + @Volatile + synchronized,用 applicationContext 避免泄漏 |
| 数据库迁移 | 版本升级必须写 Migration,测试设备验证,别用 fallbackToDestructiveMigration |