小V健身助手开发手记(五):基于 RDB 的历史记录系统设计与实现

小V健身助手开发手记(五)

数据库架构设计与实现详解

目前项目已经移植到坚果派,可以通过坚果派直接访问:项目传送门

在小V健身助手的开发过程中,数据持久化是支撑整个应用功能运转的核心模块。无论是用户每日打卡记录、运动项目管理,还是历史数据统计分析,都离不开一套稳定、高效、可维护的本地数据库系统。本文将深入剖析我们在小V健身助手中采用的数据库设计方案,从表结构定义、ORM映射、CRUD操作封装,到数据库初始化流程和工具类抽象,全面解读其技术实现细节。


一、为什么选择关系型数据库?

小V健身助手运行在HarmonyOS平台,基于ArkTS语言开发。HarmonyOS提供了多种本地存储方案,包括:

  • Preferences:轻量级键值对存储,适合配置项;
  • Relational Database(RDB):SQLite兼容的关系型数据库,适合结构化数据;
  • Distributed Data Object(DDM):用于跨设备同步。

考虑到我们的核心数据------运动记录(如跳绳次数、跑步时长等)具有明确的字段结构、需要支持复杂查询(如"按日期范围查询")、且可能随时间增长形成大量记录,我们最终选择了 Relational Database(RDB) 作为主存储引擎。

优势

  • 支持事务、索引、约束;
  • 可高效执行条件查询、分组、排序;
  • ArkTS 提供了完善的 relationalStore API 封装。

二、数据模型设计:从业务对象到数据库表

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)存储,便于跨平台处理。

🔍 注意 :虽然 amountRecordPO 中是 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 }
];

⚠️ 潜在问题amountCOLUMNS 中被标记为 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 略...
}

这种分层设计使得:

  • 业务层 只与 RecordPORecordModel 交互;
  • 数据访问层(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_timekeep_id 添加索引提升查询性能
无事务支持 在批量插入/更新时启用事务

九 功能展示

十、总结

小V健身助手的数据库模块通过 分层架构 + 类型安全映射 + 单例工具封装 ,实现了高内聚、低耦合的设计目标。从 RecordPODataRecord,再到 ValuesBucketResultSet,每一步转换都经过精心抽象,既保证了代码可读性,又提升了可维护性。

这套方案不仅适用于健身记录场景,也可作为 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健身助手得以在用户每一次点击"完成"按钮时,默默而可靠地将汗水转化为数据,为健康生活留下数字足迹。

相关推荐
西岸行者6 天前
学习笔记:SKILLS 能帮助更好的vibe coding
笔记·学习
悠哉悠哉愿意6 天前
【单片机学习笔记】串口、超声波、NE555的同时使用
笔记·单片机·学习
别催小唐敲代码6 天前
嵌入式学习路线
学习
毛小茛6 天前
计算机系统概论——校验码
学习
babe小鑫6 天前
大专经济信息管理专业学习数据分析的必要性
学习·数据挖掘·数据分析
winfreedoms6 天前
ROS2知识大白话
笔记·学习·ros2
在这habit之下6 天前
Linux Virtual Server(LVS)学习总结
linux·学习·lvs
我想我不够好。6 天前
2026.2.25监控学习
学习
im_AMBER6 天前
Leetcode 127 删除有序数组中的重复项 | 删除有序数组中的重复项 II
数据结构·学习·算法·leetcode
CodeJourney_J6 天前
从“Hello World“ 开始 C++
c语言·c++·学习