文章目录
- 第 1 章 裸 SQLite 的痛点:为什么用 Room
- 原理补充
- 踩坑
- 动手练
- 第 2 章 Entity、DAO、Database:三层骨架
- 踩坑
- 动手练
- 第 3 章 suspend:一次性读写与事务
- 原理补充
- 踩坑
- 动手练
- 第 4 章 Flow:可观察查询与 UI 联动
- 原理补充
- 踩坑
- 动手练
- 第 5 章 关系、迁移与多表
- 踩坑
- 动手练
- 第 6 章 Repository 组合:网络 + 本地单一数据源
- 动手练
- 面试速查 · 追问链
- 追问链 #1:Room 和裸 SQLite 本质区别? 🔥
- 追问链 #2:suspend 和 Flow 在 DAO 里怎么选? 🔥
- 追问链 #3:@Transaction 用在哪? ⭐
- 追问链 #4:Migration 生产注意什么? ⭐
- 追问链 #5:Repository 为何要以 Room 为真相源? 💡
- 完整链路一句通
- 相关推荐
第 1 章 裸 SQLite 的痛点:为什么用 Room
手写SQLiteOpenHelper+ SQL 字符串,在 Java 工程里常见三类问题:
- 编译期无校验:列名拼错运行时才发现。
- 映射样板:
Cursor逐列getString,DTO 与表结构容易漂移。 - 观察困难:数据变了要手动
notifyDataSetChanged,与 UI 生命周期脱节。
Room 是 Jetpack 官方 ORM:编译期验证 SQL、生成DAO实现、与Kotlin 协程 / Flow一等集成。新本地库默认 Room,裸 SQLite 仅作遗留对照。
@Entity(tableName="orders")dataclassOrderEntity(@PrimaryKeyvalid:String,valtitle:String,valstatus:String,valupdatedAt:Long,)@DaointerfaceOrderDao{@Query("SELECT * FROM orders ORDER BY updatedAt DESC")funobserveAll():Flow<List<OrderEntity>>@Query("SELECT * FROM orders WHERE id = :id")suspendfungetById(id:String):OrderEntity?@Insert(onConflict=OnConflictStrategy.REPLACE)suspendfunupsertAll(orders:List<OrderEntity>)}原理补充
Room 在编译期通过注解处理器生成_Impl类;@Query在编译期绑定参数与返回类型。运行时由RoomDatabase管理连接池与InvalidationTracker。
踩坑
- 把网络 DTO 直接当
@Entity→ 领域模型与表结构耦合,应分Entity+Mapper。 - 主线程查询 → 默认禁止;用
suspend或Flow在后台调度。 - 无版本迁移直接改表 → 线上崩溃;必须
Migration或开发期fallbackToDestructiveMigration(仅 debug)。
动手练
- A:用一句话说明 Room 相对裸 SQLite 的两个编译期优势。
- D:搜索所在工程
@Database,列出version与是否定义Migration。
第 2 章 Entity、DAO、Database:三层骨架
Entity映射表;DAO声明访问;Database聚合 DAO 与版本。
@Database(entities=[OrderEntity::class],version=1,exportSchema=true,)abstractclassAppDatabase:RoomDatabase(){abstractfunorderDao():OrderDao}// 模块级单例(或由 Hilt 提供)funbuildDatabase(context:Context):AppDatabase=Room.databaseBuilder(context.applicationContext,AppDatabase::class.java,"app.db",).addMigrations(MIGRATION_1_2)// 生产必备.build()类型转换器处理 Room 不直接支持的类型:
classConverters{@TypeConverterfunfromStatus(s:OrderStatus):String=s.name@TypeConverterfuntoStatus(raw:String):OrderStatus=OrderStatus.valueOf(raw)}索引与外键在 Entity 上声明,查询计划可预测:
@Entity(tableName="orders",indices=[Index(value=["status"])],)dataclassOrderEntity(/* ... */)踩坑
data class主构造属性才映射列;类体var默认不参与。@PrimaryKey(autoGenerate = true)与Stringid 策略要想清楚,避免与服务端 id 冲突。exportSchema = false省事但团队难审查表结构变更。
动手练
- B(起点):为
BookmarkEntity(id, url, title)写@Entity+ 带OnConflictStrategy.REPLACE的@Insert(≤12 行)。
第 3 章 suspend:一次性读写与事务
「拉一次、写一次」用suspend,Room 自动在后台线程执行(勿在主线程调)。
@DaointerfaceOrderDao{@TransactionsuspendfunreplaceAll(orders:List<OrderEntity>){clearAll()upsertAll(orders)}@Query("DELETE FROM orders")suspendfunclearAll()@Query("SELECT COUNT(*) FROM orders WHERE status = :status")suspendfuncountByStatus(status:String):Int}@Transaction保证多步操作原子性;挂起函数里可调用同 DAO 其它suspend方法。
Repository 网络刷新后写库:
classOrderRepository(privatevalapi:OrderApi,privatevaldao:OrderDao,privatevalmapper:OrderMapper,){suspendfunrefreshOrders(){valdtos=api.fetchOrders()valentities=dtos.map(mapper::toEntity)dao.upsertAll(entities)}}原理补充
Room 2.x 的suspend由CoroutineRoom包装,在RoomDatabase配置的Executor或协程上下文执行。@Transaction方法必须是suspend或同步,且同线程嵌套事务。
踩坑
- 在
@Transaction里开新协程 → 事务边界被打断。 suspend fun里调非 suspend 的阻塞 IO 未切线程 → 仍可能卡默认调度器。- 忘记
OnConflictStrategy→ 主键冲突插入失败。
动手练
- C:写
refreshAndCount():upsertAll后return countByStatus("OPEN")(@Transaction)。 - A:
@Transaction解决什么问题?一句话。
第 4 章 Flow:可观察查询与 UI 联动
列表、详情等「数据变 UI 自动变」用Flow。Room 在表变更时通过InvalidationTracker重新执行查询并发射新列表。
@DaointerfaceOrderDao{@Query("SELECT * FROM orders WHERE status = :status ORDER BY updatedAt DESC")funobserveByStatus(status:String):Flow<List<OrderEntity>>}ViewModel 收集并映射为 UiState:
classOrderListViewModel(privatevalrepository:OrderRepository,):ViewModel(){privateval_filter=MutableStateFlow(OrderStatus.ALL)valuiState:StateFlow<OrderListUiState>=_filter.flatMapLatest{status->repository.observeOrders(status).map{orders->if(orders.isEmpty())OrderListUiState.EmptyelseOrderListUiState.Success(orders)}}.stateIn(scope=viewModelScope,started=SharingStarted.WhileSubscribed(5_000),initialValue=OrderListUiState.Loading,)funonFilterChanged(status:OrderStatus){_filter.value=status}}flatMapLatest在筛选切换时取消旧订阅,避免竞态覆盖。stateIn把冷流提升为热StateFlow供 UI 收集。
原理补充
Flow查询是冷流:每个收集者触发查询;InvalidationTracker监听表 invalidation 后emit。多表 JOIN 时任一表变都会刷新。distinctUntilChanged可在map后去重。
踩坑
- 在 DAO 返回
Flow却期望「只查一次」→ 应用suspend。 - 忘记
stateIn/shareIn导致每次collect重复订阅(视场景而定)。 Flow映射很重时不加flowOn(Dispatchers.Default)→ 主线程压力。
动手练
- B(起点):下面在主线程
collect冷 Flow 写库,指出问题并改 Repository 层:
funbroken()=dao.observeAll()// 直接暴露 DAO第 5 章 关系、迁移与多表
一对一 / 一对多用@Relation+@Embedded,或显式 JOIN:
dataclassOrderWithLines(@Embeddedvalorder:OrderEntity,@Relation(parentColumn="id",entityColumn="orderId")vallines:List<LineEntity>,)@Query("SELECT * FROM orders WHERE id = :id")suspendfungetOrderWithLines(id:String):OrderWithLines?迁移生产必做:
valMIGRATION_1_2=object:Migration(1,2){overridefunmigrate(db:SupportSQLiteDatabase){db.execSQL("ALTER TABLE orders ADD COLUMN note TEXT NOT NULL DEFAULT ''")}}开发可fallbackToDestructiveMigration(dropAllTables = true),禁止对线上用户默认开启。
踩坑
@Relation查询 N+1 性能问题 → 大列表改 JOIN 或分页(Paging 篇)。- 迁移 SQL 写错无单元测试覆盖 → 用
MigrationTestHelper。 - 多模块共用一个
AppDatabase→ 注意 DAO 暴露边界与模块依赖方向。
动手练
- A:
exportSchema = true对 Code Review 有何帮助? - D:为现有表设计
1→2加列迁移 SQL 一行。
第 6 章 Repository 组合:网络 + 本地单一数据源
现代模式:Room 为 UI 真相源;网络刷新写库;UI 只观察 Room 的Flow。
classOrderRepository(privatevalapi:OrderApi,privatevaldao:OrderDao,privatevalmapper:OrderMapper,){funobserveOrders(status:OrderStatus):Flow<List<Order>>=dao.observeByStatus(status.name).map{entities->entities.map(mapper::toDomain)}suspendfunrefresh(status:OrderStatus){valremote=api.fetchOrders(status.apiParam)dao.upsertAll(remote.map(mapper::toEntity))}}离线优先:先展示本地Flow,并行refresh();失败时保留旧数据并暴露错误态(ViewModel 合并网络错误与本地流)。
与 Paging 3 结合时,Room 作PagingSource或RemoteMediator的缓存层(见 Paging 篇)。
| 反模式 | 改法 |
|---|---|
| UI 直接调 DAO | 经 Repository |
| 网络成功才写内存列表 | 写 Room + observe Flow |
全表SELECT *无分页 | Paging / LIMIT |
动手练
- C:实现
observeOrders+refresh完整 Repository(≤20 行)。 - D 验收:断网打开列表应显示上次缓存;恢复网络下拉刷新后 Flow 自动更新。
面试速查 · 追问链
追问链 #1:Room 和裸 SQLite 本质区别? 🔥
标准回答(≤200 字):Room 编译期验证 SQL 与返回类型,注解生成实现,减少 Cursor 样板。与协程suspend、KotlinFlow集成,自带线程调度约束。迁移、关系、FTS 有官方模式。新工程默认 Room;裸 SQLite 仅遗留或极特殊场景。
追问 1:SQL 写错何时发现?
答:编译期@Query校验;运行时 migration 错误需测试兜底。
追问 2:主线程能查 Room 吗?
答:默认不能;suspend/Flow在后台执行,UI 用 ViewModel 收集。
追问链 #2:suspend 和 Flow 在 DAO 里怎么选? 🔥
标准回答(≤200 字):一次性读/写用suspend;需要数据变就推送 UI 用Flow。列表页、购物车数量等「观察」场景用Flow。单次拉详情、事务批量写用suspend。不要对「只查一次」误用Flow。
追问 1:Flow查询如何感知表变化?
答:InvalidationTracker监听表 invalidation,自动 re-query emit。
追问 2:多个收集者会怎样?
答:冷流各自订阅;应用层常用stateIn/shareIn转热流。
追问链 #3:@Transaction 用在哪? ⭐
标准回答(≤200 字):多步写操作要原子性时用:先删后插、多表联动。@Transaction方法内调同 DAO 其它方法在同一数据库事务。避免在事务里再launch新协程。
追问 1:和 SQLite 手动beginTransaction关系?
答:Room 生成的代码封装了 begin/setTransactionSuccessful/end。
追问链 #4:Migration 生产注意什么? ⭐
标准回答(≤200 字):升version必须提供Migration或破坏性降级策略(仅开发)。exportSchema存档备查。测试用MigrationTestHelper跑真 SQL。加列注意DEFAULT兼容旧行。
追问 1:能否线上fallbackToDestructiveMigration?
答:等于清用户数据,一般禁止;仅 debug 或可丢数据场景。
追问链 #5:Repository 为何要以 Room 为真相源? 💡
标准回答(≤200 字):UI 观察本地Flow,旋转/进程恢复后仍能展示;网络刷新写库,自动推送。离线可用、逻辑单一。网络层不直接喂 Adapter,避免双份状态不同步。
追问 1:与 Paging 如何配合?
答:PagingSource读 Room;RemoteMediator网络分页写库。
完整链路一句通
Entity/DAO/Database 建模→suspend 写、Flow 观察→Repository 网络刷新 upsert→ViewModelflatMapLatest+stateIn→迁移与@Transaction保一致。
相关推荐
Kotlin 语法与空安全:Android 开发第一课
Kotlin 作用域函数:let/apply 工程选型