Room
Room 是 Google 的官方 SQLite 抽象层,目的是用类型安全的方式访问 SQLite,并自动生成大量样板代码(通过注解处理器)。
优点:编译时 SQL 校验、与 LiveData/Flow/Coroutines/Paging 集成、支持迁移、测试方便。
局限:对复杂 SQL 仍可回退到原生 SupportSQLiteDatabase.execSQL(),但建议把逻辑放到 DAO。
一、核心概念与组件
1、Entity(实体) —— 对应表
@Entity(tableName = "merchant_info", indices = [Index(value = ["merchantId"], unique = true)])
data class MerchantInfo(
@PrimaryKey(autoGenerate = true) val id: Int = 0,
val merchantId: String,
val merchantName: String,
val merchantToken: String?,
val operationMode: String?
)
@PrimaryKey(autoGenerate = true):插入时可用 0 或默认值占位,Room 会生成 id。
indices、foreignKeys 可在这里声明。
2、DAO(Data Access Object)—— 数据访问接口
@Dao
interface MerchantDao {
@Query("SELECT * FROM merchant_info")
fun getMerchant(): Flow<MerchantInfo?> // 推荐:Flow(响应式)
@Insert(onConflict = OnConflictStrategy.ABORT)
suspend fun insert(merchant: MerchantInfo): Long // 返回生成的主键
@Update
suspend fun update(merchant: MerchantInfo): Int // 返回影响行数
@Delete
suspend fun delete(merchant: MerchantInfo): Int
}
@Query 支持任意 SQL;参数用 :param 占位。
返回类型可为 Flow / LiveData / suspend 普通类型 / 普通同步类型(但同步查询在主线程会抛异常,除非 allowMainThreadQueries())。
3、Database(继承 RoomDatabase)
@Database(entities = [MerchantInfo::class], version = 2, exportSchema = true)
abstract class AppDatabase : RoomDatabase() {
abstract fun merchantDao(): MerchantDao
}
二、DAO 返回值套路与线程
Flow<T> / LiveData<T>:Room 自动在后台线程发射数据,UI 可以直接收集(collectAsState())。首次收集:StateFlow 会立即发当前值,SharedFlow(replay=0) 则不会;注意区分。
suspend fun(返回实体或 List) → Room 会自动在后台线程执行(你无需自己 withContext(Dispatchers.IO))。
非 suspend 的同步返回(如 fun getAll(): List<T>)默认在调用线程执行,不推荐在主线程调用。
插入(@Insert)单条可返回 Long(生成 id),多条返回 List<Long>。@Update/@Delete 返回 Int(受影响行数)。
三、关系(Relation)与事务
如果需要把多表关联的结果映射成一个 关系对象(POJO),使用 @Relation 与 @Transaction:
data class AuthorWithBooks(
@Embedded val author: Author,
@Relation(parentColumn = "id", entityColumn = "authorId")
val books: List<Book>
)
@Dao
interface AuthorDao {
@Transaction
@Query("SELECT * FROM author WHERE id = :id")
suspend fun getAuthorWithBooks(id: Long): AuthorWithBooks
}
//调用方式
val result = dao.getAuthorWithBooks(id)
val books = result?.books // 这里就是自动关联的 Book
解释:
@Embedded → 包含 Author 原始字段
@Relation → Room 会根据
Author.id == Book.authorId 自动加载对应曲线
@Transaction 保证方法内的多个查询在同一个 DB 事务里,一般用于读关联或需要一致性的多步写操作。
Room 会自动帮你查第二张表,不需要写 JOIN
四、TypeConverters(复杂类型转存)
Room 只支持基本类型(String, Int, Long, ...)。保存 Date、List<String>、自定义对象需使用 TypeConverter:
class Converters {
@TypeConverter fun fromTimestamp(value: Long?) = value?.let { Date(it) }
@TypeConverter fun dateToTimestamp(date: Date?) = date?.time
@TypeConverter fun fromStringList(value: List<String>?) = value?.joinToString(",")
@TypeConverter fun toStringList(value: String?) = value?.split(",") ?: emptyList()
}
@Database(... )
@TypeConverters(Converters::class)
abstract class AppDatabase : RoomDatabase()
五、迁移(Migrations)与预装数据库
手动迁移(常用):
val MIGRATION_1_2 = object : Migration(1, 2) {
override fun migrate(db: SupportSQLiteDatabase) {
// 新表、复制数据、删除旧表 -> 推荐的做法
db.execSQL("ALTER TABLE merchant_info ADD COLUMN newCol TEXT")
// 对于复杂变更(rename/改变列类型),通常要建一个新表 -> copy -> drop -> rename
}
}
val db = Room.databaseBuilder(context, AppDatabase::class.java, "app.db")
.addMigrations(MIGRATION_1_2)
.build()
自动迁移(AutoMigration):Room 支持声明式自动迁移(会在简单变更下自动生成迁移),但对复杂变更仍需手写。
fallbackToDestructiveMigration():测试/开发可用,会在缺失迁移时候删除旧 DB 并重建(会丢数据,生产慎用)。
预装数据库(createFromAsset / createFromFile):可把已有 SQLite DB 打包在 assets,然后 createFromAsset("db/my.db") 加载。
六、与架构/DI/Coroutine/Paging 集成(实战)
Repository + ViewModel + Flow:
class MerchantRepository @Inject constructor(private val dao: MerchantDao) {
val merchantFlow: Flow<MerchantInfo?> = dao.getMerchant()
suspend fun save(m: MerchantInfo) = dao.insert(m)
}
@HiltViewModel
class MerchantViewModel @Inject constructor(private val repo: MerchantRepository): ViewModel() {
val merchantState = repo.merchantFlow.stateIn(viewModelScope, SharingStarted.Eagerly, null)
fun save(m: MerchantInfo) = viewModelScope.launch { repo.save(m) }
}
Hilt 提供 DB/DAO:
@Module @InstallIn(SingletonComponent::class)
object DatabaseModule {
@Provides @Singleton
fun provideDb(@ApplicationContext ctx: Context) =
Room.databaseBuilder(ctx, AppDatabase::class.java, "app.db").build()
@Provides fun provideMerchantDao(db: AppDatabase) = db.merchantDao()
}
七、外键(ForeignKeys)
foreignKeys 是 Room(SQLite)的 外键约束。确保 child 数据表中的 column(子列)指向 parent 数据表中的合法 row。
@Entity(
tableName = "memory_items",
foreignKeys = [
ForeignKey(
entity = EbbinghausCurve::class,
parentColumns = ["id"],
childColumns = ["curveId"],
onDelete = ForeignKey.RESTRICT
)
],
indices = [Index(value = ["curveId"])]
)
data class MemoryItem(
@PrimaryKey(autoGenerate = true)
val id: Long = 0,
val title : String,
val content: String
)
上面的代码中:
child = MemoryItem
parent = EbbinghausCurve
child column = curveId
parent column = id
意思是:
MemoryItem.curveId 必须对应 EbbinghausCurve 表中某个有效 id。
否则插入或更新时会报错。
1️⃣. entity = EbbinghausCurve::class
父表(被引用表)是:
EbbinghausCurve
意味着:
memory_items.curveId 对应 ebbinghaus_curve.id
2️⃣. parentColumns = ["id"]
父表中用来作为外键目标的字段为:
EbbinghausCurve.id
3️⃣.childColumns = ["curveId"]
子表(MemoryItem 表)中用来引用父表的字段:
MemoryItem.curveId
4️⃣.onDelete = ForeignKey.RESTRICT
这是 删除父记录时的策略,相当重要!
RESTRICT:禁止删除
意思是:
如果某个 curveId 在 MemoryItem 中正在被使用,
那么对应的 EbbinghausCurve 不能被删除。
否则会出现外键冲突,删除失败。
🔥 Room 支持的 onDelete 策略(你会经常用到)
| 策略 |
含义 |
| RESTRICT |
默认;父记录被引用时,禁止删除(最安全) |
| CASCADE |
删除父 → 自动删除所有 child(像树一起删) |
| SET_NULL |
删除父 → child 外键字段置 null |
| NO_ACTION |
不采取行动,但最终仍会因外键约束导致失败 |
❗ 注意:既然 curveId 是外键,必须加 index
Room 外键字段必须加索引,否则会抛警告:
indices = [Index(value = ["curveId"])]
八、索引
✅ 1. 索引(Index)是什么?
索引是数据库里的“目录”,类似书籍的目录页。
如果没有索引,每次查找都得从第一页翻到最后一页 → 全表扫描。
如果有索引,就像书籍的目录,可以快速定位到目标字段所在的记录 → 高效查找。
Room 使用 SQLite,因此索引本质就是 SQLite 的 B-tree。
✅ 2. 在 Room 中怎么声明索引?
可以在 @Entity 注解里写:
@Entity(
indices = [
Index(value = ["curveId"])
]
)
//支持多个索引:
indices = [
Index("curveId"),
Index("title")
]
//或者组合索引(复合索引):
Index(value = ["title", "content"])
//用于多字段联合查询的加速。
✅ 3. 索引有什么用?(核心功能)
✔ 3.1 加速 WHERE 查询
如果你的 SQL 有:
SELECT * FROM memory_items WHERE curveId = ?
没有索引 → 全表扫描
有索引 → 从 B-tree 直接找到匹配项
查询速度会提升数百倍(大量数据时非常明显)。
✔ 3.2 加速排序 ORDER BY
SELECT * FROM memory_items ORDER BY createTime DESC
给 createTime 加索引后会快很多。
✔ 3.3 外键字段必须建立索引(Room 推荐,SQLite 优化)
上面的代码里:
foreignKeys = [...]
indices = [Index("curveId")]
原因:
插入 MemoryItem 时,SQLite 必须验证 curveId 是否存在
如果不建索引,就要扫描整个 EbbinghausCurve 表
Room 官方建议外键列一定要建索引。
✔ 3.4 唯一性约束(unique)
Room 支持:
Index(value = ["title"], unique = true)
这会在 SQLite 层强制:
title 不可重复
插入重复会直接抛错
❗ 4. 使用索引有什么成本?
一个数据库索引需要:
额外的磁盘空间
插入操作时多维护一次 B-tree → 稍微降低插入性能
但对查询性能提升巨大
特别是你有大量数据时。
实际开发中:
宁可多建索引,也不要让查询变慢。
🧠 5. 什么时候应该对字段建索引?(非常实用)
① 在 WHERE 中经常用到的字段
如:
WHERE userId = ?
WHERE curveId = ?
② 在 ORDER BY 中经常用到的字段
如:
ORDER BY createTime DESC
③ 在 JOIN 中经常用到的字段
如:
memory_items.curveId
④ 想要 unique 的字段
如 title、phoneNumber、email 等。
🧩 7. 复合索引什么时候用?
比如你常用:
SELECT * FROM memory_items WHERE curveId = ? AND title = ?
那么你应该:
Index(value = ["curveId", "title"])
SQLite 会按 (title, content) 建树,查找更快。
九、性能 & 最佳实践(Checklist)
给经常作为查询条件的列加 Index(@Index)。
避免把大量数据一次性查询到内存,使用分页(Paging 3)或分批加载。
写复杂 ALTER(如改列名、改类型)时用新表 copy 旧表数据再替换(安全且可迁移)。
在 DAO 使用 @Transaction 包装需要一致性的操作(写 + 读)。
@Insert(onConflict = REPLACE) 会删除旧行并插入新行,可能改变主键行为;选好冲突策略(ABORT/IGNORE/REPLACE)。
exportSchema = true 并保存 schema(便于版本管理/审计)。
避免在主线程执行同步查询或写(除非在测试/开发用 allowMainThreadQueries())。
使用 suspend 或 Flow 风格的 DAO,搭配 viewModelScope 和 collectAsState(),Compose UI 更容易响应式更新。
修改实体字段但忘记增加数据库版本或添加迁移 → app 崩溃或旧数据丢失。
使用 autoGenerate = true 时插入对象 id 不为 0,会被当作已存在主键导致冲突。建议默认 id: Int = 0。
直接修改实体内部字段(merchantInfo.merchantName = x)而不更新 StateFlow/LiveData — Compose 不会感知内部改变(需要 copy() 并发出新的 state)。
@Relation 需要 @Transaction,否则可能产生不一致的中间结果。
@Insert 返回 Long,注意检查返回值(-1 表示失败在某些策略下);@Update 返回受影响行数。
十、Room + Paging 3 + Compose + Hilt例子
1️⃣ Gradle 依赖(Module build.gradle)
dependencies {
// Compose UI
implementation "androidx.compose.ui:ui:1.6.0"
implementation "androidx.compose.material3:material3:1.2.0"
implementation "androidx.compose.ui:ui-tooling-preview:1.6.0"
implementation "androidx.activity:activity-compose:1.9.0"
// Room
implementation "androidx.room:room-runtime:2.6.0"
kapt "androidx.room:room-compiler:2.6.0"
implementation "androidx.room:room-ktx:2.6.0"
// Paging 3
implementation "androidx.paging:paging-runtime:3.2.0"
implementation "androidx.paging:paging-compose:3.2.0"
// Hilt
implementation "com.google.dagger:hilt-android:2.48"
kapt "com.google.dagger:hilt-compiler:2.48"
implementation "androidx.hilt:hilt-navigation-compose:1.1.0"
// Coroutines
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.0"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.0"
}
2️⃣ Entity(数据库表)
// MerchantInfo 表,对应数据库 merchant_info
@Entity(tableName = "merchant_info")
data class MerchantInfo(
@PrimaryKey(autoGenerate = true) val id: Int = 0, // 主键,自增长
val merchantId: String, // 商户ID
val merchantName: String // 商户名称
)
3️⃣ DAO(数据访问接口)
@Dao
interface MerchantDao {
// 返回 PagingSource,用于分页加载
@Query("SELECT * FROM merchant_info ORDER BY id ASC")
fun getMerchantsPaging(): PagingSource<Int, MerchantInfo>
// 批量插入测试数据,冲突则替换
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertAll(merchants: List<MerchantInfo>)
}
4️⃣ Room Database + Hilt Module
// Room Database 类
@Database(entities = [MerchantInfo::class], version = 1)
abstract class AppDatabase : RoomDatabase() {
abstract fun merchantDao(): MerchantDao // 获取 DAO
}
// Hilt 提供依赖注入
@Module
@InstallIn(SingletonComponent::class)
object DatabaseModule {
// 提供单例数据库
@Provides
@Singleton
fun provideDatabase(@ApplicationContext context: Context): AppDatabase {
return Room.databaseBuilder(context, AppDatabase::class.java, "app.db")
.fallbackToDestructiveMigration() // 缺少迁移时清空重建
.build()
}
// 提供 DAO
@Provides
fun provideMerchantDao(db: AppDatabase): MerchantDao = db.merchantDao()
}
5️⃣ Repository(处理数据逻辑)
class MerchantRepository @Inject constructor(private val dao: MerchantDao) {
// 提供分页 Flow
fun getMerchantsPager(): Flow<PagingData<MerchantInfo>>> {
return Pager(
config = PagingConfig(
pageSize = 20, // 每页加载 20 条
enablePlaceholders = false
),
pagingSourceFactory = { dao.getMerchantsPaging() }
).flow
}
// 插入测试数据,方便分页展示
suspend fun insertTestData() {
val list = (1..100).map { MerchantInfo(merchantId = "ID$it", merchantName = "Name $it") }
dao.insertAll(list)
}
}
6️⃣ ViewModel(提供给 UI 使用)
@HiltViewModel
class MerchantViewModel @Inject constructor(private val repo: MerchantRepository) : ViewModel() {
// 分页 Flow,cachedIn 绑定到 viewModelScope,防止旋转重置
val merchantPagerFlow: Flow&PagingData<MerchantInfo>> =
repo.getMerchantsPager()
.cachedIn(viewModelScope)
init {
viewModelScope.launch {
// 初始化测试数据
repo.insertTestData()
}
}
}
7️⃣ Compose UI(显示分页列表)
@Composable
fun MerchantListScreen(viewModel: MerchantViewModel = hiltViewModel()) {
// Flow<PagingData<MerchantInfo>> 转成 Compose 可用的 LazyPagingItems
val lazyPagingItems = viewModel.merchantPagerFlow.collectAsLazyPagingItems()
LazyColumn(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(8.dp) // 每行间距
) {
// 遍历分页数据
items(lazyPagingItems) { merchant ->
merchant?.let { MerchantItem(it) }
}
// Footer 显示加载状态
lazyPagingItems.apply {
when {
loadState.append is LoadState.Loading -> item { Text("加载更多...") }
loadState.refresh is LoadState.Loading -> item { Text("刷新中...") }
loadState.append is LoadState.Error -> item { Text("加载失败,请重试") }
}
}
}
}
// 单条商户展示
@Composable
fun MerchantItem(merchant: MerchantInfo) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 10.dp),
elevation = CardDefaults.cardElevation(4.dp)
) {
Column(modifier = Modifier.padding(16.dp)) {
Text("Merchant ID: ${merchant.merchantId}", style = MaterialTheme.typography.bodyLarge)
Text("Merchant Name: ${merchant.merchantName}", style = MaterialTheme.typography.bodyMedium)
}
}
}
8️⃣ Activity / MainScreen
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
MaterialTheme {
MerchantListScreen()
}
}
}
}
总结注释版
Entity:定义表结构
DAO:提供分页 PagingSource 与批量插入方法
Database:RoomDatabase + Hilt 提供依赖
Repository:处理分页逻辑,提供 Flow<PagingData>
ViewModel:缓存分页数据,初始化测试数据
Compose UI:使用 collectAsLazyPagingItems() 渲染 LazyColumn 分页列表
Footer:显示加载/刷新/错误状态