第 22 篇我们学会了用 Preferences 把「少量配置」存住(昵称、开关、是否首次打开)。但 Preferences 本质是 key-value 文件,它有三个硬伤:
- 不能做条件查询("查所有支出大于 100 的记录"做不到);
- 存的是扁平字符串,结构一复杂就 JSON 满天飞。
当你要管的是一堆有结构、要筛选、要排序的数据时,就该请出今天的主角:关系型数据库 RelationalStore(底层基于 SQLite)。为了好懂,咱们全程用一个最贴生活的例子——个人记账本来讲。
系列约定:本篇统一对标 API 22。
一、什么时候该上关系型数据库
先给个速查表,别一上来就埋数据库:
一句话记忆:少量配置用 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() | | |
beginWrap() | | |
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 把实战里最容易翻车的地方列出来了,编译或调试时对照看:
securityLevel 必填:StoreConfig 在 API 12+ 起必须带 securityLevel,漏写直接报错。按敏感度选 S1~S4。ResultSet 必须 close():遍历完一定 close(),否则原生资源泄漏,用第 23 篇 Profiler 能看出句柄持续增长。- RelationalStore 不能在 Worker 线程用:第 23 篇的
taskpool 并发场景碰不了数据库;要后台算账就主线程算完再落库。 ValuesBucket 是具名类型:声明 let v: relationalStore.ValuesBucket = {...};别写成 let v: object = {...},会触发 arkts-no-untyped-obj-literals。- 单条数据 < 2MB:超了插入成功、读取失败;大文本/大图请拆表或走文件。
struct 名全工程唯一:ArkTS 把整个 module 一起编译,新 struct 名别和 ch01~ch23 重复(否则 Duplicate identifier)。- 表结构升级自己管:RelationalStore 没有 Room 那种自动迁移。加列/改表要用
executeSql("ALTER TABLE ...") 自己处理,建议用一个版本号表记录当前 schema 版本。 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 直接跑。
留言区见~