【鸿蒙原生开发会议随记 Pro】 增删改查 封装一个优雅的 SQLite 数据库单例
对于大家来说,调用 API 打开数据库并不难。难的是如何优雅地管理这个数据库连接。你是否遇到过这样的情况:App 运行久了变得卡顿,排查发现是数据库连接打开了无数次却没关闭?或者 App 发布了 2.0 版本,用户更新后一打开就闪退,因为你加了新表却忘记处理旧数据的迁移?
在上一篇文章中,我们像绘制建筑蓝图一样,设计了 Project、Contact、Meeting 三张核心表,并理清了它们之间错综复杂的关联关系。我们还决定抛弃简单的 UserPreferences,拥抱强大的 RelationalStore。但这仅仅是纸上谈兵。
现在的数据库还只是躺在代码文件里的几行 SQL 字符串,它既不能存数据,也不能查数据。
今天,我们要当一次泥瓦匠,把这些蓝图变成实实在在的房子。
对于大家来说,调用 API 打开数据库并不难。难的是如何优雅地管理这个数据库连接。你是否遇到过这样的情况:App 运行久了变得卡顿,排查发现是数据库连接打开了无数次却没关闭?或者 App 发布了 2.0 版本,用户更新后一打开就闪退,因为你加了新表却忘记处理旧数据的迁移?
这些都是烂代码埋下的雷。今天我们要写的代码,就是要排掉这些雷。我们要封装一个全局单例的 RdbManager,它不仅能安全地管理数据库连接,还能聪明地处理版本升级。

