
上个月我把一个家庭记账的小工具搬到了 HarmonyOS 上需求其实不复杂记录每笔收支按分类统计支持按时间段查询。但就是这样一个看起来常规的 App 开发项目让我在数据持久化方案上反复纠结了好几天。HarmonyOS 提供的存储方案不少Preferences、关系型数据库、分布式数据服务各有各的适用边界选错了后面改起来相当痛苦。最后我选择了关系型数据库也就是 HarmonyOS 里的 RDB把支出流水、分类统计这些核心功能全部落在 RDB 上跑。这篇文章不会去复述官方文档我想以这个记账项目为线索把从选型、建库到增删改查、事务处理再到问题排查的完整过程记录下来。如果你正在做 HarmonyOS App 开发并且面临“到底该不该上关系型数据库”“初始化总是失败”“查询结果不更新”这类问题这篇应该能帮你少走不少弯路。1. 开始动手前先把关系型数据库选型的理由说清楚很多人一上来就写代码结果写到一半发现存储选型不对整个数据访问层推翻重来。这种时间浪费完全没必要。我先把项目里真实用到的数据结构摆出来再说为什么最终选型落在了关系型数据库上。1.1 这个 App 里的数据结构偏偏适合 RDB家庭记账工具最核心的表是支出流水每一行代表一笔支出包含这几个字段id、分类、金额、备注、创建时间。业务上经常要做的操作是按分类统计某个月花了多少、查询最近一笔大额支出、删除某一时间段内的记录。这种需求如果用键值对来做会非常别扭。Preferences 本质上是一个轻量级键值存储适合保存用户配置但你要按category等于“餐饮”的条件去过滤几百条流水就只能把全部数据读进内存自己循环比对。而关系型数据库天生就是为这种场景设计的一条带条件的 SQL 就能解决。还有一点经常被忽略流水和分类之间是有关联关系的。分类表维护分类名称和图标流水表用分类 ID 指向分类表。这种一对多的关联模型正是关系型数据库最擅长处理的领域。为了一个“小项目”去强行设计一套键值对缓存结构后面做统计报表的时候会非常吃力。1.2 除了 RDBHarmonyOS 还有哪些本地存储选项HarmonyOS 官方文档里给出了几种本地数据持久化方案我做一个简单的对照存储方案数据结构适合的场景不适合的场景Preferences键值对用户偏好、开关设置、少量配置结构化查询、多条件筛选关系型数据库RDB二维表行列结构流水、订单、联系人等结构化数据简单的单 key 存取大材小用分布式数据服务键值对/表多端协同、跨设备同步场景单设备纯本地需求成本偏高从这个表可以清晰看到RDB 的价值在于“结构化”和“查询能力”。我的记账项目里涉及大量条件筛选和统计聚合属于典型的结构化数据场景所以 RDB 是唯一合理的选择。如果你只是存一个主题色配置、一个登录状态 token那老老实实用 Preferences别为了炫技去建表。另外提一个非常容易踩的坑同一个应用里Preferences 和 RDB 并不是互斥的。我最后的设计是登录态、主题配置这类简单的标量数据走 Preferences业务流水全部进 RDB。两种存储方案各管一段这样反而最干净。2. 建库与建表一张能扛业务的表应该怎么设计选型定下来之后第二个关键动作是设计数据库和表结构。很多初学者拿到 RDB 就是无脑执行一条建表 SQL等后面需求加字段、加索引的时候才发现进退两难。这一章我把 StoreConfig、SQL 建表索引、数据库版本升级这三个点拆开讲。2.1 从 StoreConfig 到 RdbStore先明白这二者负责什么在 HarmonyOS 的关系型数据库 API 里最核心的入口是relationalStore.getRdbStore(context, config)。这里的config是StoreConfig类型主要包含两个信息数据库文件名和安全等级。import { relationalStore } from kit.ArkData; const STORE_CONFIG: relationalStore.StoreConfig { name: family_account.db, securityLevel: relationalStore.SecurityLevel.S1, };安全等级是 HarmonyOS RDB 特有的一项设计从 S1 到 S4 分四档对应不同的数据敏感级别。S1 适用于一般个人信息比如记账数据S4 则适用于比较敏感的凭证、密钥类数据。安全等级会影响数据库文件的加密存储方式级别定得越高写入开销也会越大。做普通应用选中低档即可不需要为了追求安全去盲目拔高级别。有一个容易忽略的细节数据库的文件名必须是.db结尾的字符串HarmonyOS 会在这个名字的基础上创建对应的数据库文件。如果你在调试时想知道数据库文件具体落在哪里可以打日志看一下应用的沙盒路径在context.databaseDir下面能找到它。2.2 建表 SQL字段类型、主键和索引的取舍拿到RdbStore实例之后第一步就是执行建表 SQL。我建议使用“IF NOT EXISTS”来判断避免应用重启后重复建表导致报错。const SQL_CREATE_EXPENSE_TABLE CREATE TABLE IF NOT EXISTS expense ( id INTEGER PRIMARY KEY AUTOINCREMENT, category_id INTEGER NOT NULL, amount REAL NOT NULL, note TEXT, create_time INTEGER NOT NULL ) ; await rdbStore.executeSql(SQL_CREATE_EXPENSE_TABLE);这里几个字段设计是有讲究的。id用INTEGER PRIMARY KEY AUTOINCREMENT让数据库自增主键避免在业务层生成唯一 ID 的麻烦。create_time我故意存成INTEGER而不是TEXT因为我在业务层写入的时间是Date.now()时间戳。用时间戳的好处是排序和范围比较非常快存成字符串还要考虑格式统一的问题。建表之后我给create_time加了一个索引因为查询里几乎都有时间段条件const SQL_CREATE_INDEX CREATE INDEX IF NOT EXISTS idx_expense_create_time ON expense(create_time) ; await rdbStore.executeSql(SQL_CREATE_INDEX);有人会问数据量也不大加索引有必要吗我的经验是对开发阶段的几千条测试数据确实感觉不到差别但当数据量涨到几万条按时间范围筛选的查询速度会肉眼可见地变慢。索引不是银弹但给高频查询字段建索引是无脑收益的操作前提是你要清楚哪些查询是高频的。2.3 数据库版本升级没有想象中那么神秘项目开发到第二周产品提了一个需求支出分类要支持图标这意味着要在expense表旁边新增一张category表还要给expense表加一个icon字段。这时候就涉及到数据库迁移。HarmonyOS 的关系型数据库在底层沿用了 SQLite 的机制所以版本管理思路可以借鉴 SQLite 社区的成熟做法。我在项目里使用PRAGMA user_version来记录数据库结构版本private async judgeDatabaseVersion(store: relationalStore.RdbStore): Promisevoid { const versionResultSet await store.querySql(PRAGMA user_version); let version 0; if (versionResultSet.goToNextRow()) { version versionResultSet.getLong(0); } versionResultSet.close(); if (version 1) { await store.executeSql(SQL_CREATE_CATEGORY_TABLE); await store.executeSql(SQL_ADD_ICON_COLUMN); await store.executeSql(PRAGMA user_version 1); } }这个写法的思路是启动时先读当前库版本如果小于目标版本就按版本号逐级执行迁移脚本。执行完成后把user_version更新到最新避免下次启动重复执行。在模拟器上调试迁移脚本时如果发现迁移逻辑写错了可以直接删掉应用重新安装数据库文件会一起清掉不需要手动去删沙盒文件。3. 初始化 RdbStore 的完整链路一次成功的连接是怎么建立起来的建库建表的 SQL 想清楚以后剩下的问题是连代码层面把RdbStore实例成功拿到手。这个环节不算难但细节不少尤其是 context 来源、回调/Promise 选择、连接复用这三块。3.1 Context 从哪里来安全等级怎么定getRdbStore的第一个参数是Context在 HarmonyOS 的 UIAbility 里通常直接传this.contextimport { common } from kit.AbilityKit; Entry Component struct IndexPage { private context getContext(this) as common.UIAbilityContext; async initDB(): Promisevoid { try { const store await relationalStore.getRdbStore(this.context, STORE_CONFIG); // 后续操作 } catch (err) { console.error(initDB failed: ${JSON.stringify(err)}); } } }我建议初始化数据库的操作放在onPageShow或者 Ability 的onWindowStageCreate阶段去做不要在aboutToAppear里做耗时操作避免首页首帧被数据库初始化卡住。实际操作中优先把初始化结果暴露成一个 Promise 或回调页面侧等 Promise resolve 之后再发请求查询数据。3.2 回调方式与 Promise 方式各自适合什么场景relationalStore.getRdbStore同时提供了回调和 Promise 两种写法。我自己的习惯是新代码一律用异步函数加 await可读性强很多而且错误处理集中在一个 try catch 里。private store: relationalStore.RdbStore | undefined undefined; async getStore(): PromiserelationalStore.RdbStore { if (this.store) { return this.store; } this.store await relationalStore.getRdbStore(this.context, STORE_CONFIG); return this.store; }这里我顺手做了一个“单例缓存”同一个RdbStore只初始化一次后续所有数据访问都复用这个实例。为什么一定要复用因为 HarmonyOS 底层对同一个数据库文件是有锁机制的如果业务代码里到处getRdbStore有些场景会导致数据库被重复打开轻则资源浪费重则触发锁库异常。在本文后半部分的踩坑记录里我会详细展开这个问题的复现过程。3.3 用一个 DAO 类把数据库访问收口数据库连接拿到之后我建议立刻做一层 DAO 封装不要让业务页面直接接触 SQL 和RdbPredicates。项目里的记账模块最后是一个ExpenseDao类所有跟支出表相关的操作都收敛在这里export class ExpenseDao { private store: relationalStore.RdbStore | undefined; constructor(private context: common.UIAbilityContext) { } private async ensureStore(): PromiserelationalStore.RdbStore { if (!this.store) { this.store await relationalStore.getRdbStore(this.context, STORE_CONFIG); } return this.store; } }封装 DAO 的意义不只是代码美观。当后续需求要改表结构、调整查询条件时页面层代码可以完全不动只需要改 DAO 内部实现。项目迭代到第三周时我把amount从“流水金额”扩展成“支出金额 退款金额”两套逻辑页面侧因为走的是 DAO 接口几乎为零改动。4. 增删改查落到业务支出流水模块完整实现数据结构设计和连接初始化都理顺了接下来就是真正的业务功能开发了。我以支出流水模块为例子把插入、查询、更新、删除完整走一遍。4.1 插入一笔支出ValuesBucket 与 insert 的配合新增一笔支出时业务页面拿到用户输入的金额、分类、备注组装成一个ValuesBucket调用insert写入数据库。async addExpense(categoryId: number, amount: number, note: string): Promisenumber { const store await this.ensureStore(); const values: relationalStore.ValuesBucket { category_id: categoryId, amount: amount, note: note || , create_time: Date.now(), }; const rowId await store.insert(expense, values); console.info(addExpense success, rowId${rowId}); return rowId; }有个小细节要提醒ValuesBucket的键是字符串对应表的列名拼写必须和建表 SQL 完全一致大小写也要一致。我在开发早期遇到过因为列名写错比如把category_id写成categoryId导致数据插入失败的情况排查了半天才通过日志发现是字段名不匹配。如果你在页面上填完表单点保存数据库表里却没有新数据第一反应应该去检查日志里的报错第二个就是检查列名。4.2 条件查询RdbPredicates 组合筛选与排序查询是 RDB 最核心的场景。我要实现“按分类查最近 20 条支出”这个功能代码长这样async queryByCategory(categoryId: number, limit: number): PromiseExpenseItem[] { const store await this.ensureStore(); const predicates new relationalStore.RdbPredicates(expense); predicates.equalTo(category_id, categoryId) .orderByDesc(create_time) .limitAs(limit); const resultSet await store.query(predicates, [id, category_id, amount, note, create_time]); const list: ExpenseItem[] []; while (resultSet.goToNextRow()) { const item new ExpenseItem( resultSet.getLong(resultSet.getColumnIndex(id)), resultSet.getLong(resultSet.getColumnIndex(category_id)), resultSet.getDouble(resultSet.getColumnIndex(amount)), resultSet.getString(resultSet.getColumnIndex(note)), resultSet.getLong(resultSet.getColumnIndex(create_time)), ); list.push(item); } resultSet.close(); return list; }这里最关键的一步是resultSet.close()我在初学阶段经常忘记导致内存占用居高不下。ResultSet是数据库查询结果的游标对象它持有底层的内存资源读取完必须关闭。官方文档里也明确要求使用完调用close在循环里尽早把数据解析成普通对象然后立刻关掉游标这条习惯比任何内存优化技巧都重要。4.3 修改与删除根据主键定位记录更新和删除操作逻辑类似都是通过RdbPredicates指定条件然后对命中的记录做修改。async updateExpense(id: number, note: string): Promisenumber { const store await this.ensureStore(); const values: relationalStore.ValuesBucket { note: note }; const predicates new relationalStore.RdbPredicates(expense); predicates.equalTo(id, id); const changedRows await store.update(values, predicates); return changedRows; } async deleteByTimeRange(startTime: number, endTime: number): Promisenumber { const store await this.ensureStore(); const predicates new relationalStore.RdbPredicates(expense); predicates.greaterThanOrEqualTo(create_time, startTime) .lessThanOrEqualTo(create_time, endTime); const deletedRows await store.delete(predicates); return deletedRows; }update和delete的返回值都是受影响的行数。我在开发中会根据这个返回值判断操作是否真正生效。比如用户删掉一条支出后如果返回 0大概率是指定 id 的记录不存在此时页面应该提示“该记录已被删除”而不是无脑提示“删除成功”。4.4 在页面中调用 DAO 并实时刷新 UI数据访问层搞定后页面侧调用就非常直观了async loadRecentExpenses() { const list await this.expenseDao.queryByCategory(currentCategoryId, 20); this.expenseList list; }这里不需要在页面里出现任何 SQL也不需要关心ResultSet的读取细节。界面只需要一个列表容器拿到数据后刷新渲染即可。实际从ResultSet解析成普通对象数组这步我每次都要求放在 DAO 内部完成避免页面层直接依赖数据库驱动的返回类型。5. 数据安全性与性能事务、批处理与分页的取舍App 开发做到可以增删改查只是起步真正要应付的是数据安全和性能问题。这一章说说我在项目里用到的几个提升可靠性的手段。5.1 什么时候必须开启事务事务的价值在“多笔写操作要么全部成功要么全部回滚”。我在记账项目里遇到一个典型场景批量导入账单时用户选中一个 CSV 文件一次性插入上百条流水。如果每条流水单独insert中途发生一条错误就会留下“导入了一半”的脏数据。这时我会用事务把整批插入包起来async batchImportExpenses(items: ExpenseItem[]): Promisevoid { const store await this.ensureStore(); store.beginTransaction(); try { for (const item of items) { const values: relationalStore.ValuesBucket { category_id: item.categoryId, amount: item.amount, note: item.note, create_time: item.createTime, }; await store.insert(expense, values); } store.commit(); } catch (err) { store.rollBack(); console.error(batchImport failed: ${JSON.stringify(err)}); } }beginTransaction之后如果业务代码中途抛了异常rollBack会把整个事务回滚到开始前的状态。这个模式特别适合账单导入、设置页批量保存这类功能。另外提一个细节rollBack和commit的执行路径一定要用 try catch 包裹好否则一旦事务没有正常收尾连接的后续操作可能都会异常。5.2 分页查询limitAs 和 offsetAs 的配合列表页的数据量一大直接全量查询会导致首屏卡顿。我用limitAs和offsetAs做分页async queryPage(categoryId: number, page: number, pageSize: number): Promise{ list: ExpenseItem[], total: number } { const store await this.ensureStore(); const countPredicates new relationalStore.RdbPredicates(expense); if (categoryId 0) { countPredicates.equalTo(category_id, categoryId); } const countResultSet await store.query(countPredicates, [COUNT(*) AS total]); let total 0; if (countResultSet.goToNextRow()) { total countResultSet.getLong(countResultSet.getColumnIndex(total)); } countResultSet.close(); const listPredicates new relationalStore.RdbPredicates(expense); if (categoryId 0) { listPredicates.equalTo(category_id, categoryId); } listPredicates.orderByDesc(create_time) .limitAs(pageSize) .offsetAs((page - 1) * pageSize); const resultSet await store.query(listPredicates, [id, category_id, amount, note, create_time]); const list: ExpenseItem[] []; while (resultSet.goToNextRow()) { list.push(this.parseExpenseItem(resultSet)); } resultSet.close(); return { list, total }; }分页查询有一个隐藏的性能点如果业务上只需要上下翻页用offsetAs可以应对大多数场景。但如果数据量达到十万条以上OFFSET的深分页性能会急剧下降届时可以考虑用“上一页最后一条记录的时间戳”作为查询游标这属于另一个深度优化话题目前记账项目用不到。5.3 数据库连接与 ResultSet 的释放习惯RDB 的使用主要有两个需要主动释放的地方一个是上面反复提到的ResultSet另一个是长生命周期应用的数据库连接。RdbStore实例本身在应用运行期间一般不需要主动关闭除非应用要做多数据库切换。但你在调试页面跳转时会发现每次进页面初始化数据库、每次查询都打开游标却不关闭内存曲线会一路走高。我的经验是把所有ResultSet的关闭操作集中在 DAO 内部数据全部解析成普通对象数组返回给页面这样页面层永远接触不到游标也就不会出现漏关的问题。还有一个小技巧是在 DAO 里写一个统一的query私有方法内部负责创建谓词、执行查询、解析结果并关闭游标业务方法只需要传列名和条件即可。这样即使将来数据库驱动升级需要改动的地方也集中在 DAO 一处。6. 踩坑实录初始化无回调、锁库、查询结果不同步任何一个数据库方案在真实开发里都会遇到各种反直觉的问题。这一章我把自己在开发过程中实际踩过的三个坑完整还原不是直接给结论而是带你把排查链路走一遍。6.1 第一次打开数据库就没有回调问题出在哪现象是这样的项目刚切换到一个新的模拟器镜像我调用getRdbStore结果 Promise 一直处于 pending 状态既不成功也不失败页面就一直转圈。排查时我先确认了不是代码逻辑问题同样的代码在老模拟器上跑得好好的。后来我打开 Log 面板过滤关键字rdb和database发现有一条系统级错误日志提到了数据库文件被占用。定位思路逐渐清晰这次切换模拟器之后我多装了一个应用版本两个版本用同一个应用包名分别打开了不同的数据库目录但有别于原生 Android 沙盒机制HarmonyOS 应用在数据迁移后残留的旧数据库文件如果没有正常关闭就会让新连接无法获取锁。最终的处理方式很简单把模拟器上的应用卸载重装清掉所有残留文件后重新运行一切恢复正常。这个问题的教训是如果你在开发阶段遇到“数据库无法初始化且没有任何业务层报错”一定要先怀疑环境问题而不是像无头苍蝇一样改业务代码。6.2 数据库被锁多实例与并发写入冲突第二个坑有意思。我在某个版本里做了一次“优化”把ExpenseDao和CategoryDao分别通过各自的方式调用getRdbStore以为自己节省了初始化时间。结果用户同时新增一笔支出和修改一个分类名称时日志里偶尔会冒出数据库被锁的错误。原因很典型两个RdbStore实例同时指向同一个数据库文件虽然 HarmonyOS 底层对并发读写有一定容错能力但在高强度写入场景下文件级别的锁竞争仍然存在。因为错误日志只是偶尔出现我第一次还没往这方面想直到我打印了store的哈希值才发现内存里居然存在两个不同的RdbStore实例。解决方式就是第 3.2 节那个单例模式全局只维护一个 store通过一个模块级的 DAO 管理器来访问。6.3 查询结果没有实时更新谓词没有重建的坑还有一个频率很高的问题列表页退出再进入时新增的数据偶尔不显示。排查链路走到最后才发现是在页面返回时复用了上一个页面缓存的RdbPredicates对象。RdbPredicates是有状态的对象一旦绑定了之前的条件再次使用会继续带着旧条件。我调试时打印了实际执行的查询语句才发现页面复用的谓词上带着上次的limitAs(10)导致新插入的数据恰好排在 10 条之后自然就看不到了。正确做法是每次查询都新建谓词实例或者在查询结束后调用clear方法重置条件。比如在 DAO 的queryPage方法里我把谓词的构建放在方法内部每个方法私有副本绝不让同一个谓词跨页面复用。这也回到了封装 DAO 的核心价值把有状态的对象使用边界限制在数据访问层内部业务层就不会踩到这个坑。7. 写在最后几个值得养成的 RDB 开发习惯项目上线后回头看这次关系型数据库的开发过程最大的收获不是学会调用几个 API而是建立起一套数据访问的纪律。这里分享几个我最终沉淀下来的习惯希望能给你一些参考。第一所有数据库操作都走 DAO不要在页面里散落 SQL。哪怕是临时调试代码也应该紧挨着 DAO 写项目迭代越久这个约束的价值越明显。第二ResultSet的关闭不要靠“想起来”靠代码结构保证。我的 DAO 里有一个强制约束凡是query方法中创建ResultSet的地方立刻在相邻代码块里准备 try-finally 或手工 close避免代码审查时漏掉。第三数据库初始化做成全局单例。无论你写RdbStore还是RdbStore管理器全局只需要一个入口这能规避掉大量奇怪的并发问题。第四遇到查询结果不对先把实际执行的 SQL 打出来。HarmonyOS 的RdbPredicates在当前阶段还看不到现成 SQL 预览但我可以通过打日志拼出等价的查询条件很快就能定位是谓词写错还是数据本身有问题。关系型数据库在 HarmonyOS 应用开发里的位置是目前本地复杂数据持久化最可行的一条路。它不激进但足够可靠。我这个记账项目只是用到了最基础的能力如果你接着往深处做还会接触到跨设备数据同步、数据库加密、复杂多表联查等高级话题。先把基础和习惯打好后面的路会顺很多。