读数(ReadStats)是一个鸿蒙阅读统计 App,核心数据层用 relationalStore 建了 5 张表:books / reading_sessions / goals / goal_books / highlights。真跑到线上以后,schema 怎么演进、关联表要不要上外键、count 查询怎么写、异常怎么兜底,这些事情官方 helloworld 都不会提,得自己定。下面是读数里绕不开的 5 个 schema 决策,记录一下,顺便分享给有需要的朋友。
一、schema 怎么演进不让老用户崩
App 发版加字段这种事,不处理老用户的本地库 schema,新代码一读新列就抛 no such column 直接页面空白。两种做法。
规范一点的做法是用 store.version 加版本号迁移:
typescript
import { relationalStore } from '@kit.ArkData'
const store = await relationalStore.getRdbStore(context, config)
if (store.version < 2) {
await store.executeSql(`ALTER TABLE reading_sessions ADD COLUMN pages_before INTEGER`)
await store.executeSql(`ALTER TABLE reading_sessions ADD COLUMN pages_after INTEGER`)
store.version = 2
}
if (store.version < 3) { /* v2 → v3 的迁移 */ }
读数没用这个。读数所有建表都是 CREATE TABLE IF NOT EXISTS,加字段用 ALTER TABLE ADD COLUMN 把"duplicate column"的错 catch 掉:
typescript
private async createTables(rdbStore: relationalStore.RdbStore): Promise<void> {
const createSessions = `CREATE TABLE IF NOT EXISTS reading_sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
book_id INTEGER,
start_time INTEGER,
end_time INTEGER,
duration_seconds INTEGER,
pages_before INTEGER,
pages_after INTEGER,
pace REAL
)`
try {
await rdbStore.executeSql(createSessions)
} catch (_e) {
// table creation error 吞掉
}
const alters = [
`ALTER TABLE reading_sessions ADD COLUMN pages_before INTEGER`,
`ALTER TABLE reading_sessions ADD COLUMN pages_after INTEGER`,
]
for (const sql of alters) {
try {
await rdbStore.executeSql(sql)
} catch (_e) {
// duplicate column 吞掉
}
}
}
每次冷启动跑一遍:老库补上新列、新库 catch 掉 duplicate column,相当于每次把 schema 对齐到最新。省掉了维护一整套 switch (oldVersion) 分支,所有建表和改表的 SQL 都在这里,看一眼全。
能这么写的前提是读数到目前为止的变更全是加列:没改过列类型、没删过列。SQLite 的 ALTER TABLE 不支持 ALTER COLUMN TYPE,要改列类型得走"CREATE 新表 → INSERT SELECT → DROP 旧表 → RENAME"的套路,必须按版本号严格走。删列、加 UNIQUE/CHECK 约束同理。这三种出现的那天 version + onUpgrade 跑不掉,但读数现在还没遇到,这条幂等重跑的路还能继续走一阵。
二、goal_books 要不要用 FOREIGN KEY
读数里 goals 和 books 是多对多:一本书可以同时在"年度 50 本"和"10 月份 3 本"两个目标里。关联表 goal_books 的字段怎么写。
教科书写法是 FK 加级联:
sql
-- 没这么写
CREATE TABLE goal_books (
id INTEGER PRIMARY KEY AUTOINCREMENT,
goal_id INTEGER NOT NULL REFERENCES goals(id) ON DELETE CASCADE,
book_id INTEGER NOT NULL REFERENCES books(id) ON DELETE CASCADE,
completed INTEGER DEFAULT 0
);
读数实际写的是普通 INTEGER,没有一个 FOREIGN KEY:
sql
CREATE TABLE IF NOT EXISTS goal_books (
id INTEGER PRIMARY KEY AUTOINCREMENT,
goal_id INTEGER,
book_id INTEGER,
completed INTEGER DEFAULT 0
)
两点让我没走 FK。
一点是 relationalStore 对 FK 的开启方式有点别扭。SQLite 默认 PRAGMA foreign_keys = OFF,必须每个连接显式打开,查了文档也没找到"全局打开"的简单办法,等于每次拿到 store 都要先 executeSql('PRAGMA foreign_keys = ON'),少打一次 FK 就等于没有。
另一点是业务上要的级联规则其实并不是统一的 CASCADE:
- 删 goal 的时候,goal_books 里相关行一起删 ------ 合理
- 删 book 的时候,goal_books 相关行一起删,年度"已读 23 本"的统计数字跟着掉 ------ 不合理
要保留"书可以删,但统计数字不丢"的能力,就得给 books 加 is_deleted 列做软删除,比在业务层写一行 goalBookDao.deleteByBookId(bookId) 麻烦很多。
最后用业务层处理级联。goal_books 退化成两个 INTEGER 列,谁都能连:
typescript
async delete(bookId: number): Promise<number> {
const predicates = new relationalStore.RdbPredicates('books').equalTo('id', bookId)
const affected = await this.getStore().delete(predicates)
// goal_books 不动,书的历史和统计数字都保留
return affected
}
这个选择不是"更好",是"更贴业务"。什么时候会换回 FK:当孤儿行真的会让业务崩的时候(比如孤儿行会被查询到、还引用了一个已经不存在的书)。读数当前所有查询都是按 id 直接查,没有 JOIN,拿不到就当没有,不会崩。
三、金句存 book_title 还是 book_id
读数的 highlights 表存用户摘录的句子,一条金句挂在某本书下。关键字段是存 book_id INTEGER(关联到 books.id)还是 book_title TEXT(冗余)。
我最早想的是 book_id:
sql
-- 第一版想法
CREATE TABLE highlights (
id INTEGER PRIMARY KEY AUTOINCREMENT,
book_id INTEGER NOT NULL,
quote TEXT,
page_num INTEGER,
created_at INTEGER
)
然后在想一个场景:用户读完某本书、过两个月觉得不想要这本书的记录了,把它从书房删了。他攒下来的几条金句怎么办?
想到这里就把 book_id 换成了 book_title TEXT:
sql
CREATE TABLE IF NOT EXISTS highlights (
id INTEGER PRIMARY KEY AUTOINCREMENT,
book_title TEXT,
quote TEXT,
page_num INTEGER DEFAULT 0,
image_uri TEXT,
created_at INTEGER
)
这个决策有代价。书名改了,老金句的"出处"就对不上;两本同名书也没法区分。
代价我接受,因为读数里金句的定位是"这句话打动我",书是载体不是本体。书删了金句还在,算是对用户的一点体贴。两本同名书极罕见、改书名的场景也很少,这些边界情况的代价远小于"书删了金句要么变孤儿、要么跟着没"带来的困惑。
技术上这个选择还顺带让 highlights 变成了独立表 ------ 可以单独迁移、单独导出、单独做备份策略。goals 和 reading_sessions 都和 books 强耦合,只有 highlights 独立。
如果你的"附属内容"是书的注释、复习记录这类必须跟书一起归档的东西(删书等于归档所有相关内容),存 book_id 更合理,这个决策不要照抄。
四、count 查询用 SQL COUNT 还是 arr.length
说点难为情的。读数所有的"在读 N 本"、"共有 K 条金句"、"目标完成了几本",全部是 JS 侧 arr.length,没有一个走 SQL COUNT(*)。
规范做法是走 SQL:
typescript
async getCountByStatus(status: string): Promise<number> {
const sql = `SELECT COUNT(*) AS cnt FROM books WHERE status = ?`
const resultSet = await this.getStore().querySql(sql, [status])
try {
if (resultSet.goToNextRow()) {
return resultSet.getLong(resultSet.getColumnIndex('cnt'))
}
return 0
} finally {
resultSet.close()
}
}
读数里写的是:
typescript
async getCountByStatus(status: string): Promise<number> {
const books = await this.queryByStatus(status)
return books.length
}
async getCompletedCount(goalId: number): Promise<number> {
const goalBooks = await this.getGoalBooks(goalId)
return goalBooks.filter(gb => gb.completed === 1).length
}
把所有行取出来、全部 parse 成 BookModel 对象、再数长度。相比 SQL COUNT,这里多了 N 次字段解析、N 次对象实例化、一次 ArrayList 分配。数据量 1000+ 条时差距明显,数据量 500 以内感觉不到。
写成这样就是"先能跑再说"。当时的理由是 books 表最多几百行,差距我感觉不出来。上线一段时间以后,读数用户数据集最大的 books 几百条、reading_sessions 几千条,查询都还在毫秒级,但只要有一个用户的 highlights 慢慢攒到上千条,这几句 .length 就必须换。
承认是工程债,不是最优。写的时候我在 count 方法上打了 TODO,等真实数据量压上来再改。换的时机也简单:单表行数 >1000、或者单个页面要显示 ≥3 个 count、或者出现 SUM 这种 JS 侧累加比 SQL 慢很多的查询,就立刻切 SQL COUNT。
五、ResultSet 异常怎么处理
从 relationalStore 的 ResultSet 读行时,getString / getLong / getColumnIndex 都可能抛异常(列不存在、null 值、类型不匹配)。这个异常传不传给调用方。
一种做法是抛出,让 UI 层 try-catch:
typescript
async queryAll(): Promise<BookModel[]> {
const resultSet = await this.getStore().query(...)
const books: BookModel[] = []
while (resultSet.goToNextRow()) {
const book = new BookModel()
book.id = resultSet.getLong(resultSet.getColumnIndex('id'))
book.title = resultSet.getString(resultSet.getColumnIndex('title'))
books.push(book)
}
resultSet.close()
return books
}
读数里全部 DAO 走的是另一条路:吞掉异常、返回空数组,finally 里保证 close:
typescript
private parseResultSet(resultSet: relationalStore.ResultSet): BookModel[] {
const books: BookModel[] = []
try {
while (resultSet.goToNextRow()) {
const book = new BookModel()
book.id = resultSet.getLong(resultSet.getColumnIndex('id'))
book.title = resultSet.getString(resultSet.getColumnIndex('title'))
book.author = this.optionalString(resultSet, 'author')
book.coverUri = this.optionalString(resultSet, 'cover_uri')
book.currentPage = resultSet.getLong(resultSet.getColumnIndex('current_page'))
book.totalPages = this.optionalNumber(resultSet, 'total_pages')
book.status = resultSet.getString(resultSet.getColumnIndex('status'))
books.push(book)
}
} catch (_e) {
// parse error 吞掉
} finally {
resultSet.close()
}
return books
}
空字段的处理用两个 helper:
typescript
private optionalString(resultSet: relationalStore.ResultSet, column: string): string | undefined {
try {
const index = resultSet.getColumnIndex(column)
if (resultSet.isColumnNull(index)) {
return undefined
}
const value = resultSet.getString(index)
return value.length ? value : undefined
} catch (_e) {
return undefined
}
}
private optionalNumber(resultSet: relationalStore.ResultSet, column: string): number | undefined {
try {
const index = resultSet.getColumnIndex(column)
if (resultSet.isColumnNull(index)) {
return undefined
}
return Number(resultSet.getLong(index))
} catch (_e) {
return undefined
}
}
两个收益。
一个是 UI 层的空态处理和"查完没有"合并成同一分支。拿到的永远是 BookModel[],books.length === 0 既代表"没数据"也代表"解析失败",UI 只用一条空态渲染路径。
另一个是空字符串和 null 统一归 undefined。业务模型里 author?: string 的语义是"没录作者"或"录了个空字符串",数据库 TEXT 字段分不开这两种,统一按前者处理,UI 侧 author ?? '佚名' 简单兜底。
代价是真实解析错误被静默吞掉,调试时感知不到。catch 块现在是空的,正式产品里建议补一行 hilog.error(DOMAIN, 'parse error', e.message),至少日志能追溯。读数这块的日志我还没补,又一个 TODO。
踩坑清单
- 加列用
ALTER TABLE ADD COLUMN+ 吞 duplicate column 错,别写store.version直到真的要改列类型或删列 - 关联表要不要 FOREIGN KEY,先想清楚"删主表"是不是真要级联
- 附属内容(金句 / 评论)考虑用 TEXT 存冗余字段换"主记录删了附属还在"的能力,但要承担书名改了出处对不上的代价
arr.length当 COUNT 用前期够用,但要立 flag:单表 >1000 行或者单页 ≥3 个 count 时切 SQL COUNT- ResultSet 吞错返回空数组省心,catch 里补 hilog 做审计
尾声
这 5 张表、4 个 DAO 跑了一段时间,没出过数据相关的用户反馈。最大的痛不是哪一次 schema 改错,是每隔几周把 DbHelper.ets 翻出来重新看,总能发现某个决策当初是在赌。有些赌已经验证划算,有些还悬在半空,谁也不知道哪天会被证伪。
上面 5 个决策的代码片段全部来自读数的 entry/src/main/ets/database/ 目录,场景类似的直接抄或改一改都 OK。
有一个 API 我没用上但推荐你看看:RdbStore.beginTransaction / commit / rollBack。读数当前没有跨表原子操作的场景(删书时 goal_books 用业务代码控制而非 FK 级联),所以省了。你要做类似"完成阅读 + 更新目标进度 + 送金币 + 写一条流水"这种多步、不能出半成品的业务,一定要开事务,不然任何一步失败,后面处理脏数据会很头疼。