ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

鸿蒙开发实战:SQLite单例封装与数据持久化方案

2026/10/5 13:50:09 拓冰建站 浏览量
鸿蒙开发实战:SQLite单例封装与数据持久化方案 1. 项目背景与方案选型思考1.1 会议随记Pro为什么选择SQLite做鸿蒙原生开发的时候数据持久化是绕不开的一环。我手里这个会议随记Pro项目核心功能是记录会议纪要、议程、待办事项后来还加了录音转写和标签分类。数据量不算大但查询条件多字段结构也经常要跟着业务调。一开始我也纠结过几种方案首选项Preferences适合存键值对KVStore是分布式场景的利器但会议记录这种结构化、需要条件查询、需要排序分页的数据最合适的还是SQLite。鸿蒙的ohos.data.relationalStore模块提供了完整的SQLite关系型数据库能力底层用的就是SQLite引擎跟我在其他平台上的经验几乎无缝衔接。选SQLite还有一个现实原因会议随记Pro需要离线可用会议室里网络情况不确定录音转写、文字纪要这些内容必须在本地先落库之后再按需同步。SQLite单文件、零服务、轻量级的特性刚好满足这个诉求。1.2 单例模式的核心价值项目进入第二个版本迭代时我发现自己写的数据库操作代码开始失控了。每个业务页面都自己getRdbStore然后各自拼SQL、各自处理结果集。等业务模块到8个页面的时候光初始化数据库的代码就复制了6份更灾难的是有的页面忘了释放资源有的页面建表语句不一致还有一次因为重复初始化导致数据错乱。这时候我决定做一个统一的SQLite数据库单例。单例模式在这个场景下的价值很直接全局只有一份RdbStore实例初始化一次、复用到底避免重复打开连接的开销所有建表、升级、增删改查的SQL都收敛在一个类里业务侧不再碰裸SQL对上层只暴露insertMeetingNote、queryMeetingList这类语义化方法而不是executeSql这种底层操作后续换存储引擎、加缓存、做同步只改这一处业务代码零感知说白了就是开着这些破代码的车行走了几次夜路之后确信该修一条正经的高速公路了。这篇博文就把我把这个单例从0到1封装出来的完整过程、踩过的坑、以及实测数据都记录下来给同样在鸿蒙上做数据持久化的朋友一个参考。2. 数据表设计与基础结构搭建2.1 会议记录表字段规划封装数据库操作之前一定要先把表结构设计清楚。这是整个封装的地基字段设计不合理后面封装得再优雅也是空中楼阁。会议随记Pro的核心表是会议记录表meeting_note。我按实际业务场景做了这么一套字段字段名类型约束说明idINTEGERPRIMARY KEY AUTOINCREMENT主键自增titleTEXTNOT NULL会议标题contentTEXT默认空会议纪要正文locationTEXT可空会议地点start_timeINTEGERNOT NULL开始时间存毫秒时间戳end_timeINTEGER可空结束时间tagTEXT可空标签逗号分隔多个标签created_atINTEGERNOT NULL创建时间updated_atINTEGERNOT NULL最后修改时间有几个设计上的考量值得展开说时间字段我坚持用INTEGER存毫秒时间戳不用TEXT存2025-06-12 14:30这种字符串。原因有两个一是按时间范围查询时时间戳可以直接用比较走索引效率高二是避免时区问题存UTC时间戳展示层自己转本地时间。十万条数据量下时间戳方案和字符串方案查询性能能差一个数量级。tag字段用逗号分隔字符串而不是单独建一张标签关联表。这是有意的取舍会议记录这个场景标签数量少、查询条件简单用LIKE %tag%就能满足过滤需求。如果后续标签变成重业务比如按标签统计、标签推荐再拆表也不迟。过度设计在业务早期是负担。建表SQL是这样的CREATE TABLE IF NOT EXISTS meeting_note ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, content TEXT DEFAULT , location TEXT DEFAULT , start_time INTEGER NOT NULL, end_time INTEGER DEFAULT 0, tag TEXT DEFAULT , created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL ); CREATE INDEX IF NOT EXISTS idx_meeting_note_start_time ON meeting_note(start_time); CREATE INDEX IF NOT EXISTS idx_meeting_note_updated_at ON meeting_note(updated_at);索引这块我一开始忘建了后来数据量到两万条左右按时间倒序拉列表明显变慢加了start_time和updated_at两个索引之后立竿见影。索引不是越多越好只给高频查询的排序字段和WHERE字段建。2.2 数据库帮组的初始化设计鸿蒙里创建RdbStore需要两个关键参数数据库配置和建表/升级的回调。配置里有name数据库文件名、securityLevel安全级别等回调里要做ON CREATE和ON UPGRADE的处理。这块有个很容易忽略的细节securityLevel不是随便填的它跟应用的数据敏感度挂钩。会议纪要涉及商业内容我设置的是S1对应用沙箱内开放。如果你的应用涉及用户隐私数据要按需调到S2以上否则审核和合规层面可能出问题。单例的初始化方法设计成了显式的init方法在EntryAbility的onCreate里调用一次import { relationalStore } from kit.ArkData; import { common } from kit.AbilityKit; import { BusinessError } from kit.BasicServicesKit; const DB_NAME meeting_note.db; const DB_VERSION 1; export class SqliteManager { private static instance: SqliteManager | null null; private store: relationalStore.RdbStore | null null; private constructor() {} public static getInstance(): SqliteManager { if (SqliteManager.instance null) { SqliteManager.instance new SqliteManager(); } return SqliteManager.instance; } public async init(context: common.UIAbilityContext): Promisevoid { if (this.store) { return; } const config: relationalStore.StoreConfig { name: DB_NAME, securityLevel: relationalStore.SecurityLevel.S1, }; const store await relationalStore.getRdbStore(context, config); await store.executeSql(CREATE_TABLE_SQL); await store.executeSql(CREATE_INDEX_SQL); this.store store; } /** * 获取当前可用的 RdbStore * 如果尚未初始化抛出明确错误避免流程静默失败 */ public getStore(): relationalStore.RdbStore { if (!this.store) { throw new Error(SqliteManager 尚未初始化请先调用 init(context)); } return this.store; } }这个实现有几个关键的决策点懒加载 显式初始化不是等到第一次调getStore才初始化而是在Ability启动时主动调init。因为数据库初始化涉及IO提前做可以避免业务页面第一次查询时出现卡顿。构造器私有杜绝外部new SqliteManager()防的就是前面说的每页一个实例的乱象。getStore 抛异常而非返回null很多人喜欢if (store null) return;这样静默处理但null调后续链式操作会直接崩而且排查困难。宁可抛一个明确异常让开发者第一时间知道是初始化顺序出了问题。写到这里不得不提一句环境问题relationalStore这套API在新SDK版本里推荐用kit.ArkData导入老的ohos.data.relationalStore也能用但编译期会有deprecated警告。建议跟上新导入路径。3. 单例封装增删改查的四个核心模块3.1 插入方法让写库优雅起来单例最核心的价值是封装增删改查。我先写插入方法。鸿蒙RdbStore原生提供了insert(table, values)接口返回的是插入行的自增主键id。但直接在业务层调用原生insert会让业务层依赖ValuesBucket这种存储层数据结构耦合太重。我的封装思路是定义实体类与会话层之间的转换层业务层传实体对象进来封装层负责转成ValuesBucket再落库。export interface MeetingNote { id?: number; title: string; content: string; location: string; startTime: number; endTime: number; tag: string; createdAt: number; updatedAt: number; } export class SqliteManager { // 实体转存储层数据 private toValuesBucket(note: MeetingNote): relationalStore.ValuesBucket { const bucket: relationalStore.ValuesBucket { title: note.title, content: note.content, location: note.location, start_time: note.startTime, end_time: note.endTime, tag: note.tag, created_at: note.createdAt, updated_at: note.updatedAt, }; if (note.id ! undefined) { bucket[id] note.id; } return bucket; } /** * 插入一条会议记录 * returns 新记录的主键id */ public async insertMeetingNote(note: MeetingNote): Promisenumber { const store this.getStore(); const rowId await store.insert(meeting_note, this.toValuesBucket(note)); return rowId; } }你可能会觉得这个封装看起来没什么技术含量就是包了一层。但这里的价值在于业务层从此不再知道表名、字段名、ValuesBucket长什么样。后面表结构改了比如location字段拆成city和room只需要改toValuesBucket和实体类所有业务调用方零改动。插入这块还有一个高频需求批量插入。开几个会议连开议程一条条录入如果逐条insert每条都是一次IO确认慢且耗电。我封装了一个基于事务的批量插入方法public async insertMeetingNotesBatch(notes: MeetingNote[]): Promisenumber { const store this.getStore(); const promises: Promisenumber[] []; store.beginTransaction(); try { for (const note of notes) { promises.push(store.insert(meeting_note, this.toValuesBucket(note))); } await Promise.all(promises); store.commit(); return notes.length; } catch (e) { store.rollBack(); throw e; } }我自己实测纯逐条插入1000条记录耗时大概在3秒以上包一个事务之后1000条能压到500毫秒以内。这个差距在批量同步场景下感受非常明显。注意鸿蒙RdbStore的事务API是beginTransaction/commit/rollBack大小写和命名跟其他平台有点不一样别记成beginTransaction()是同步的实际上它确实是同步方法这点倒是省心。3.2 查询方法灵活才是灵魂查询是增删改查里最需要设计的方法因为业务场景复杂按id查、按时间范围查、按关键字模糊查、组合条件查还要排序、分页。我提供三层查询能力对应不同复杂度第一层按主键查详情。这个最简单直接public async queryNoteById(id: number): PromiseMeetingNote | null { const store this.getStore(); const predicates new relationalStore.RdbPredicates(meeting_note); predicates.equalTo(id, id); const resultSet await store.query(predicates, [id, title, content, location, start_time, end_time, tag, created_at, updated_at]); return this.parseNoteFromResultSet(resultSet); }第二层条件列表查询。我暴露一个会话查询参数对象让业务层可以通过对象字面量的方式传条件而不是拼字符串export interface MeetingQueryParams { startTime?: number; endTime?: number; tag?: string; keyword?: string; page?: number; pageSize?: number; } public async queryMeetingList(params: MeetingQueryParams): PromiseMeetingNote[] { const store this.getStore(); const predicates new relationalStore.RdbPredicates(meeting_note); if (params.startTime ! undefined) { predicates.greaterThanOrEqualTo(start_time, params.startTime); } if (params.endTime ! undefined) { predicates.lessThanOrEqualTo(start_time, params.endTime); } if (params.tag) { predicates.like(tag, %${params.tag}%); } if (params.keyword) { // 标题和正文任一命中都可以 predicates.beginWrap() .like(title, %${params.keyword}%) .or() .like(content, %${params.keyword}%) .endWrap(); } predicates.orderByDesc(start_time); // 分页 if (params.page ! undefined params.pageSize ! undefined) { predicates.limitAs(params.pageSize, params.page * params.pageSize); } const resultSet await store.query(predicates); return this.parseNotesFromResultSet(resultSet); }这里重点说两个API细节limitAs(count, offset)的offset传的是 跳过的条数不是页码所以第2页要传page * pageSize。这个我经常看到有人传错导致分页翻页后数据错乱。beginWrap()/or()/endWrap()这一组方法切记or两侧的字段必须按左条件.or().右条件顺序组合不然会生成错误的SQL括号分组。我早期踩过一次拼出来的SQL带了多余的OR导致查询结果全量返回排查了半天。第三层原生SQL兜底。列表查询再灵活也覆盖不了所有场景比如聚合统计、连表查询。我留一个受控的queryBySql方法只在确实需要原生SQL能力时使用public async queryBySql(sql: string, args: relationalStore.ValueType[] []): PromiserelationalStore.ResultSet { const store this.getStore(); return await store.querySql(sql, args); }第三层是逃生门我的底线是能用前两层解决的业务不要用原生SQL。原生SQL一多封装层的语义化能力就被架空了代码review时也得花更多精力确认SQL安全性。3.3 更新与删除方法安全是本分更新的核心诉求是改对了而不是改得爽。我封装的更新方法强制要求主键id没有id直接抛错从源头上杜绝忘了带WHERE条件把全表更新了的惨案public async updateMeetingNote(note: MeetingNote): Promiseboolean { if (note.id undefined || note.id null) { throw new Error(更新操作必须携带主键id); } const store this.getStore(); const predicates new relationalStore.RdbPredicates(meeting_note); predicates.equalTo(id, note.id); const bucket: relationalStore.ValuesBucket { title: note.title, content: note.content, location: note.location, start_time: note.startTime, end_time: note.endTime, tag: note.tag, updated_at: Date.now(), }; const rows: number await store.update(bucket, predicates); return rows 0; }注意updated_at我在业务代码里不传封装层自动填当前时间戳。这个细节保证最后修改时间永远不会因为调用方忘记赋值而出现0值。删除方法同样设计成两种按id删单条、按条件批量删。按id删很简单不再贴代码。批量删我特意限制了一把如果调用方传入的id数组为空直接返回0而不是执行一次空条件删除。空条件删除相当于DELETE FROM meeting_note这是删库级别的事故public async deleteMeetingNotesByIds(ids: number[]): Promisenumber { if (ids.length 0) { return 0; } const store this.getStore(); const predicates new relationalStore.RdbPredicates(meeting_note); predicates.in(id, ids); const rows await store.delete(predicates); return rows; }提一个很多人忽略的点RdbStore的delete和update方法返回的是受影响行数所以有没有删成功应该看返回值而不是try-catch。没匹配到数据时不会抛异常返回0而已。业务流程里要区分删除目标不存在和删除失败这两种情况。3.4 结果集解析稳定复用才是真优雅结果集ResultSet解析是封装里最容易被搞砸的环节。ResultSet是游标式访问用完要close()否则会泄漏连接资源。我在封装里统一处理了这个生命周期private parseNotesFromResultSet(resultSet: relationalStore.ResultSet): MeetingNote[] { const notes: MeetingNote[] []; try { while (resultSet.goToNextRow()) { const note: MeetingNote { id: resultSet.getValue(resultSet.getColumnIndex(id)) as number, title: resultSet.getValue(resultSet.getColumnIndex(title)) as string, content: resultSet.getValue(resultSet.getColumnIndex(content)) as string, location: resultSet.getValue(resultSet.getColumnIndex(location)) as string, startTime: resultSet.getValue(resultSet.getColumnIndex(start_time)) as number, endTime: resultSet.getValue(resultSet.getColumnIndex(end_time)) as number, tag: resultSet.getValue(resultSet.getColumnIndex(tag)) as string, createdAt: resultSet.getValue(resultSet.getColumnIndex(created_at)) as number, updatedAt: resultSet.getValue(resultSet.getColumnIndex(updated_at)) as number, }; notes.push(note); } return notes; } finally { resultSet.close(); } }关键点用finally保证异常路径也执行close()。这个是常规文档不会强调的但恰恰是最容易泄漏资源的地方。getValue返回的是联合类型需要强转。咱们封装调用方的时候就把转换做好了业务侧拿到的是干净的MeetingNote[]不用再关心游标怎么走。每次解析都走getColumnIndex查一次列索引其实有性能开销。数据量大时可以提前缓存列索引但对会议随记这种规模属于过度优化没必要。4. 业务层接入与异步处理4.1 调用姿势业务层看看有多干净单例封装完之后业务侧的代码变化是肉眼可见的。这是刚才按时间查列表的调用示例function loadMeetingList(): void { const manager SqliteManager.getInstance(); const params: MeetingQueryParams { startTime: startOfMonth.getTime(), endTime: endOfMonth.getTime(), page: 0, pageSize: 20, }; const notes await manager.queryMeetingList(params); // 直接拿到 MeetingNote[]往list控件上铺就行 this.meetingList notes; }主页面唯一的初始化代码是import { SqliteManager } from ../database/SqliteManager; async function onAbilityCreate(): Promisevoid { await SqliteManager.getInstance().init(this.context); }业务层不再出现任何表名、字段名、SQL关键字、ValuesBucket、RdbPredicates。这之后新进项目的同事甚至不需要懂SQLite就能把列表页写完因为他只需要知道有一个queryMeetingList方法可以捞数据就行了。我觉得这是封装最重要的收益降低团队的心智负担。封装完用起来有多顺从我只改了一处就调通了原来的全部业务代码也能看出来。之前6个页面各有各的数据库写法切成单例后每个页面就是改十几行重复代码的事。4.2 异步建模从回调地狱到await链鸿蒙RdbStore的API设计是Promise风格为主老版本有callback风格所以封装层的方法天然可以用async/await。业务层调用长这样async function handleSaveClick(): Promisevoid { const note: MeetingNote { title: this.titleInput, content: this.contentEditor, location: this.locationInput, startTime: this.startTime, endTime: this.endTime, tag: this.tagInput, createdAt: Date.now(), updatedAt: Date.now(), }; try { const id await SqliteManager.getInstance().insertMeetingNote(note); showToast(保存成功); } catch (e) { showToast(保存失败请稍后重试); } }这个用法跟其他平台的数据库写法几乎一致了。鸿蒙的并发模型是ArkTS的单线程事件循环加Worker数据库IO本身是异步的不阻塞UI线程。理论上封装层这里的异步就足够平滑了。不过我要提醒一点不要在非UI的Worker线程里使用同一个 RdbStore 实例。鸿蒙的RdbStore绑定的是创建它的Ability上下文跨线程使用会出问题。如果确实需要在Worker里做数据预聚合方案是单独在那个Worker里重新getRdbStore打开同一个数据库文件而不是共享单例。4.3 页面数据刷新与状态管理增删改查封装好之后业务侧最常见的坑反而是数据库里改了页面不刷新。这是因为ArkUI的响应式状态(State)不会被普通方法显式赋值自动感知数据改了需要显式触发更新。我这里的做法是在页面数据变更后重新查询并更新状态State meetingList: MeetingNote[] []; async function refreshList(): Promisevoid { const notes await SqliteManager.getInstance().queryMeetingList(this.params); this.meetingList notes; } async function onDelete(noteId: number): Promisevoid { await SqliteManager.getInstance().deleteMeetingNotesByIds([noteId]); await this.refreshList(); // 删除成功后重新捞列表 }这个模式简单可靠避免了在数据库层引入观察者模式的复杂度。对于会议随记这种数据频度不高的业务操作完重新查询就是最简单有效的方案。5. 踩坑实录与性能实测5.1 字段类型映射的隐性坑鸿蒙RdbStore返回的ResultSet里INTEGER类型默认映射成numberTEXT类型默认映射成string这看起来很合理对吧但有一个坑如果某一行字段的值是NULLgetValue返回的是null而类型上声明的是string或number强转之后就成了undefined。我在解析content允许为空这个字段时第一版直接as string结果空内容保存后读出来的值变成了undefined列表页渲染时直接崩了。后来在解析层加了一层兜底转换private safeGetString(resultSet: relationalStore.ResultSet, columnIndex: number): string { const value resultSet.getValue(columnIndex); return value null ? : String(value); } private safeGetNumber(resultSet: relationalStore.ResultSet, columnIndex: number): number { const value resultSet.getValue(columnIndex); return value null ? 0 : Number(value); }所有可空列的读取都走安全方法不再直接强转。这个改动虽小但帮我消灭了一整类页面莫名白屏的问题。5.2 并发写操作为什么会报锁错误项目里有个功能是会议录音转写转写进程完成一段就把文本追加进数据库。某次测试时发现偶发报错SQLITE_BUSY排查后发现是转写回调里逐段写入同时列表页还在做更新操作两个写操作在同一时间段并发SQLite的写锁机制抛了busy异常。解决思路不是加大超时时间而是做了写队列让所有写操作串行执行private writeChain: Promiseunknown Promise.resolve(); private enqueueWriteT(writeTask: () PromiseT): PromiseT { const runTask this.writeChain.then(writeTask); this.writeChain runTask.catch(() {}); return runTask; }用法就是封装层内部所有写方法都过enqueueWritepublic async insertMeetingNote(note: MeetingNote): Promisenumber { return this.enqueueWrite(async () { const store this.getStore(); return await store.insert(meeting_note, this.toValuesBucket(note)); }); }串行化之后SQLITE_BUSY再也没出现过。代价是并发写场景下的吞吐略降但对会议记录量级来说完全无损。如果你的App有高吞吐写需求可以改用批量写入 队列组合的方式优化。5.3 常用问题排查速查表我整理了这段时间实际遇到的高频问题方便遇到同样情况的朋友直接对照现象原因解法查询结果一直为空但数据库文件里能看到数据表名或字段名拼写不一致最常见是建表用下划线start_time查询时传了驼峰startTime统一在封装层维护字段名常量业务侧严禁手写字段名插入后拿到的id是0或负数建表时没有声明AUTOINCREMENT自增机制没生效建表SQL补上AUTOINCREMENT已有表需要重建或迁移页面操作卡顿掉帧在主线程同步执行了阻塞查询或ResultSet没有及时close确保所有查询走异步Promise结果集解析统一走parse方法自动close数据库升级崩溃版本号变了但ON UPGRADE回调里没做迁移逻辑ON UPGRADE里做字段兼容先查询旧表结构再决定ALTER方案莫名出现内存泄漏ResultSet、RdbStore关闭时机不对统一由封装层管理生命周期业务侧只拿数据不碰游标5.4 十万条数据查询实测设计这套封装时我关心的最后问题是十万条数据下增删改查到底能不能扛住我用一个脚本批量插了10万条模拟会议记录然后做了一轮基准测试操作耗时毫秒备注批量插入1000条约460ms开启事务比逐条快6倍多条件查询按时间范围命中约2000条约11ms走start_time索引关键字模糊查询命中约50条约38msLIKE %xxx%会全表扫描数据量大后是瓶颈按主键查询约2ms主键索引最快更新单条记录约3ms按主键定位删除100条约15ms按主键in删除实测下来流程业务完全够用。真正需要关注的是LIKE模糊查询那句%关键字%没法走索引数据量到几十万级别会明显变慢。如果后续会议纪要全文搜索成为高频需求考虑引入分词方案或者用FTS4全文索引SQLite在鸿蒙底层是支持这个能力的只不过封装层需要额外写虚拟表那时候再迭代即可。6. 后续还可以怎么扩展这个单例封装目前只针对会议随记pro的单表场景但底子打好了扩展方向其实很清晰。我自己计划中的两个方向供你参考一个是加数据迁移工具。应用版本迭代必然牵涉表结构变更目前的DB_VERSION从1升到2时需要在onUpgrade回调里写迁移SQL。封装层可以进一步抽象出版本迁移注册表每次升级只写一段从N到N1的迁移代码职责单一回滚也方便。另一个是引入数据同步监听。单例内部可以在增删改查的关键节点埋点向外抛DataChangeEvent。这样页面可以做局部刷新而不像我现在的方案一样操作完无条件整表重查。那个重查全表的方案在数据量小的时候很省事但数据量上来了会有体验问题。还有一点想说的封装要克制。我在封装过程中反复提醒自己不要因为写都写了就把所有SQL功能都包进去。像连表查询、聚合统计这种复用度低的功能通过queryBySql逃生门放出去就好。封装是为了让高频业务变得简单不是为了消灭所有SQL。保持核心单例小而美后续维护才长久。