当前位置:首页>鸿蒙APP>跟着 cc 一起学鸿蒙开发 24 — 本地数据库:关系型数据库 RelationalStore

跟着 cc 一起学鸿蒙开发 24 — 本地数据库:关系型数据库 RelationalStore

  • 2026-10-11 06:55:46
跟着 cc 一起学鸿蒙开发 24 — 本地数据库:关系型数据库 RelationalStore
哈喽大家好,我是 cc。

第 22 篇我们学会了用 Preferences 把「少量配置」存住(昵称、开关、是否首次打开)。但 Preferences 本质是 key-value 文件,它有三个硬伤:

  • 不能做条件查询("查所有支出大于 100 的记录"做不到);
  • 不能多表关联("按分类统计每月花费"做不到);
  • 存的是扁平字符串,结构一复杂就 JSON 满天飞。

当你要管的是一堆有结构、要筛选、要排序的数据时,就该请出今天的主角:关系型数据库 RelationalStore(底层基于 SQLite)。为了好懂,咱们全程用一个最贴生活的例子——个人记账本来讲。

系列约定:本篇统一对标 API 22。


一、什么时候该上关系型数据库

先给个速查表,别一上来就埋数据库:

场景
用 Preferences
用 RelationalStore
用户昵称、深色模式开关
✅
❌ 杀鸡用牛刀
单条 JSON 配置
✅(先 stringify)
❌
记账 / 订单 / 聊天记录
❌
✅ 要筛选、排序
按分类统计、按月汇总
❌
✅ 谓词 + 聚合
多表关联(用户↔订单)
❌
✅ 多表 JOIN

一句话记忆:少量配置用 Preferences,结构化业务数据用 RelationalStore。

下面这张图把「内存 ↔ 落盘」和 Preferences 对照一下,后面代码全围绕它转:

        你的 App (ArkUI 组件)              │    getContext(this) 拿上下文              ▼        RdbStore 实例 (SQLite 封装)         name = 'bill.db'              │   ┌──────────┼───────────────┐ insert()   query()        executeSql()   │           │               │   ▼           ▼               ▼ 写一行    谓词查结果集     直接跑 SQL(建表/索引)   │           │   ▼           ▼ App 私有沙箱数据库文件 (持久化) /data/storage/el2/.../database/<bundle>/rdb/bill.db

⚠️ 注:和 Preferences 一样,RelationalStore 存的是 App 私有目录,不需要申请任何权限。


二、建库与建表

核心就两步:拿 RdbStore、用 SQL 建表。

