数据库与Room库
数据库与Room库:保留第四版正文机制,以所有者—状态—结果合同、事件轨迹和章专属故障完成可重放验收。
学习目标
- 能沿“数据库与Room库”的用户事件解释Android组件、状态所有者、线程与销毁边界。
- 能围绕“掌握 Room 持久化库的 Entity-DAO-Database 三层架构,能用 LiveData 和 Flow 实现响应式数据查询与更新。”改出一个可运行结果,并用前后状态而非组件数量验收。
- 能在旋转、进程重建、拒权、离线或无效输入中选择适用反例,定位首个状态分叉。
- 能用schema、DAO查询、迁移前后行数和观察者更新独立重放结论,并标明第四版机制与现代targetSdk政策的边界。
为什么不能直接裸写 SQLite?
Android 系统内置了一个叫 SQLite 的小型数据库。它能存数据、能查、能删——听起来够用了。但实际上裸写 SQLite 非常痛苦:
你写一条 SQL 语句→传一堆问号参数→拿到一个 Cursor(一个"数据库查询结果遍历器")→手动读每一列→手动类型转换→用完记得关 Cursor→等 App 被系统杀死时数据没了。
整个过程全是胶水代码,而且没有编译期检查——你把列名写错了(CrimTitle vs crimeTitle),只有等 App 跑到那行崩溃时才知道。
这就引出 Room。Room 不是重写一个数据库引擎——它只是在 SQLite 上面加了三层抽象,把你从胶水代码里解放出来。它自动把 Kotlin 数据类和 SQL 语句绑定、自动做类型检查、自动在后台线程执行、还能跟 LiveData/Flow 配合实现"数据一变,界面自动刷新"。
Room 三层架构,逐层拆开看
第一层:Entity——数据库的"一行"长什么样
↡Room 的 Entity 就是把一个 Kotlin data class 标记为数据库里的「一张表」。类的每个属性默认对应表里的一列。@PrimaryKey 标记主键列。就是数据库里的一张表在 Kotlin 世界里的"镜像"。一个 @Entity 注解的 data class
对应表里的一行——类的属性就是表的各列。
第二层:DAO——"怎么查"和"怎么改"
↡DAO(Data Access Object,数据访问对象)是一个被 @Dao 注解的接口。它定义了对数据库能做哪些操作——插入、查询、更新、删除。Room 在编译期为这个接口自动生成实现类。就是你对数据库可以做什么的"菜单"。增、删、改、查都定义在这里,Room 会在编译期自动帮你生成实现代码——你只用写接口,不用写实现。
第三层:Database——把上面两层串起来
↡RoomDatabase 是一个被 @Database 注解的抽象类——它是进入数据库的「大门」。它告诉 Room 有哪些 Entity、用哪个 DAO,并提供获取 DAO 实例的方法。通常用单例模式确保全局只有一个数据库实例。就是通往数据库的大门。它告诉 Room"我的数据库里有哪些表(entities)、有哪些 DAO"。它用单例模式——全局只有一个数据库实例,避免重复打开文件。
@Entity(数据类 ↔ 表,类 = 表、字段 = 列)、 @Dao(增删改查接口,@Query 的 SQL 编译期校验、可返回 LiveData/Flow)、@Database(持有 DAO、声明 entities 与版本号的单例大门)。App 调 DAO,Room 代你读写 SQLite;查询结果用 LiveData/Flow 包着——数据一变,界面自动刷新。猜一猜:如果在数据库已发布 App 后,你改了 Entity 的结构(比如加了一列),App 会怎样?继续猜——如果没写迁移策略又会怎样?
① Entity 定义数据表结构
创建一个 @Entity 注解的 data class,用 @PrimaryKey 标记主键列。这个类就是数据库一张表的 Kotlin 镜像——它的每个属性自动对应表的一列,Room 在编译期生成建表 SQL:CREATE TABLE Crime (...)。
代码逐段拆解
第一步:定义 Crime 为 Entity
import { Objectives } from "@/components/mdx/objectives";
import { Term } from "@/components/mdx/term";
import { RoomArchitectureDiagram } from "@/components/mdx/diagrams/room-architecture-diagram";
import { Step, Stepper } from "@/components/mdx/stepper";
import { Callout } from "@/components/mdx/callout";
import { Answer, Exercises } from "@/components/mdx/exercises";
import { Glossary, GlossaryItem } from "@/components/mdx/glossary";
import { Attribution } from "@/components/mdx/attribution";
import androidx.room.Entity
import androidx.room.PrimaryKey
import java.util.Date
import java.util.UUID
@Entity
data class Crime(
@PrimaryKey
val id: UUID = UUID.randomUUID(),
val title: String = "",
val date: Date = Date(),
val isSolved: Boolean = false,
val suspect: String = ""
)@PrimaryKey 标注 id 列是主键——Room 用它来唯一标识每一行。UUID.randomUUID() 作为默认值意味着每次 Crime() 自动生成全局唯一 ID。
第二步:写 TypeConverter(非基本类型的"翻译官")
import androidx.room.TypeConverter
import java.util.Date
import java.util.UUID
class CrimeTypeConverters {
@TypeConverter
fun fromUUID(uuid: UUID): String = uuid.toString()
@TypeConverter
fun toUUID(uuid: String): UUID = UUID.fromString(uuid)
@TypeConverter
fun fromDate(date: Date): Long = date.time
@TypeConverter
fun toDate(timestamp: Long): Date = Date(timestamp)
}Room 在写数据时调用 from 方法(对象→可存类型),读数据时调用 to 方法(可存类型→对象)。对 UUID 来说:存的时候变成 String,取的时候从 String 重建 UUID。
第三步:定义 DAO——对数据库能做哪些操作
import androidx.lifecycle.LiveData
import androidx.room.*
@Dao
interface CrimeDao {
@Query("SELECT * FROM crime ORDER BY date DESC")
fun getCrimes(): LiveData<List<Crime>>
@Query("SELECT * FROM crime WHERE id = (:id)")
fun getCrime(id: UUID): LiveData<Crime?>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun addCrime(crime: Crime)
@Update
suspend fun updateCrime(crime: Crime)
@Delete
suspend fun deleteCrime(crime: Crime)
}注意三个细节:
LiveData<List<Crime>>返回值——Room 会把查询结果包在 LiveData 里。底层数据一变,所有观察这个 LiveData 的地方自动收到新数据suspend关键字——增删改操作标记为挂起函数,Room 自动在后台线程执行,不阻塞主线程(꞉id)是命名参数占位符——比?更可读,IDE 也能自动补全
第四步:定义 Database 抽象类
import androidx.room.Database
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
@Database(entities = [Crime::class], version = 1, exportSchema = false)
@TypeConverters(CrimeTypeConverters::class)
abstract class CrimeDatabase : RoomDatabase() {
abstract fun crimeDao(): CrimeDao
companion object {
@Volatile
private var INSTANCE: CrimeDatabase? = null
fun getInstance(context: Context): CrimeDatabase =
INSTANCE ?: synchronized(this) {
INSTANCE ?: Room.databaseBuilder(
context.applicationContext,
CrimeDatabase::class.java,
"crime-database"
).build().also { INSTANCE = it }
}
}
}@Volatile + 双重检查锁 = 线程安全的单例。databaseBuilder 三个参数:上下文、Database 类、数据库文件名。
第五步:Repository 模式——把数据访问封装一层
直接在 ViewModel 里操作 DAO 会让 ViewModel 和 Room 紧耦合。加一个 Repository 中间层:
class CrimeRepository(private val crimeDao: CrimeDao) {
val crimes: LiveData<List<Crime>> = crimeDao.getCrimes()
suspend fun addCrime(crime: Crime) {
crimeDao.addCrime(crime)
}
suspend fun getCrime(id: UUID): Crime? {
return crimeDao.getCrime(id).value
}
}这样的话,ViewModel 只知道 Repository,不知道 Room 存在——将来换数据库引擎只需改 Repository。
容易踩的坑
小结
- Room 用三层封装 SQLite:Entity(表结构)→ DAO(操作接口)→ Database(大门+单例)
- DAO 返回 LiveData/Flow 实现"数据变、UI 自动变"的响应式编程
- 非基本类型必须提供 TypeConverter 告诉 Room 如何序列化/反序列化
- Repository 层隔离数据源细节,让 ViewModel 与 Room 解耦
- 修改 Entity 时必须同步升级 version 并提供 Migration——或开发期用
fallbackToDestructiveMigration()(慎用)
练习
问题 1:“第11章 Databases and the Room Library”覆盖哪些正式节点和项目主线?
问题 2:怎样建立本页最小可执行实验?
问题 3:为什么只在正常点击路径运行不能证明完成?
问题 4:怎样设计能推翻当前实现的反例?
问题 5:从第4版语境迁移到现代目标SDK时如何控制变量?
问题 6:本页达到独立交接标准需要什么?
名词解释
名词解释
本章出现的专业名词,用大白话再讲一遍。
- Entity
数据库里一张表的 Kotlin 镜像。一个
@Entity注解的 data class 对应一行数据,类属性对应列。标记后 Room 就会自动生成建表 SQL。- DAO
数据库操作菜单。一个
@Dao注解的接口,里面声明增删改查方法,Room 在编译期自动生成实现。不用手写一行胶水代码。- Database
通往数据库的大门。一个
@Database注解的抽象类,声明有哪些 Entity 和 DAO。它管理数据库文件的打开和关闭。- TypeConverter
非基本类型的"翻译官"。Room 默认只认 Int、String、Boolean。遇到 UUID、Date 等自定义类型时,TypeConverter 告诉 Room 存成什么格式、取出来怎么重建。
- Migration
数据库结构升级的"施工方案"。当你改了 Entity(加列、改类型),Room 需要一份指令告诉它怎么从旧表结构变成新表结构。
“数据库与Room库”不使用未获授权的纸书正文;InformIT出版信息与授权电子版完整目录只用于确认第四版32章、269个正式目录节点和时代语境,第四版官方勘误用于识别工具链变更。下列中文解释、图示、交互、代码与练习均为独立教学重写,平台行为再以Android Developers的一手文档复核。
为什么“数据库与Room库”必须回到可观察状态
“数据库与Room库”的学习结果不是记住类名,而是能预测“掌握 Room 持久化库的 Entity-DAO-Database 三层架构,能用 LiveData 和 Flow 实现响应式数据查询与更新。”在一次输入、一次重建和一次失败中的不同状态,并指出哪条Android合同产生差异。
第四版机制逐项深读
11. Databases and the Room Library
在“数据库与Room库”中,验证“11. Databases and the Room Library”从空库、重复键、并发写与旧schema升级四条路径核对行数、约束、通知和回滚。
Room Architecture Component Library
在“数据库与Room库”中,“Room Architecture Component Library”按事实、界面表示和用户事件拆责任;状态所有者不能持有已销毁View,界面也不能绕过边界直接改持久数据。
Creating a Database
在“数据库与Room库”中,“Creating a Database”必须导出schema并测试每条版本迁移;destructive fallback会通过启动却丢失用户事实。
Defining a Data Access Object
在“数据库与Room库”中,“Defining a Data Access Object”把Entity模式、DAO合同和SQLite文件连接成单一事实源;编译期SQL校验不能替代迁移和真实数据反例。
Accessing the Database Using the Repository Pattern
在“数据库与Room库”中,“Accessing the Database Using the Repository Pattern”的价值由依赖方向与可测试状态转换证明,而不是由类名是否含Model、View或Repository决定。
Testing Queries
在“数据库与Room库”中,“Testing Queries”把Entity模式、DAO合同和SQLite文件连接成单一事实源;编译期SQL校验不能替代迁移和真实数据反例。
Application Threads
在“数据库与Room库”中,“Application Threads”若依赖第四版时期API,应分开说明原书机制与现代平台政策,并用Android官方文档核对迁移边界。
Using LiveData
在“数据库与Room库”中,“Using LiveData”把Entity模式、DAO合同和SQLite文件连接成单一事实源;编译期SQL校验不能替代迁移和真实数据反例。
Challenge: Addressing the Schema Warning
在“数据库与Room库”中,分析“Challenge: Addressing the Schema Warning”要区分数据库实例、事务线程、观察者和View生命周期,写入完成与UI收到新快照不是同一时刻。
For the More Curious: Singletons
在“数据库与Room库”中,分析“For the More Curious: Singletons”要区分数据库实例、事务线程、观察者和View生命周期,写入完成与UI收到新快照不是同一时刻。
“数据库与Room库”验收回顾
“数据库与Room库”只有在schema、DAO查询、迁移前后行数和观察者更新能够从相同基线再次得到相同断言时才通过;第四版机制与现代平台政策分别记录,不用新API名称掩盖旧行为。
← 上一页:使用布局与部件创建用户界面 · 下一页:Fragment Navigation →
章专属可重放状态实验
先预测“插入、更新、查询、进程重启和 schema 升级”发生后,Room 数据库和 Repository应怎样改变schema、Crime 实体、查询流、事务和迁移版本;再操作三个实验。第四版示例与当前 Android 政策分别记录,实验不把新 API 名称倒填为原书内容。
实验一:所有者—状态—结果合同
选择任一正式目录节点和正常/边界场景,检查它是否真的进入本章状态合同。目录标题只有同时出现在解释、可视状态和交付证据中才算覆盖。
Owner · state · observable result
数据库与Room库:状态合同
用 Entity、DAO、RoomDatabase 与 Repository 建立 CriminalIntent 单一事实源
验证场景
第四版正式目录节点
room-database · 正常任务
11. Databases and the Room Library:固定 SDK、设备配置和初始状态,触发“插入、更新、查询、进程重启和 schema 升级”
冻结入口:11. Databases and the Room Library
记录Room 数据库和 Repository的初始schema、Crime 实体、查询流、事务和迁移版本
观察:schema 导出、DAO 测试、迁移样本、事务日志和查询断言中的“11. Databases and the Room Library”轨迹
预期:由Room 数据库和 Repository提交schema、Crime 实体、查询流、事务和迁移版本,并持续满足“持久事实由数据库拥有,界面观察结果不绕过 DAO 直接改写”
实验二:事件与生命周期轨迹
沿五次转换逐步执行“插入、更新、查询、进程重启和 schema 升级”。每一步只允许Room 数据库和 Repository按职责提交状态,并持续核对“持久事实由数据库拥有,界面观察结果不绕过 DAO 直接改写”。
Deterministic event replay
数据库与Room库:事件轨迹
不变量:持久事实由数据库拥有,界面观察结果不绕过 DAO 直接改写
交付证据:schema 导出、DAO 测试、迁移样本、事务日志和查询断言
实验三:章专属反例与同输入恢复
注入“升级 schema 后启用 destructive migration,用户案件无提示丢失”,保存第一个偏离点;撤销后以完全相同的 SDK、设备状态和用户事件重放。只有schema 导出、DAO 测试、迁移样本、事务日志和查询断言一起恢复才算修复。
Fault · cancel · restore
数据库与Room库:反例与恢复
故障:升级 schema 后启用 destructive migration,用户案件无提示丢失
第 1 次使用相同 SDK、设备配置、初始状态与用户事件
保持正常输入不变,仅注入“升级 schema 后启用 destructive migration,用户案件无提示丢失”
持久事实由数据库拥有,界面观察结果不绕过 DAO 直接改写
schema 导出、DAO 测试、迁移样本、事务日志和查询断言