HarmonyOS 用 relationalStore 做阅读统计 App —— 5 张表背后的 5 个 schema 决策

读数(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 级联),所以省了。你要做类似"完成阅读 + 更新目标进度 + 送金币 + 写一条流水"这种多步、不能出半成品的业务,一定要开事务,不然任何一步失败,后面处理脏数据会很头疼。

相关推荐
LV技术派2 小时前
【成为 AI Native Builder · 02】什么是 AI Native Builder:一个人 + AI,交付完整闭环
前端·程序员·ai编程
#麻辣小龙虾#2 小时前
网页布局css中flex+margin的组合布局技巧
开发语言·前端·javascript
叼烟扛炮2 小时前
Linux 学习笔记(四十五):Web 安全基石——Cookie、Session 与 HTTPS 原理解析
linux·前端·网络·笔记·学习·tcp/ip·安全
环境栈笔记2 小时前
指纹浏览器安卓怎么用?从运行位置到功能验收,先把三种方案分清
前端·人工智能·后端·自动化
计算机魔术师3 小时前
Anthropic IPO 招股文件披露近 9000 亿美元隐性债务
前端
chi11303 小时前
【无标题】
前端·javascript·python
IT_陈寒4 小时前
JavaScript实战技巧总结:深挖数组遍历的性能陷阱与实战解法
前端·人工智能·后端
步行cgn4 小时前
为什么既要 web.xml 又要 springmvc.xml?能不能只配一个?
xml·前端·hive
风骏时光牛马4 小时前
大模型综合能力评测:多维度性能与对齐效果对比
前端