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:显示加载/刷新/错误状态