小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健身助手得以在用户每一次点击"完成"按钮时,默默而可靠地将汗水转化为数据,为健康生活留下数字足迹。

相关推荐
ones~5 小时前
软件体系架构(三)
学习·架构·软件工程
Chloeis Syntax5 小时前
MySQL初阶学习日记(6)--- 索引
数据库·学习·mysql
0和1的舞者5 小时前
《MyBatis 从入门到上手:超全基础操作 + XML 配置指南》
数据库·spring boot·学习·spring·mybatis·框架·开发
狮智先生5 小时前
【学习笔记】利用blender生成的mesh模型(ply格式)并不是水密的
笔记·学习·blender
gis分享者5 小时前
学习threejs,生成复杂3D迷宫游戏
学习·游戏·3d·threejs·cannon·迷宫·cannon-es
deardao5 小时前
【张量等变学习】张量学习与正交,洛伦兹和辛对称
人工智能·学习·自然语言处理
会编程的吕洞宾5 小时前
智能体学习记录一
人工智能·学习
啄缘之间7 小时前
10.基于 MARCH C+ 算法的SRAM BIST
经验分享·笔记·学习·verilog
石像鬼₧魂石14 小时前
如何配置Fail2Ban的Jail?
linux·学习·ubuntu