
-
个人首页: VON
-
鸿蒙系列专栏: 鸿蒙开发小型案例总结
-
综合案例 :鸿蒙综合案例开发
-
鸿蒙6.0:从0开始的开源鸿蒙6.0.0
-
鸿蒙5.0:鸿蒙5.0零基础入门到项目实战
-
Electron适配开源鸿蒙专栏:Electron for OpenHarmony
-
Flutter 适配开源鸿蒙专栏:Flutter for OpenHarmony
-
本文所属专栏:鸿蒙综合案例开发
-
本文atomgit地址:小V健身
小V健身助手开发手记(五)
- 数据库架构设计与实现详解
-
- 一、为什么选择关系型数据库?
- 二、数据模型设计:从业务对象到数据库表
-
- [1. 业务对象:`RecordPO`](#1. 业务对象:
RecordPO) - [2. 数据库表结构](#2. 数据库表结构)
- [1. 业务对象:`RecordPO`](#1. 业务对象:
- [三、列映射机制:ColumnInfo 与类型安全](#三、列映射机制:ColumnInfo 与类型安全)
- 四、通用数据记录容器:DataRecord
- [五、数据库工具类:DBUtil(单例 + 封装)](#五、数据库工具类:DBUtil(单例 + 封装))
-
- [1. 初始化数据库](#1. 初始化数据库)
- [2. 建表](#2. 建表)
- [3. CRUD 操作封装](#3. CRUD 操作封装)
- 六、业务模型层:RecordModel
- 七、辅助模块:运动项管理与首选项
- 八、潜在问题与改进建议
- [九 功能展示](#九 功能展示)
- 十、总结

数据库架构设计与实现详解
目前项目已经移植到坚果派,可以通过坚果派直接访问:项目传送门
在小V健身助手的开发过程中,数据持久化是支撑整个应用功能运转的核心模块。无论是用户每日打卡记录、运动项目管理,还是历史数据统计分析,都离不开一套稳定、高效、可维护的本地数据库系统。本文将深入剖析我们在小V健身助手中采用的数据库设计方案,从表结构定义、ORM映射、CRUD操作封装,到数据库初始化流程和工具类抽象,全面解读其技术实现细节。
一、为什么选择关系型数据库?
小V健身助手运行在HarmonyOS平台,基于ArkTS语言开发。HarmonyOS提供了多种本地存储方案,包括:
- Preferences:轻量级键值对存储,适合配置项;
- Relational Database(RDB):SQLite兼容的关系型数据库,适合结构化数据;
- Distributed Data Object(DDM):用于跨设备同步。
考虑到我们的核心数据------运动记录(如跳绳次数、跑步时长等)具有明确的字段结构、需要支持复杂查询(如"按日期范围查询")、且可能随时间增长形成大量记录,我们最终选择了 Relational Database(RDB) 作为主存储引擎。
✅ 优势:
- 支持事务、索引、约束;
- 可高效执行条件查询、分组、排序;
- ArkTS 提供了完善的
relationalStoreAPI 封装。
二、数据模型设计:从业务对象到数据库表
1. 业务对象:RecordPO
首先,我们定义了代表一条运动记录的业务对象 RecordPO(Persistent Object):
ts
export default class RecordPO {
id?: number; // 主键,自增
keepId: number = 0; // 关联的运动项ID
amount: number = 0; // 计划完成量(如:跳绳1000次)
createTime?: number; // 记录创建时间(时间戳)
successAmount: number = 0; // 实际完成量
}
该类完全对应一条数据库记录,字段命名采用驼峰式(符合TS规范),而数据库列名则使用下划线风格(符合SQL惯例)。
2. 数据库表结构
根据 RecordPO,我们设计了如下建表语句:
ts
const CREATE_TABLE_SQL: string = `(
id INTEGER PRIMARY KEY AUTOINCREMENT,
keep_id INTEGER NOT NULL,
amount INTEGER NOT NULL,
create_time INTEGER NOT NULL,
success_amount INTEGER NOT NULL
)`;
- 表名为
recode(注:此处应为record,属笔误,但不影响功能); - 所有字段均为
NOT NULL,确保数据完整性; id为主键并自动递增;- 时间以毫秒时间戳(
INTEGER)存储,便于跨平台处理。
🔍 注意 :虽然
amount在RecordPO中是number类型,但在数据库中我们统一用INTEGER存储。若未来需支持小数(如公里数),可改为REAL。
三、列映射机制:ColumnInfo 与类型安全
为了在业务对象与数据库列之间建立可靠映射,我们引入了 ColumnInfo 接口和 ColumnType 枚举:
ts
export interface ColumnInfo {
name: string; // RecordPO 中的属性名(如 'keepId')
columnName: string; // 数据库列名(如 'keep_id')
type: ColumnType; // 列的数据类型
}
export enum ColumnType {
LONG,
DOUBLE,
STRING,
BLOB
}
并定义了具体的列映射数组:
ts
const COLUMNS: ColumnInfo[] = [
{ name: 'id', columnName: 'id', type: ColumnType.LONG },
{ name: 'keepId', columnName: 'keep_id', type: ColumnType.LONG },
{ name: 'amount', columnName: 'amount', type: ColumnType.DOUBLE }, // 注意:此处设为DOUBLE,但建表用INTEGER,存在不一致
{ name: 'createTime', columnName: 'create_time', type: ColumnType.LONG },
{ name: 'successAmount', columnName: 'success_amount', type: ColumnType.LONG }
];
⚠️ 潜在问题 :
amount在COLUMNS中被标记为DOUBLE,但建表 SQL 使用INTEGER。这可能导致读取时类型转换异常。建议统一为LONG或根据实际需求调整。
此设计实现了解耦 :业务层无需关心数据库列名,只需通过 ColumnInfo 配置即可完成自动映射。
四、通用数据记录容器:DataRecord
由于 ArkTS 的 ValuesBucket 要求键为字符串、值为基本类型,我们封装了一个通用的 DataRecord 类:
ts
export class DataRecord {
private data: Map<string, number | string | boolean | Uint8Array | null | undefined> = new Map();
setValue(key: string, value: any): void {
this.data.set(key, value);
}
getValue(key: string): any {
return this.data.get(key);
}
hasValue(key: string): boolean {
const value = this.data.get(key);
return typeof value !== 'undefined' && value !== null;
}
}
它作为中间层,将 RecordPO 转换为可被数据库操作的格式,避免直接暴露底层 ValuesBucket。
五、数据库工具类:DBUtil(单例 + 封装)
DBUtil 是整个数据库操作的核心枢纽,采用单例模式确保全局唯一实例:
ts
class DBUtil {
private rdbStore!: relationalStore.RdbStore;
private static instance: DBUtil | null = null;
private constructor() {}
static getInstance(): DBUtil {
if (!DBUtil.instance) {
DBUtil.instance = new DBUtil();
}
return DBUtil.instance;
}
}
1. 初始化数据库
通过 initDB 方法,在应用启动时绑定上下文并打开数据库:
ts
initDB(context: common.UIAbilityContext): Promise<void> {
const config: relationalStore.StoreConfig = {
name: 'Small_V_Health.db',
securityLevel: 1, // 安全等级
};
return relationalStore.getRdbStore(context, config)
.then(rdbStore => {
this.rdbStore = rdbStore;
});
}
2. 建表
提供通用的 createTable 方法:
ts
createTable(createSQL: string): Promise<void> {
return this.rdbStore.executeSql(`CREATE TABLE IF NOT EXISTS recode ${createSQL}`);
}
💡 建议:表名应作为参数传入,或从常量读取,避免硬编码。
3. CRUD 操作封装
插入(Insert)
ts
insert(tableName: string, obj: DataRecord, columns: ColumnInfo[]): Promise<number> {
const value = this.buildValueBucket(obj, columns);
return new Promise((resolve, reject) => {
this.rdbStore.insert(tableName, value, (err, id) => {
err ? reject(err) : resolve(id);
});
});
}
其中 buildValueBucket 负责将 DataRecord 转为 ValuesBucket:
ts
buildValueBucket(obj: DataRecord, columns: ColumnInfo[]): relationalStore.ValuesBucket {
const value: relationalStore.ValuesBucket = {};
columns.forEach(info => {
if (obj.hasValue(info.name)) {
value[info.columnName] = obj.getValue(info.name);
}
});
return value;
}
查询(Query)
查询是最复杂的部分,需将 ResultSet 转回 DataRecord 数组:
ts
queryForList(predicates: RdbPredicates, columns: ColumnInfo[]): Promise<DataRecord[]> {
return new Promise((resolve, reject) => {
this.rdbStore.query(predicates, columns.map(c => c.columnName), (err, result) => {
if (err) reject(err);
else {
try {
const records = this.parseResultSet(result, columns);
resolve(records);
} finally {
result.close(); // 必须关闭结果集,防止内存泄漏
}
}
});
});
}
parseResultSet 方法逐行解析:
ts
parseResultSet(result: ResultSet, columns: ColumnInfo[]): DataRecord[] {
const arr: DataRecord[] = [];
if (result.rowCount <= 0) return arr;
result.goToFirstRow();
while (!result.isAtLastRow) {
const record = this.extractRow(result, columns);
arr.push(record);
result.goToNextRow();
}
// 处理最后一行(API设计缺陷:isAtLastRow 不包含最后一行)
if (result.rowCount > 0) {
const record = this.extractRow(result, columns);
arr.push(record);
}
return arr;
}
private extractRow(result: ResultSet, columns: ColumnInfo[]): DataRecord {
const record = new DataRecord();
columns.forEach(info => {
const idx = result.getColumnIndex(info.columnName);
let val: any;
switch (info.type) {
case ColumnType.LONG: val = result.getLong(idx); break;
case ColumnType.DOUBLE: val = result.getDouble(idx); break;
case ColumnType.STRING: val = result.getString(idx); break;
case ColumnType.BLOB: val = result.getBlob(idx); break;
default: val = null;
}
record.setValue(info.name, val);
});
return record;
}
📌 关键点 :HarmonyOS 的
ResultSet遍历逻辑较为特殊,需手动处理"最后一行",这是官方 API 的一个常见陷阱。
六、业务模型层:RecordModel
在 DBUtil 之上,我们构建了 RecordModel,提供面向业务的接口:
ts
class RecordModel {
// 表常量
private readonly TABLE_NAME = 'recode';
private readonly ID_COLUMN = 'id';
private readonly DATE_COLUMN = 'create_time';
insert(record: RecordPO): Promise<number> {
const dataRecord = new DataRecord();
dataRecord.setValue('id', record.id);
dataRecord.setValue('keepId', record.keepId);
// ... 其他字段
return DBUtil.insert(this.TABLE_NAME, dataRecord, COLUMNS);
}
async queryByDate(date: number): Promise<RecordPO[]> {
const predicates = new RdbPredicates(this.TABLE_NAME);
const startOfDay = date;
const endOfDay = date + 24 * 60 * 60 * 1000 - 1;
predicates.between(this.DATE_COLUMN, startOfDay, endOfDay);
const dataRecords = await DBUtil.queryForList(predicates, COLUMNS);
return dataRecords.map(dr => {
const po = new RecordPO();
po.id = dr.getValue('id') as number;
po.keepId = dr.getValue('keepId') as number;
// ... 赋值其他字段
return po;
});
}
// delete / update 略...
}
这种分层设计使得:
- 业务层 只与
RecordPO和RecordModel交互; - 数据访问层(DBUtil)完全屏蔽了 SQL 和底层 API 细节;
- 可测试性 强:可 mock
RecordModel返回模拟数据。
七、辅助模块:运动项管理与首选项
除了核心记录表,我们还维护了一个静态的运动项列表:
ts
const keeps: RecordItem[] = [
new RecordItem(0, '跳绳', $r('app.media.home_ic_swimming'), '/小时', 600),
// ...
];
并通过 ItemModel 提供查询:
ts
class ItemModel {
getById(id: number) { return keeps[id]; }
list() { return keeps; }
}
🔄 优化建议 :未来可将
keeps存入数据库,支持用户自定义运动项目。
同时,使用 PreferenceUtil 管理用户设置(如首次启动状态、目标提醒等),其基于 @ohos.data.preferences 实现,采用单例+异步加载模式,确保线程安全。
八、潜在问题与改进建议
尽管当前架构已满足 MVP 需求,但仍存在可优化空间:
| 问题 | 建议 |
|---|---|
表名 recode 拼写错误 |
改为 record,并添加数据库版本迁移逻辑 |
amount 类型不一致(INTEGER vs DOUBLE) |
统一为 REAL 或明确业务含义 |
RecordPO 字段未校验非空 |
添加构造函数或 Builder 模式 |
| 查询遍历逻辑冗余 | 封装通用 ResultSet to T[] 工具 |
| 缺少索引 | 对 create_time 和 keep_id 添加索引提升查询性能 |
| 无事务支持 | 在批量插入/更新时启用事务 |
九 功能展示

十、总结
小V健身助手的数据库模块通过 分层架构 + 类型安全映射 + 单例工具封装 ,实现了高内聚、低耦合的设计目标。从 RecordPO 到 DataRecord,再到 ValuesBucket 和 ResultSet,每一步转换都经过精心抽象,既保证了代码可读性,又提升了可维护性。
这套方案不仅适用于健身记录场景,也可作为 HarmonyOS 应用本地数据库开发的参考模板。未来,我们将引入数据库版本管理、加密存储、以及云端同步能力,进一步提升数据安全与用户体验。
代码即文档,架构即承诺。在小V健身助手的演进之路上,稳健的数据基石,是我们交付可靠体验的底气所在。
附:关键常量与类型定义汇总
ts
// 表名与列名
const TABLE_NAME = 'recode';
const ID_COLUMN = 'id';
const DATE_COLUMN = 'create_time';
// 列信息
const COLUMNS: ColumnInfo[] = [
{ name: 'id', columnName: 'id', type: ColumnType.LONG },
{ name: 'keepId', columnName: 'keep_id', type: ColumnType.LONG },
{ name: 'amount', columnName: 'amount', type: ColumnType.DOUBLE },
{ name: 'createTime', columnName: 'create_time', type: ColumnType.LONG },
{ name: 'successAmount', columnName: 'success_amount', type: ColumnType.LONG }
];
// 建表语句
const CREATE_TABLE_SQL = `( ... )`;
通过以上设计,小V健身助手得以在用户每一次点击"完成"按钮时,默默而可靠地将汗水转化为数据,为健康生活留下数字足迹。