import { relationalStore } from'@kit.ArkData';import { common } from'@kit.AbilityKit';const DB_NAME: string = 'bill.db';const TABLE_NAME: string = 'bill';asyncfunctioninitDb(context: common.Context): Promise<relationalStore.RdbStore> {// StoreConfig: name 必填;API 12+ 起 securityLevel 也必填(S1 最低 ~ S4 最高)const config: relationalStore.StoreConfig = {    name: DB_NAME,    securityLevel: relationalStore.SecurityLevel.S2  };const store = await relationalStore.getRdbStore(context, config);// 建表:IF NOT EXISTS 保证只建一次;自增主键 + 普通列 + 类型约束await store.executeSql(`CREATE TABLE IF NOT EXISTS ${TABLE_NAME} (      id INTEGER PRIMARY KEY AUTOINCREMENT,      type INTEGER,      category TEXT,      amount REAL,      remark TEXT,      createTime INTEGER    )`  );return store;}

几个点说清楚:

  • 导入:import { relationalStore } from '@kit.ArkData'(API 22 推荐 kit 形式;经典 from '@ohos.data.relationalStore' 同样可用)。
  • securityLevel 必填:S1(低)~S4(高),按数据敏感度选。漏了这行,编译或运行直接报错——这是新手第一坑。
  • executeSql 建表:RelationalStore 基于 SQLite,所以直接写 SQL 就行。咱们这张 bill 表存:类型(0 支出 / 1 收入)、分类、金额、备注、时间戳。
  • 自增主键:INTEGER PRIMARY KEY AUTOINCREMENT,插入时不用管 id,数据库自己给。

建库 → 建表 的时序:

getRdbStore(config)  ──►  拿到 RdbStore        │        ▼executeSql("CREATE TABLE IF NOT EXISTS bill (...)")  ──►  表就绪        │        ▼insert / query / update / delete  ←── 业务随意调用

三、增:insert / batchInsert / 事务

单条插入用 ValuesBucket(一个「列名 → 值」的对象)配合 insert:

// ValuesBucket must be typed; declare it explicitly to avoid ArkTS untyped-literal errorconst valueBucket: relationalStore.ValuesBucket = {'type': 0,'category': '餐饮','amount': 38.5,'remark': '午饭','createTime': Date.now()};const rowId: number = await store.insert('bill', valueBucket); // 返回新行 id

批量插入用 batchInsert,比循环 insert 快:

const rows: relationalStore.ValuesBucket[] = [  { 'category': '交通', 'amount': 12, 'type': 0, 'remark': '', 'createTime': Date.now() },  { 'category': '工资', 'amount': 8000, 'type': 1, 'remark': '', 'createTime': Date.now() }];await store.batchInsert('bill', rows);

事务:多个写操作要么全成、要么全滚,用 beginTransaction / commit / rollBack 包起来:

try {await store.beginTransaction();await store.insert('bill', bucketA);await store.insert('bill', bucketB);await store.commit();      // 全部成功才提交} catch (e) {await store.rollBack();    // 中途出错 → 回滚,数据库回到事务前状态}

⚠️ 事务不支持多进程 / 多线程(和 Preferences 一样非线程安全);而且第 23 篇讲的 taskpool 并发场景里,RelationalStore 不能在 Worker 线程用——要做后台算账,请在主线程算完再落库。


四、查(上):RdbPredicates 谓词

RelationalStore 不推荐手写 WHERE 字符串,而是用 RdbPredicates 这种「条件构造器」来拼查询条件,安全又不易拼错 SQL。它能干什么:

方法
含义
例子
equalTo(f, v)
等于
equalTo('type', 0)
notEqualTo(f, v)
不等于
notEqualTo('type', 1)
greaterThan(f, v)
大于
greaterThan('amount', 100)
lessThan(f, v)
小于
lessThan('amount', 50)
like(f, pat)
模糊(含 %)
like('category', '%餐%')
between(f, lo, hi)
区间(含边界)
between('amount', 0, 100)
and()
 / or()
与 / 或
组合条件
beginWrap()
 / endWrap()
加括号分组
优先级控制
const p = new relationalStore.RdbPredicates('bill');// 支出 且 金额 > 100:用 .and() 链接p.equalTo('type', 0).and().greaterThan('amount', 100);// 或:分类含「餐」 或 金额 > 500// p.like('category', '%餐%').or().greaterThan('amount', 500);

RdbPredicates 不是线程安全的,别在多线程里共享同一个实例。


五、查(下):ResultSet 遍历、排序与分页

query(predicates, columns?) 返回 ResultSet(游标),要手动遍历,而且必须 close():

const p = new relationalStore.RdbPredicates('bill');p.orderByDesc('createTime').limitAs(20).offsetAs(0); // 排序 + 分页(取前20条)const rs = await store.query(p, ['id', 'type', 'category', 'amount']);const list: Bill[] = [];if (rs.rowCount > 0) {  rs.goToFirstRow();                       // 游标移到第一行do {    list.push({      id: rs.getLong(rs.getColumnIndex('id')),type: rs.getLong(rs.getColumnIndex('type')),      category: rs.getString(rs.getColumnIndex('category')),      amount: rs.getDouble(rs.getColumnIndex('amount')),      remark: '',      createTime: 0    });  } while (rs.goToNextRow());              // 移到下一行,到底返回 false}rs.close();                             // ⚠️ 必须关,否则泄露原生资源

要点:

  • 类型读取要配对:INTEGER 用 getLong、REAL 用 getDouble、TEXT 用 getString。
  • goToFirstRow() + do…while(goToNextRow()) 是标准遍历姿势;空结果集 rowCount === 0 时别进循环。
  • 排序:orderByAsc('x') / orderByDesc('x')。
  • 分页:limitAs(n) 限制条数,offsetAs(m) 跳过前 m 条,两者配合做翻页。
  • ⚠️ ResultSet.close() 必写——这是 RelationalStore 最高频的资源泄漏源,漏了在 Profiler(第 23 篇)里能看到句柄一直涨。

六、改与删

改和删都用「ValuesBucket / 谓词」定位:

// 改:把 id=3 的金额改成 88const updateBucket: relationalStore.ValuesBucket = { 'amount': 88 };const updPred = new relationalStore.RdbPredicates('bill');updPred.equalTo('id', 3);await store.update(updateBucket, updPred); // 返回受影响行数(注意:表名只通过 updPred 传入,不在这里传)// 删:删掉分类是「交通」的所有记录const delPred = new relationalStore.RdbPredicates('bill');delPred.equalTo('category', '交通');await store.delete(delPred); // 返回删除行数(表名只通过 delPred 传入)

注意:update / delete 的第二个参数位置不同(一个传 ValuesBucket,一个传 predicates),别写反。


七、实战:个人记账本

把上面全部串起来,做成三个可跑 demo(工程里 pages/ch24/ 下):

pages/ch24/ ├─ Index.ets        章首页:三个 demo 入口 ├─ StoreHelper.ets  RdbStore 初始化 + 通用 CRUD 封装(教学"别每页重复 init") ├─ BillListDemo.ets 列表 + 按分类筛选 + 删除 + 跳编辑 ├─ BillEditDemo.ets 新增 / 编辑一笔(router 传 id) └─ PredicateDemo.ets 谓词专项 playground(§四/§五对照)

StoreHelper 封装思路:把 init + insertBill / updateBill / deleteBill / queryBills 收进一个类,demo 页只管 UI。关键查询方法(已含 close()):

async queryBills(predicates?: relationalStore.RdbPredicates): Promise<Bill[]> {const store = this.store;if (!store) { return []; }const p = predicates ?? new relationalStore.RdbPredicates(TABLE_NAME);const resultSet = await store.query(p, ['id', 'type', 'category', 'amount', 'remark', 'createTime']);const list: Bill[] = [];if (resultSet.rowCount > 0) {    resultSet.goToFirstRow();do {      list.push({        id: resultSet.getLong(resultSet.getColumnIndex('id')),type: resultSet.getLong(resultSet.getColumnIndex('type')),        category: resultSet.getString(resultSet.getColumnIndex('category')),        amount: resultSet.getDouble(resultSet.getColumnIndex('amount')),        remark: resultSet.getString(resultSet.getColumnIndex('remark')),        createTime: resultSet.getLong(resultSet.getColumnIndex('createTime'))      });    } while (resultSet.goToNextRow());  }  resultSet.close(); // MUST closereturn list;}

列表页筛选(实时按分类模糊搜 + 只看支出开关):

const p = new relationalStore.RdbPredicates(TABLE_NAME);p.orderByDesc('createTime');if (this.keyword.trim().length > 0) {  p.like('category', '%' + this.keyword + '%');}if (this.onlyExpense) {  p.equalTo('type', 0);}this.bills = awaitthis.helper.queryBills(p);

编辑页传参(点「改」把 id 带给编辑页):

// 列表页:用具名接口 EditParams 传参(EditParams 与 Bill 一起在 StoreHelper 里 export;// 注意 Record<string, Object> 直接赋值也触发 arkts-no-untyped-obj-literals,故用显式接口)let params: EditParams = { billId: bill.id! };router.pushUrl({ url: 'pages/ch24/BillEditDemo', params: params } as router.RouterOptions);// 编辑页 aboutToAppear:取参 → 查库 → 回填表单const params = router.getParams() as Record<string, Object> | undefined;if (params && params['billId'] !== undefined) {this.loadBill(params['billId'] asnumber);}

跑起来后:记一笔(支出/收入 + 分类 + 金额 + 备注)→ 列表按分类筛选 → 点「改」回填再保存 → 点「删」移除。一个能落盘、杀进程重开数据还在的记账本就成了。

谓词到底怎么拼,直接跑 PredicateDemo.ets:equalTo / greaterThan / like / between / or / 分页 六个按钮,点一下看结果,比看文档直观。


八、踩坑提醒 & 性能注意

cc 把实战里最容易翻车的地方列出来了,编译或调试时对照看:

  1. securityLevel 必填:StoreConfig 在 API 12+ 起必须带 securityLevel,漏写直接报错。按敏感度选 S1~S4。
  2. ResultSet 必须 close():遍历完一定 close(),否则原生资源泄漏,用第 23 篇 Profiler 能看出句柄持续增长。
  3. RelationalStore 不能在 Worker 线程用:第 23 篇的 taskpool 并发场景碰不了数据库;要后台算账就主线程算完再落库。
  4. ValuesBucket 是具名类型:声明 let v: relationalStore.ValuesBucket = {...};别写成 let v: object = {...},会触发 arkts-no-untyped-obj-literals。
  5. 单条数据 < 2MB:超了插入成功、读取失败;大文本/大图请拆表或走文件。
  6. struct 名全工程唯一:ArkTS 把整个 module 一起编译,新 struct 名别和 ch01~ch23 重复(否则 Duplicate identifier)。
  7. 表结构升级自己管:RelationalStore 没有 Room 那种自动迁移。加列/改表要用 executeSql("ALTER TABLE ...") 自己处理,建议用一个版本号表记录当前 schema 版本。
  8. update / delete 不传表名:表名只通过 new RdbPredicates('bill') 传入,所以正确写法是 store.update(values, predicates) 和 store.delete(predicates)(两个参数);而 insert / batchInsert / query 仍要带表名。游标方法是 goToFirstRow() / goToNextRow(),不是 goToFirst() / goToNext()(API 22 已更名)。

九、进阶注意(运行/性能篇)

上面「踩坑」节是编译 / 语法级的坑。这一节补运行、设计、性能级的注意点

1. 写入性能与线程

  • 事务包批量写:batchInsert 不自动包事务。N 条循环 insert 在事务外 = N 次磁盘 fsync,巨慢。正确姿势:store.beginTransaction() → 循环 insert → store.commit();catch 里 store.rollBack()。注意这三个是同步方法,要包 try-catch。
  • 不能进 Worker,重查询别阻塞 UI:RDB 不支持 Worker/TaskPool(踩坑第 3 条)。缓解:全用异步 API(本身不卡调度)+ 分页 + 控制单次数据量;大数据导入一次性放事务提交。
  • getRdbStore 是单例缓存:重复调用返回同一实例(懒加载缓存),StoreHelper 单例封装是对的,别每次新建。

2. 查询性能与索引

  • like 只有前缀走索引:'餐饮%' 可用索引;'%餐%' 全表扫描。大数据量慎用模糊搜索。
  • 深分页用游标法:limitAs + offsetAs 深翻页(offset 很大)仍全扫前 N 行。改 WHERE id > lastId LIMIT M 游标分页。
  • 索引要克制:executeSql("CREATE INDEX IF NOT EXISTS idx_cat ON bill(category)") 加速查但拖慢写,按真实查询频率建。

3. 数据建模与 SQL 边界

  • ValuesBucket 类型边界:只收 number / string / boolean / Uint8Array。Date→存 number(epoch millis);数组→JSON.stringify 成 string;二进制→Uint8Array。单条 < 2MB(踩坑第 5 条)。
  • 外键默认关闭:SQLite PRAGMA foreign_keys 默认 OFF,RDB 没帮你开,且是连接级需每次重设。多表关联靠应用层维护。
  • JOIN 用 executeSql:RdbPredicates 不支持 join;要关联查询直接 store.executeSql("SELECT ... JOIN ...", [params]) 取 ResultSet 手解析。
  • 列名大小写一致:getColumnIndex(name) 要和建表列名一致,建议全小写命名避免踩坑。

4. 工程化与安全

  • 版本迁移无 onUpgrade 回调:StoreConfig.version 只记版本号,不自动迁移(不像 Room)。自己建 __meta 表存当前 version,启动对比后 ALTER TABLE / CREATE TABLE(踩坑第 7 条的展开)。
  • 参数化防注入:条件用 RdbPredicates(天然参数化);executeSql 用 ? 占位符 executeSql("DELETE FROM bill WHERE id=?", [id]),别拼字符串。
  • 加密等级:securityLevel S1~S4 必填且决定加密强度(S4 最高、性能略降);旧 encrypt 字段与它的关系随版本变,以你本机 d.ts 为准。
  • 备份与隐私:RDB 在沙箱,默认不进云备份 / 不跨设备;记账类含敏感财务数据,别打日志、别未授权上传。

十、小结 & 下一步

今天我们把「结构化数据存得住、查得动」这件事讲完了:

  • RelationalStore = 鸿蒙内置 SQLite 封装;getRdbStore 拿实例、executeSql 建表。
  • 增用 insert/batchInsert,改删配 RdbPredicates,查用 query → ResultSet(必关)。
  • 条件用 RdbPredicates 拼(equalTo/like/between/and/or),排序 orderByXxx,分页 limitAs+offsetAs。
  • 多写用事务 beginTransaction/commit/rollBack 保原子性。
  • 最大坑:漏 securityLevel、ResultSet 不关、Worker 里用数据库。

📦 完整代码已上传 GitHub:https://github.com/Mrshenyan/TutorialDemo 

本篇所有 demo 都在 entry/src/main/ets/pages/ch24/ 下,克隆下来用 DevEco 直接跑。

留言区见~

最新文章

随机文章