鸿蒙原生开发数据库封装:基于relationalStore的单例增删改查实践

📅 发布时间:2026/10/5 3:38:39
鸿蒙原生开发数据库封装:基于relationalStore的单例增删改查实践
做鸿蒙原生开发的这段时间我最大的感触是越是看起来基础的东西越容易在项目后期找你算账。我正在开发一个叫【会议随记 Pro】的本地笔记类应用需求本身非常朴素——用户记录会议标题、正文、时间能按日期排序能搜关键词能改能删。但就是这个“朴素”的本地存储让我反复纠结了很久到底用 Preferences 还是 SQLite要不要封装单例增删改查怎么设计才算优雅又不至于过度设计这篇文章把我最终的方案完整记录下来核心思路就一句话基于鸿蒙官方的 relationalStore也就是 SQLite 能力封装一个全局唯一的数据库单例把增删改查收敛成几个干净的方法。如果你也在做鸿蒙原生开发或者刚准备接触 SQLite 相关的 API这篇应该能帮你少走不少弯路。1. 先搞清楚为什么要封装单例而不是到处 new1.1 一个会议记录 App 的存储需求为什么 Preferences 不合适【会议随记 Pro】的表结构其实很简单核心就是会议记录这一张表标题 title、正文 content、会议时间 meetingDate、创建时间 createTime、更新时间 updateTime外加一个 tags 字段存标签。业务操作无非就是增删改查加分页查询。有人可能会说这数据量又不大用 Preferences 存个 JSON 不就行了我一开始也这么想过但真正动手后发现几个痛点第一Preferences 是典型的 key-value 结构你要按时间倒序拉一页记录只能把整个 JSON 读出来在内存里排序、截断第二用户改了其中一条记录你得全量写回数据一多每次写入都像在倒腾一整个文件第三做关键词搜索的时候普通 KV 结构连个像样的 LIKE 都没有全靠自己写正则循环。这些问题本质上是“结构化查询”的需求。会议记录天然是表格型数据需要排序、筛选、分页、部分更新这些正是 SQLite 的强项。鸿蒙这边提供的是 relationalStore 关系型数据库接口底层就是 SQLite支持 SQL、事务、索引而且是官方封装不需要我自己去操心驱动集成和跨平台适配。所以方案很快就定了本地数据用 relationalStore把所有访问收敛到一个类里。1.2 单例模式在这里的价值不是“省一行代码”很多人一听到单例就觉得是老八股但在鸿蒙的 relationalStore 场景下单例有实实在在的理由。getRdbStore 是一个异步初始化过程内部要创建数据库连接、配置权限、准备底层存储属于“创建成本高”的对象。如果每个页面都在用的时候自己调用一次 getRdbStore轻则重复初始化的性能浪费重则出现多个连接管理同一份数据文件事务和锁的行为会变得很难预测。更重要的是数据库访问是带状态的建表、加索引、升级迁移、批量事务这些逻辑都需要一个统一的地方来管。你把 RdbStore 封进单例业务层永远只面对 addNote、queryNotes、updateNote、deleteNote 这几个方法底层是 SQLite 还是未来换成别的存储对上层完全透明。以后想加日志、做埋点、统一错误处理也只需要动这一个类。这就是单例最大的价值它是整个数据层的唯一入口也是你把所有脏活累活关在笼子里的地方。下面是单例的骨架。注意 ArkTS 的语法约束静态字段必须显式初始化或者声明为可空类型构造函数私有外部只能通过 getInstance 获取。import relationalStore from ohos.data.relationalStore; import common from ohos.app.ability.common; export class MeetingNoteDB { private static instance: MeetingNoteDB | null null; private store: relationalStore.RdbStore | null null; private initTask: Promisevoid | null null; private context: common.UIAbilityContext; private constructor(context: common.UIAbilityContext) { this.context context; } public static getInstance(context: common.UIAbilityContext): MeetingNoteDB { if (MeetingNoteDB.instance null) { MeetingNoteDB.instance new MeetingNoteDB(context); } return MeetingNoteDB.instance; } public init(): Promisevoid { if (this.store ! null) { return Promise.resolve(); } if (this.initTask null) { this.initTask this.doInit(); } return this.initTask; } private async doInit(): Promisevoid { // 初始化 store、建表、加索引 } }这里 initTask 的作用下面会细说它解决的是“多个页面同时第一次调用 init 导致重复初始化”的并发问题。先把框架立起来后面继续往里面补充细节。2. 搭好底子StoreConfig、建表语句和索引2.1 初始化配置与版本管理的取舍doInit 里第一步是拿到 RdbStore。StoreConfig 最核心的两个字段是 name 和 securityLevel。name 就是数据库文件名建议用和项目相关的名字比如 meeting_notes.db别起一堆通用名后面多个模块共用时找起来头疼。securityLevel 按数据敏感程度选会议随记这类业务数据用 S1 就够了如果你的 App 涉及更敏感的个人信息再按合规要求调高等级、配合加密方案。代码长这样private async doInit(): Promisevoid { const config: relationalStore.StoreConfig { name: meeting_notes.db, securityLevel: relationalStore.SecurityLevel.S1, }; this.store await relationalStore.getRdbStore(this.context, config); await this.createTable(); // 如果版本升级这里做迁移 await this.checkAndMigrate(); }注意一点getRdbStore 在鸿蒙不同 API 版本里签名略有差异有的版本可以传入 version 和升级回调有的版本需要拿到 store 之后自己管理版本号。我的做法是尽量少依赖回调拿到 store 之后通过 store.version 判断当前库的版本再决定要不要做迁移。这样逻辑更可控也避免了回调里没法 await 的尴尬。具体的迁移流程在第 4 章详细写。拿到 store 之后所有增删改查都基于它。createTable 用 executeSql 执行 DDL这是最直接的建表方式。2.2 建表语句字段类型、默认值和索引一起定建表不是随便写个 CREATE TABLE 就完事。我建议在建表阶段就把字段名、字段类型、默认值、索引一次性定清楚因为后面每改一次表结构都要处理老用户的数据兼容。会议记录的建表语句如下private async createTable(): Promisevoid { const sql: string CREATE TABLE IF NOT EXISTS meeting_note ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, content TEXT DEFAULT , meeting_date INTEGER NOT NULL, create_time INTEGER NOT NULL, update_time INTEGER NOT NULL, tags TEXT DEFAULT ) ; await this.store.executeSql(sql); await this.store.executeSql( CREATE INDEX IF NOT EXISTS idx_meeting_date ON meeting_note(meeting_date) ); await this.store.executeSql( CREATE INDEX IF NOT EXISTS idx_title ON meeting_note(title) ); }几个细节说明一下。时间字段我用 INTEGER 存毫秒时间戳而不是 TEXT 存格式化字符串。时间戳可排序、可比较、可运算格式化交给 UI 层这是最稳的做法。十年前的 SQLite 老教程喜欢让你存 datetime 字符串那套在移动端真的不合适。title 设为 NOT NULL因为会议记录没有标题基本没有意义content 给 DEFAULT 避免插入时因为少字段报错id 用 INTEGER PRIMARY KEY AUTOINCREMENT自增主键插入时不要传。索引在建表时一起建。很多人等到查询慢了才想起加索引但那时候表里已经有大量数据了在移动设备上后期建索引会让 UI 卡一下不如建表时顺手做了。索引的价值在第 4 章的十万条数据实测里会看得很清楚。字段名用下划线风格meeting_date、create_timeTS 模型里用驼峰风格meetingDate、createTime中间做一次显式映射。好处是 SQL 语义更清晰和数据库惯例一致坏处是增删改查的每个方法里都要写映射看着啰嗦。但我宁可啰嗦十行映射代码也不想让数据库字段和业务模型混在一起后面维护的时候少很多麻烦。对应的 TS 模型export interface MeetingNote { id: number; title: string; content: string; meetingDate: number; createTime: number; updateTime: number; tags: string; }3. 增删改查四个方法写得顺手比什么都重要3.1 插入ValuesBucket 的键要跟字段名严格一致插入操作在 relationalStore 里用 insert(table, values)。第二个参数是 ValuesBucket本质上是一个 key 为字符串、value 为基本类型的对象key 必须和表字段名完全一致一个字符都不能差。我的实现是这样async addNote(note: MeetingNote): Promisenumber { this.ensureReady(); const bucket: relationalStore.ValuesBucket { title: note.title, content: note.content, meeting_date: note.meetingDate, create_time: note.createTime, update_time: note.updateTime, tags: note.tags, }; return this.store.insert(meeting_note, bucket); } private ensureReady(): void { if (this.store null) { throw new Error(MeetingNoteDB not initialized, call init() first); } }insert 返回的是新插入行的 rowId也就是自增主键的值。业务层拿到这个 rowId可以更新本地 UI也可以接着做关联操作比如往子表里写参会人。这里有个很容易犯的错ValuesBucket 的 key 是字符串字面量不是变量所以编译器帮不了你检查拼写。一旦 meeting_date 写成 meetingDate插入就会直接报错或者插入默认值你得靠运行时报错才能发现。我后来是把字段名全部定义成常量放在一个单独文件里比如const COL_MEETING_DATE meeting_date代码里引用常量而不是裸字符串能提前拦截一批低级错误。还有一点插入时不要塞 id 字段。AUTOINCREMENT 会让数据库自己分配 id你硬塞一个有可能撞上主键约束也有可能打乱自增序列完全没必要。3.2 查询RdbPredicates 链式拼条件ResultSet 记得 close鸿蒙的查询接口设计得不算复杂核心是 RdbPredicates 这个谓词对象。它最大的好处是链式调用条件越多代码越干净。比如会议记录列表页要做“关键词搜索 时间倒序 分页”就拼成这样async queryNotes(keyword: string, page: number, pageSize: number): PromiseMeetingNote[] { this.ensureReady(); const predicates new relationalStore.RdbPredicates(meeting_note); if (keyword ! ) { predicates.like(title, %${keyword}%); } predicates.orderByDesc(meeting_date); predicates.limit(pageSize, page * pageSize); const resultSet await this.store.query(predicates, [ id, title, content, meeting_date, create_time, update_time, tags ]); const list: MeetingNote[] []; while (resultSet.goToNextRow()) { list.push(this.mapRowToNote(resultSet)); } resultSet.close(); return list; } private mapRowToNote(rs: relationalStore.ResultSet): MeetingNote { return { id: rs.getLong(rs.getColumnIndex(id)), title: rs.getString(rs.getColumnIndex(title)), content: rs.getString(rs.getColumnIndex(content)), meetingDate: rs.getLong(rs.getColumnIndex(meeting_date)), createTime: rs.getLong(rs.getColumnIndex(create_time)), updateTime: rs.getLong(rs.getColumnIndex(update_time)), tags: rs.getString(rs.getColumnIndex(tags)), }; }几个关键点query 的第二个参数是列名数组只取你需要的列别图省事传空数组或者 SELECT *。会议正文 content 往往很长列表页根本不需要加载它少取一列就少一次内存拷贝。ResultSet 自带游标机制初始位置在 -1goToNextRow() 每调用一次往下走一行返回 false 表示到头了。这个循环写法跟官方示例一致也是最不容易出错的。getString、getLong 这些方法接收的是列索引不是列名所以要先 getColumnIndex。每次循环里取索引有少量开销但对我们这个量级无所谓别为了微优化把代码搞复杂。resultSet 用完必须 close()。它内部持有数据库游标和内存不释放的话连续查询几次就可能在日志里看到游标泄漏。这一点在真机上尤其明显长时间驻留的 App 会越跑越慢。3.3 更新与删除predicates 一个都不能少更新和删除在结构上是同一类操作先拼谓词定位目标行再执行。更新用 store.update(values, predicates)删除用 store.delete(predicates)。我的封装如下export interface MeetingNoteUpdate { title?: string; content?: string; meetingDate?: number; tags?: string; } async updateNote(id: number, fields: MeetingNoteUpdate): Promisenumber { this.ensureReady(); const bucket: relationalStore.ValuesBucket {}; if (fields.title ! undefined) { bucket[title] fields.title; } if (fields.content ! undefined) { bucket[content] fields.content; } if (fields.meetingDate ! undefined) { bucket[meeting_date] fields.meetingDate; } if (fields.tags ! undefined) { bucket[tags] fields.tags; } const predicates new relationalStore.RdbPredicates(meeting_note); predicates.equalTo(id, id); return this.store.update(bucket, predicates); } async deleteNote(id: number): Promisenumber { this.ensureReady(); const predicates new relationalStore.RdbPredicates(meeting_note); predicates.equalTo(id, id); return this.store.delete(predicates); }这里必须敲黑板update 和 delete 的返回值是受影响的行数而谓词决定了哪些行受影响。如果 predicates 一个条件都没加数据库会把整张表都更新掉或者清空掉。这种事故我见过不止一次而且灾难性极强——你只是想改一条记录的标题结果全表标题都变成了同一个值。我的习惯是每次写 update 和 delete 之前先在心里默念一遍“必须 equalTo 主键”然后用 id 字段定位单行。这类问题很难靠单元测试兜住因为测试数据量小全表更新的后果不明显到了生产环境数据一多才暴露。所以从封装上就要把单行操作的谓词固定下来。另外update 的 ValuesBucket 是“部分更新”语义只出现在 bucket 里的字段会被更新其他字段保持原值。所以业务层更新一条记录时不需要先查出来再全量塞回去只传改动字段即可。这也是我定义 MeetingNoteUpdate 而不是直接复用 MeetingNote 的原因语义上明确告诉调用方这里只需要传要改的部分。4. 实测与踩坑一万条和十万条数据的差距不容忽视4.1 十万条数据到底查询多久我在【会议随记 Pro】开发过程中专门造了一批测试数据验证性能和索引的效果。测试机是当时手头的一台中端真机数据规模从 1 万条到 10 万条都跑了一遍。先说结论索引的力量在这个量级被体现得淋漓尽致。10 万条数据全表扫描的耗时在几百毫秒级别而走索引的精确查询可以压到几十毫秒。具体数据大概是这个量级不同设备和 API 版本会有波动看趋势就行查询场景无索引耗时10 万条有索引耗时10 万条按 meeting_date 范围查询筛出约 1000 条约 200ms约 25ms按 title 精确匹配约 180ms约 20mstitle LIKE %关键字%约 320ms约 320ms索引基本无效最值得关注的是最后一行LIKE 查询只要把通配符放在前面比如 %关键词%SQLite 的索引就帮不上忙了哪怕你有索引它也只能全表扫描。这是因为 B-Tree 索引只能高效匹配前缀确定的字符串。想走索引可以把搜索词改成 关键词% 这种前缀匹配命中率会下降但性能能回来。会议记录的场景里用户搜索“周会”往往是在找“周会纪要”这类标题前缀匹配其实够用。如果你真需要全文搜索那种带分词、带倒排索引的体验需要先在鸿蒙的封装下做技术验证再上不要拍脑袋。我踩过的另一个坑是条数统计。列表页通常会显示“共 N 条记录”这个 N 要单独查 count。别用“查出全量数组再取 length”的方式10 万条数据查出来内存直接涨几十 MB完全没必要。正确姿势是让数据库帮你数async countNotes(keyword: string): Promisenumber { this.ensureReady(); const predicates new relationalStore.RdbPredicates(meeting_note); if (keyword ! ) { predicates.like(title, %${keyword}%); } const resultSet await this.store.query(predicates, [COUNT(*) AS total]); let count 0; if (resultSet.goToNextRow()) { count resultSet.getLong(resultSet.getColumnIndex(total)); } resultSet.close(); return count; }count 查询走数据库聚合速度非常快10 万条也就是几十毫秒的事。列表页的总数和分页数据分开查一个走 count一个走 limit。4.2 并发初始化与多页面竞争init 只跑一次的承诺鸿蒙的页面生命周期比较分散一个 App 里很可能有多个页面同时触发数据访问。假设主页面在 onPageShow 调了一次 init同时详情页在 aboutToAppear 也调了一次 init如果没有保护getRdbStore 会被执行两遍。虽然大多数情况下它会返回同一个底层 store但这个行为终究不靠谱而且两遍初始化意味着建表逻辑也可能执行两遍日志里全是重复执行的 SQL。我的解决办法就是前面提到过的 initTask 缓存public init(): Promisevoid { if (this.store ! null) { return Promise.resolve(); } if (this.initTask null) { this.initTask this.doInit().catch((e) { this.initTask null; throw e; }); } return this.initTask; }注意 catch 里要把 initTask 重置为 null。否则初始化失败一次之后所有后续调用都拿到一个永远 pending 的 Promise整个数据库层就瘫痪了。这个细节是我在调试时发现的初始化失败后重试一直无效找了半天才发现是缓存 Promise 没清掉。另一个更省心的做法在 UIAbility 的 onCreate 里就调用一次 init。这样 Application 启动阶段数据库就已经就绪页面进来直接访问不用关心初始化时序。缺点也很直观——启动时间会稍微变长因为 getRdbStore 是异步的如果数据量不大、建表很快这点开销可以接受。我的习惯是小表、简单结构就在 Ability 里提前初始化将来数据结构复杂、初始化耗时上去了再改回懒加载反正单例的接口不变切换成本很低。4.3 版本升级与迁移别在回调里做异步操作数据库版本升级是我在这个项目里最狼狈的部分。第一版建表之后就上线测试了后来要加一个 location 字段我天真地以为改一下 createTable 就行了。结果老用户的数据库里根本没有这一列查询的时候 getColumnIndex(location) 直接抛异常。原因很简单CREATE TABLE IF NOT EXISTS 只会在表不存在时创建表已经存在的情况下它什么都不做。你更新了 DDL老表还是老表。正确的做法是引入版本控制和迁移。getRdbStore 在不同版本里提供过 version 参数和升级回调但我实际开发中发现回调是同步的里面做不了 await而 executeSql 是异步的直接在回调里执行迁移非常别扭。所以我改成自己接管版本管理拿到 store 后判断版本再执行增量迁移private readonly CURRENT_VERSION: number 2; private async checkAndMigrate(): Promisevoid { const oldVersion this.store.version; if (oldVersion 2) { // v1 - v2增加 location 字段 await this.store.executeSql( ALTER TABLE meeting_note ADD COLUMN location TEXT DEFAULT ); this.store.version 2; } }这里有一个很容易被忽略的坑store.version 的默认值。新建数据库文件时version 是 0如果表是新创建的你也应该在建表后把 version 设置成当前版本否则下次启动 checkAndMigrate 会认为还是老版本重复执行迁移。重复执行 ALTER TABLE ADD COLUMN 会直接报 duplicate column name 错误。所以迁移逻辑一定要写成幂等的要么用版本号保证每一段迁移只执行一次要么在执行前检查目标结构是否已经存在。我在调试工具部分还会提到这个坑因为当你用数据库管理工具把 .db 文件导出来检查时能直接看到 version 是多少排查这类问题会快很多。下面讲讲调试工具。5. 再优化一点事务、调试工具与扩展方向5.1 批量写入必须用事务差距是数量级的会议随记初期不太会触发批量写入但测试阶段我要造数据一次性插入几千条。第一版代码是简单的 for 循环逐条 insert插入 1000 条记录耗时在几秒到十几秒之间而且后续操作明显被拖慢。后来把批量插入包进事务同样 1000 条耗时降到几百毫秒差距完全不是一个量级。原因很简单每条独立的 insert 都是一次完整的事务操作数据库要为每次写入做日志、刷盘、维护页缓存而把 1000 条 insert 放在一个事务里日志只写一次中途失败还能整体回滚。代码如下async batchAddNotes(list: MeetingNote[]): Promisevoid { this.ensureReady(); this.store.beginTransaction(); try { for (const note of list) { const bucket: relationalStore.ValuesBucket { title: note.title, content: note.content, meeting_date: note.meetingDate, create_time: note.createTime, update_time: note.updateTime, tags: note.tags, }; await this.store.insert(meeting_note, bucket); } this.store.commit(); } catch (e) { this.store.rollBack(); throw e; } }事务有个注意点beginTransaction 之后任何一条语句失败都要走到 rollBack然后重新抛出异常。别只在成功分支 commit失败分支直接 return那样事务会一直挂着后续所有写操作都得等它释放锁。还有事务里面不要穿插网络请求、用户输入等待这类时间不可控的操作数据库锁占用的时间越短越好。5.2 用 DB Browser for SQLite 检查鸿蒙应用里的真实数据开发过程中我强烈建议备一个 SQLite 桌面管理工具。我自己用的是 DB Browser for SQLite开源免费Windows、macOS、Linux 都有对应版本特别适合快速打开一个 .db 文件看表结构、看数据、手动跑 SQL。鸿蒙应用的数据文件在应用沙箱里找它的路径并不难在 DevEco Studio 里打开 Device File Explorer定位到应用沙箱目录下的 database 目录把 meeting_notes.db 导出来用 DB Browser 打开就能看到完整的表和数据。这个工具在排查问题时帮了大忙。比如前面那个版本升级的坑我导出数据库后一眼就看到 meeting_note 表里没有 location 列v2 代码却在 SELECT 里引用了它问题定位只用了 5 分钟。再比如你想验证某个查询慢是不是索引没生效可以直接在 DB Browser 的 SQL 执行窗口里跑 EXPLAIN QUERY PLAN SELECT ...看看查询计划里有没有用上索引。这类手段在真机上不好模拟但导出文件之后就是几分钟的事。有一点要注意每次导出前先确认 App 已经把数据落盘了最好是在完全退出页面、甚至杀掉进程之后再看。数据库有 WAL 机制的时候部分数据可能还在 -wal 文件里直接拷主库文件会看到数据不完整。5.3 这个封装后续还能怎么扩展单例封装好之后增删改查只是第一步。后续【会议随记 Pro】要加参会人、会议附件、待办关联表会越来越多封装的思路也可以顺势演进。我的计划是引入 DAO 层MeetingNoteDB 保持底层职责——持有 store、管理建表和迁移、提供通用查询工具业务上每个实体对应一个 DAO比如 MeetingNoteDao、ParticipantDao、AttachmentDao。这些 DAO 通过构造函数拿到 MeetingNoteDB 的实例只关心自己的增删改查。这样做的直接好处是单例类不会随着业务膨胀变成一个两三千行的上帝类。另外一个可以思考的方向是通用 CRUD 抽象。你在很多后端项目里见过基于 MyBatis-Plus 这类框架拼出来的通用 service无状态、拿来即用。移动端其实也可以做类似的抽象比如把“按 id 查单条”“按条件分页查列表”这类模式提取成泛型方法。不过我要提醒一下ArkTS 对泛型的支持比 TypeScript 严格很多高级类型操作没法直接写硬抽象容易把代码搞得又绕又难调。我的建议是如果表结构差异不大可以做一个轻量的 BaseDao如果表结构差异明显宁可写几个具体的 DAO 类重复一些映射代码也不要为了通用性牺牲可读性。最后再分享一个使用层面的小经验。单例封装完成之后所有页面访问数据库都要走 init为了不在每个页面里重复写 getInstance(context).init()我在入口页面把 init 结果挂到一个全局状态里页面加载时统一等待数据层就绪。这个模式配合 UIAbility 启动时预初始化整个 App 的数据访问时序会清晰很多基本没有遇到过“数据库还没准备好”的报错。单例不是银弹但它把数据库访问从“每个页面各自为政”变成了“全局只有一个出口”单就这一点就值得每个鸿蒙原生项目认真对待。