---
title: "HarmonyOS 用 relationalStore 做阅读统计 App —— 5 张表背后的 5 个 schema 决策 - Angryecho"
description: "读数（ReadStats）是一个鸿蒙阅读统计 App，核心数据层用 relationalStore 建了 5 张表：books / reading_sessions / goals / goal_books / highlights。"
canonical_url: "https://www.angryecho.com/c/qian_duan_she_qu/p/P2TBC40fpGPzQMEsYQ4dM"
meta:
  "og:description": "读数（ReadStats）是一个鸿蒙阅读统计 App，核心数据层用 relationalStore 建了 5 张表：books / reading_sessions / goals / goal_books / highlights。"
  "og:image": "https://www.angryecho.com/_og/d/c_Post%2Ctitle_~5ZCQ5qe9572R%2Cdescription_~5ZCQ5qe95LiA5LiL77yM5bCx5LiK5ZCQ5qe9572R44CC%2CpostId_P2TBC40fpGPzQMEsYQ4dM%2Crevision_~MjAyNi0xMS0wM1QwNzowMDozNS44Nzc0MDRa%2Cp_Ii9jL3FpYW5fZHVhbl9zaGVfcXUvcC9QMlRCQzQwZnBHUHpRTUVzWVE0ZE0i%2Cs_0Lz7YK8JPrfl7tew.png"
  "og:image:height": "630"
  "og:image:type": image/png
  "og:image:width": "1200"
  "og:title": "HarmonyOS 用 relationalStore 做阅读统计 App —— 5 张表背后的 5 个 schema 决策"
---

> Index of every page on this site: [llms.txt](https://www.angryecho.com/llms.txt)

# **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` 加版本号迁移：

```
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 掉：

```
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 加级联：

```
-- 没这么写
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：

```
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 列，谁都能连：

```
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：

```
-- 第一版想法
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：

```
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：

```
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()
  }
}
```

读数里写的是：

```
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：

```
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：

```
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：

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

排序方式 :

### **成为第一条评论！**

Nobody's responded to this post yet. Add your thoughts and get the conversation going.

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

## Sitemap

See the full [sitemap](https://www.angryecho.com/sitemap.md) for all pages.