一、 为什么必须是单例?
在移动端开发中,数据库连接(RdbStore)是一个非常重的对象。它对应着底层的文件句柄和内存缓冲区。如果你在每次点击保存按钮时都去 getRdbStore,用完又忘了关,或者在多个页面同时持有多个连接实例,那么很快你的 App 就会因为资源耗尽而崩溃,或者遇到文件锁冲突导致写入失败。
所以,我们必须强制使用单例模式。这意味着,无论你的 App 有多少个页面,无论你何时何地需要查数据,你拿到的永远是同一个数据库连接实例。
让我们打开 entry/src/main/ets/data/db/RdbManager.ts,对上一篇的雏形进行深度改造。这一次,我们要加入一个至关重要的逻辑:STORE_CONFIG 的完整配置。
// entry/src/main/ets/data/db/RdbManager.ts
import { relationalStore } from '@kit.ArkData';
import { common } from '@kit.AbilityKit';
import {
DB_NAME,
DB_SECURITY_LEVEL,
SQL_CREATE_PROJECT,
SQL_CREATE_CONTACT,
SQL_CREATE_MEETING
} from './MeetingRdb';
export class RdbManager {
// 静态私有变量,持有唯一实例
private static instance: RdbManager;
// 持有数据库操作对象
private rdbStore: relationalStore.RdbStore | null = null;
// 应用上下文,初始化时注入
private context: common.UIAbilityContext | null = null;
// 这里的版本号非常关键,后续升级全靠它
private static readonly DB_VERSION = 1;
/**
* 获取单例
* 无论调用多少次,返回的都是同一个 RdbManager 对象
*/
public static getInstance(): RdbManager {
if (!RdbManager.instance) {
RdbManager.instance = new RdbManager();
}
return RdbManager.instance;
}
/**
* 初始化方法
* 建议在 EntryAbility 的 onCreate 中调用
* 这样 App 一启动,上下文就准备好了
*/
public init(context: common.UIAbilityContext): void {
this.context = context;
// 可以在这里预热数据库,也可以懒加载
}
/**
* 核心方法:获取 RdbStore
* 采用了“懒加载”策略,只有真正需要查数据时才打开连接
*/
public async getRdbStore(): Promise<relationalStore.RdbStore> {
// 1. 如果已经打开且未关闭,直接返回复用
if (this.rdbStore) {
return this.rdbStore;
}
// 2. 检查 Context 是否注入
if (!this.context) {
throw new Error('[RdbManager] Context is null! Call init() first.');
}
// 3. 配置数据库参数
const config: relationalStore.StoreConfig = {
name: DB_NAME,
securityLevel: DB_SECURITY_LEVEL,
// 开启加密(可选,商业级项目建议开启,这里暂不演示)
// encrypt: false
};
try {
// 4. 调用系统 API 打开或创建数据库
this.rdbStore = await relationalStore.getRdbStore(this.context, config);
// 5. 检查版本并升级
// 这是一个非常关键的步骤,决定了你的 App 能否平滑更新
if (this.rdbStore.version === 0) {
// version 为 0 说明是全新安装,直接建表
await this.initTables(this.rdbStore);
this.rdbStore.version = RdbManager.DB_VERSION;
} else if (this.rdbStore.version < RdbManager.DB_VERSION) {
// 这里的 version 是旧版本号,说明用户是覆盖安装
// 我们需要执行升级逻辑
await this.upgradeTables(this.rdbStore, this.rdbStore.version, RdbManager.DB_VERSION);
this.rdbStore.version = RdbManager.DB_VERSION;
}
return this.rdbStore;
} catch (e) {
console.error(`[RdbManager] Get Store Failed: ${JSON.stringify(e)}`);
// 这里可以抛出自定义异常,让 UI 层捕获并提示用户
throw new Error('Database init failed');
}
}
/**
* 第一次建表逻辑
* 干净利落,把所有 CREATE TABLE 语句执行一遍
*/
private async initTables(store: relationalStore.RdbStore) {
console.info('[RdbManager] Initialize tables...');
// 使用事务可以加快批量执行速度
store.beginTransaction();
try {
await store.executeSql(SQL_CREATE_PROJECT);
await store.executeSql(SQL_CREATE_CONTACT);
await store.executeSql(SQL_CREATE_MEETING);
store.commit();
} catch (e) {
console.error(`[RdbManager] Init tables failed: ${e}`);
store.rollBack();
throw e;
}
}
/**
* 数据库升级逻辑
* 随着 App 版本迭代,这里会越来越长
*/
private async upgradeTables(store: relationalStore.RdbStore, oldVersion: number, newVersion: number) {
console.info(`[RdbManager] Upgrade DB from ${oldVersion} to ${newVersion}`);
// 假设未来我们发布了 2.0 版本,DB_VERSION 变成了 2
// if (oldVersion < 2) {
// // 在 Project 表加一个字段 "sort_order"
// await store.executeSql('ALTER TABLE project ADD COLUMN sort_order INTEGER');
// }
// 假设发布了 3.0 版本
// if (oldVersion < 3) { ... }
}
}
通过这段代码,我们把复杂的生命周期管理封装在了一个黑盒子里。对于调用者(比如 ViewModel)来说,它只需要 await RdbManager.getInstance().getRdbStore(),就能拿到一个健康、可用、版本正确的数据库对象,完全不需要关心底下发生了什么。
二、 DAO 层与 CRUD 实战
有了管家,我们还需要工人。在架构设计中,我们通常会引入 DAO(Data Access Object) 层,或者叫 Repository(仓库) 层,专门负责具体的增删改查业务。我们不要把 SQL 语句散落在 App 的各个角落。
请在 entry/src/main/ets/data/db 目录下新建 ProjectRepo.ts。我们以项目(Project)这个最基础的实体为例,来演示如何实现一个标准的 CRUD 流程。
这里有一个痛点:数据库 API 需要的是 ValuesBucket(用于插入)和返回 ResultSet(用于查询),而我们的业务层需要的是 Project 对象。中间的转换代码写起来非常枯燥,但必须写,而且要写得健壮。
我们先写插入(Create)。
TypeScript
// entry/src/main/ets/data/db/ProjectRepo.ts
import { relationalStore } from '@kit.ArkData';
import { Project } from '../models/Project';
import { RdbManager } from './RdbManager';
import { TABLE_PROJECT } from './MeetingRdb';
/**
* 插入一个新项目
* @param project 业务对象
* @returns 插入的行 ID
*/
export async function insertProject(project: Project): Promise<number> {
// 1. 获取数据库实例
const store = await RdbManager.getInstance().getRdbStore();
// 2. 构建 ValuesBucket
// 这是鸿蒙 RdbStore 要求的特定格式,类似于一个 Map
// 键是数据库列名,值是数据
// 注意:我们这里手动映射了驼峰属性到下划线列名
const valueBucket: relationalStore.ValuesBucket = {
'id': project.id,
'name': project.name,
'description': project.description || '', // 处理 undefined
'color': project.color,
'created_at': project.createdAt,
'updated_at': project.updatedAt
};
// 3. 执行插入
// relationalStore.ConflictResolution.REPLACE 表示如果 ID 冲突则覆盖
// 这对于去重非常有用
const rowId = await store.insert(TABLE_PROJECT, valueBucket, relationalStore.ConflictResolution.REPLACE);
return rowId;
}
接下来是查询(Read)。这是最繁琐的部分,因为 ResultSet 是一个游标,我们需要遍历它,把每一列的数据取出来,填到 Project 对象里。
为了不让重复代码淹没我们,建议把“单行转对象”的逻辑抽离成一个 Helper 函数。
TypeScript
// entry/src/main/ets/data/db/ProjectRepo.ts
/**
* 查询所有项目
* 按更新时间倒序排列(最近活跃的在前)
*/
export async function queryAllProjects(): Promise<Project[]> {
const store = await RdbManager.getInstance().getRdbStore();
// 1. 构建查询条件
// RdbPredicates 是鸿蒙提供的强大的查询构建器,类似 SQL 的 WHERE 子句
const predicates = new relationalStore.RdbPredicates(TABLE_PROJECT);
predicates.orderByDesc('updated_at'); // 排序
// 2. 执行查询
const resultSet = await store.query(predicates);
// 3. 遍历结果集
const projects: Project[] = [];
try {
// goToNextRow() 返回 false 表示游标走到头了
while (resultSet.goToNextRow()) {
// 调用转换函数
projects.push(convertRowToProject(resultSet));
}
} finally {
// 4. 极其重要:关闭 ResultSet 释放内存!
// 很多内存泄漏都是因为忘了这一步
resultSet.close();
}
return projects;
}
/**
* 辅助函数:将当前游标指向的行转换为 Project 对象
*/
function convertRowToProject(resultSet: relationalStore.ResultSet): Project {
// getColumnIndex 是为了防范列名写错或者列不存在的情况
// 虽然稍微损耗一点性能,但更安全
return {
id: resultSet.getString(resultSet.getColumnIndex('id')),
name: resultSet.getString(resultSet.getColumnIndex('name')),
description: resultSet.getString(resultSet.getColumnIndex('description')),
color: resultSet.getString(resultSet.getColumnIndex('color')),
// getLong 对应数据库的 INTEGER
createdAt: resultSet.getLong(resultSet.getColumnIndex('created_at')),
updatedAt: resultSet.getLong(resultSet.getColumnIndex('updated_at'))
};
}
你看,通过这种模式,无论 Project 表有多少列,复杂的取值逻辑都被封装在了 convertRowToProject 这一处。以后如果我们要给项目加个“图标”字段,只需要改这一个函数,而不用去改每一个查询方法。
最后简单带过 更新(Update) 和 删除(Delete)。它们的逻辑和查询类似,都是先构建 RdbPredicates 来锁定要操作的行。
TypeScript
// entry/src/main/ets/data/db/ProjectRepo.ts
export async function updateProjectName(id: string, newName: string): Promise<number> {
const store = await RdbManager.getInstance().getRdbStore();
const valueBucket: relationalStore.ValuesBucket = {
'name': newName,
'updated_at': Date.now() // 记得更新时间戳
};
const predicates = new relationalStore.RdbPredicates(TABLE_PROJECT);
predicates.equalTo('id', id); // WHERE id = ?
return store.update(valueBucket, predicates);
}
export async function deleteProject(id: string): Promise<number> {
const store = await RdbManager.getInstance().getRdbStore();
const predicates = new relationalStore.RdbPredicates(TABLE_PROJECT);
predicates.equalTo('id', id);
return store.delete(predicates);
}
三、 在 EntryAbility 中激活它
现在,我们的武器库已经准备好了,但它们还没被加载。我们需要在 App 启动的最早时机,把 Context 注入给 RdbManager。
打开 entry/src/main/ets/entryability/EntryAbility.ts。这是 Stage 模型中 Ability 的生命周期入口。
// entry/src/main/ets/entryability/EntryAbility.ts
import { UIAbility, AbilityConstant, Want } from '@kit.AbilityKit';
import { window } from '@kit.ArkUI';
import { RdbManager } from '../data/db/RdbManager';
export default class EntryAbility extends UIAbility {
onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
// 在 Ability 创建时,初始化数据库管理器
// 传入 context,这样 RdbManager 就能拿到沙箱路径
RdbManager.getInstance().init(this.context);
console.info('[EntryAbility] RdbManager initialized');
}
// ... 其他生命周期方法
}
这里有一个常识性的问题:为什么不在 Index.ets 的 aboutToAppear 里初始化?
因为 Index.ets 是 UI 界面。如果你的 App 支持后台运行或者卡片(Service Widget)启动,有可能 UI 界面根本不需要显示,但后台服务需要读写数据库。
如果在 UI 里初始化,后台任务就会因为拿不到 Context 而崩溃。在 EntryAbility 初始化是目前最稳妥的做法。
四、 总结
今天,我们干了一件非常硬核的事情。
我们不仅写了代码,更是在构建基础设施。我们封装了一个线程安全、支持版本升级的 RdbManager 单例,彻底解决了数据库连接管理的后顾之忧。我们还实践了标准的 DAO 模式,将底层的 ValuesBucket 和 ResultSet 转换逻辑屏蔽在 Repo 文件内部,为上层的 ViewModel 提供了干净清爽的 TypeScript 接口。
现在的会议随记 Pro,已经具备了记忆能力。它不再是那个重启就会失忆的 Demo,而是一个能持久化存储用户资产的商业级应用雏形。
更多推荐




所有评论(0)