news 2026/4/16 17:59:50

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
小V健身助手开发手记(五):基于 RDB 的历史记录系统设计与实现

  • 个人首页: VON

  • 鸿蒙系列专栏: 鸿蒙开发小型案例总结

  • 综合案例 :鸿蒙综合案例开发

  • 鸿蒙6.0:从0开始的开源鸿蒙6.0.0

  • 鸿蒙5.0:鸿蒙5.0零基础入门到项目实战

  • Electron适配开源鸿蒙专栏:Electron for OpenHarmony

  • Flutter 适配开源鸿蒙专栏:Flutter for OpenHarmony

  • 本文所属专栏:鸿蒙综合案例开发

  • 本文atomgit地址:小V健身

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

  • 数据库架构设计与实现详解
    • 一、为什么选择关系型数据库?
    • 二、数据模型设计:从业务对象到数据库表
      • 1. 业务对象:`RecordPO`
      • 2. 数据库表结构
    • 三、列映射机制:ColumnInfo 与类型安全
    • 四、通用数据记录容器:DataRecord
    • 五、数据库工具类:DBUtil(单例 + 封装)
      • 1. 初始化数据库
      • 2. 建表
      • 3. CRUD 操作封装
        • 插入(Insert)
        • 查询(Query)
    • 六、业务模型层: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):

exportdefaultclassRecordPO{id?:number;// 主键,自增keepId:number=0;// 关联的运动项IDamount:number=0;// 计划完成量(如:跳绳1000次)createTime?:number;// 记录创建时间(时间戳)successAmount:number=0;// 实际完成量}

该类完全对应一条数据库记录,字段命名采用驼峰式(符合TS规范),而数据库列名则使用下划线风格(符合SQL惯例)。

2. 数据库表结构

根据RecordPO,我们设计了如下建表语句:

constCREATE_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枚举:

exportinterfaceColumnInfo{name:string;// RecordPO 中的属性名(如 'keepId')columnName:string;// 数据库列名(如 'keep_id')type:ColumnType;// 列的数据类型}exportenumColumnType{LONG,DOUBLE,STRING,BLOB}

并定义了具体的列映射数组:

constCOLUMNS: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类:

exportclassDataRecord{privatedata:Map<string,number|string|boolean|Uint8Array|null|undefined>=newMap();setValue(key:string,value:any):void{this.data.set(key,value);}getValue(key:string):any{returnthis.data.get(key);}hasValue(key:string):boolean{constvalue=this.data.get(key);returntypeofvalue!=='undefined'&&value!==null;}}

它作为中间层,将RecordPO转换为可被数据库操作的格式,避免直接暴露底层ValuesBucket


五、数据库工具类:DBUtil(单例 + 封装)

DBUtil是整个数据库操作的核心枢纽,采用单例模式确保全局唯一实例:

classDBUtil{privaterdbStore!:relationalStore.RdbStore;privatestaticinstance:DBUtil|null=null;privateconstructor(){}staticgetInstance():DBUtil{if(!DBUtil.instance){DBUtil.instance=newDBUtil();}returnDBUtil.instance;}}

1. 初始化数据库

通过initDB方法,在应用启动时绑定上下文并打开数据库:

initDB(context:common.UIAbilityContext):Promise<void>{constconfig:relationalStore.StoreConfig={name:'Small_V_Health.db',securityLevel:1,// 安全等级};returnrelationalStore.getRdbStore(context,config).then(rdbStore=>{this.rdbStore=rdbStore;});}

2. 建表

提供通用的createTable方法:

createTable(createSQL:string):Promise<void>{returnthis.rdbStore.executeSql(`CREATE TABLE IF NOT EXISTS recode${createSQL}`);}

💡 建议:表名应作为参数传入,或从常量读取,避免硬编码。

3. CRUD 操作封装

插入(Insert)
insert(tableName:string,obj:DataRecord,columns:ColumnInfo[]):Promise<number>{constvalue=this.buildValueBucket(obj,columns);returnnewPromise((resolve,reject)=>{this.rdbStore.insert(tableName,value,(err,id)=>{err?reject(err):resolve(id);});});}

其中buildValueBucket负责将DataRecord转为ValuesBucket

buildValueBucket(obj:DataRecord,columns:ColumnInfo[]):relationalStore.ValuesBucket{constvalue:relationalStore.ValuesBucket={};columns.forEach(info=>{if(obj.hasValue(info.name)){value[info.columnName]=obj.getValue(info.name);}});returnvalue;}
查询(Query)

查询是最复杂的部分,需将ResultSet转回DataRecord数组:

queryForList(predicates:RdbPredicates,columns:ColumnInfo[]):Promise<DataRecord[]>{returnnewPromise((resolve,reject)=>{this.rdbStore.query(predicates,columns.map(c=>c.columnName),(err,result)=>{if(err)reject(err);else{try{constrecords=this.parseResultSet(result,columns);resolve(records);}finally{result.close();// 必须关闭结果集,防止内存泄漏}}});});}

parseResultSet方法逐行解析:

parseResultSet(result:ResultSet,columns:ColumnInfo[]):DataRecord[]{constarr:DataRecord[]=[];if(result.rowCount<=0)returnarr;result.goToFirstRow();while(!result.isAtLastRow){constrecord=this.extractRow(result,columns);arr.push(record);result.goToNextRow();}// 处理最后一行(API设计缺陷:isAtLastRow 不包含最后一行)if(result.rowCount>0){constrecord=this.extractRow(result,columns);arr.push(record);}returnarr;}privateextractRow(result:ResultSet,columns:ColumnInfo[]):DataRecord{constrecord=newDataRecord();columns.forEach(info=>{constidx=result.getColumnIndex(info.columnName);letval:any;switch(info.type){caseColumnType.LONG:val=result.getLong(idx);break;caseColumnType.DOUBLE:val=result.getDouble(idx);break;caseColumnType.STRING:val=result.getString(idx);break;caseColumnType.BLOB:val=result.getBlob(idx);break;default:val=null;}record.setValue(info.name,val);});returnrecord;}

📌关键点:HarmonyOS 的ResultSet遍历逻辑较为特殊,需手动处理“最后一行”,这是官方 API 的一个常见陷阱。


六、业务模型层:RecordModel

DBUtil之上,我们构建了RecordModel,提供面向业务的接口:

classRecordModel{// 表常量privatereadonlyTABLE_NAME='recode';privatereadonlyID_COLUMN='id';privatereadonlyDATE_COLUMN='create_time';insert(record:RecordPO):Promise<number>{constdataRecord=newDataRecord();dataRecord.setValue('id',record.id);dataRecord.setValue('keepId',record.keepId);// ... 其他字段returnDBUtil.insert(this.TABLE_NAME,dataRecord,COLUMNS);}asyncqueryByDate(date:number):Promise<RecordPO[]>{constpredicates=newRdbPredicates(this.TABLE_NAME);conststartOfDay=date;constendOfDay=date+24*60*60*1000-1;predicates.between(this.DATE_COLUMN,startOfDay,endOfDay);constdataRecords=awaitDBUtil.queryForList(predicates,COLUMNS);returndataRecords.map(dr=>{constpo=newRecordPO();po.id=dr.getValue('id')asnumber;po.keepId=dr.getValue('keepId')asnumber;// ... 赋值其他字段returnpo;});}// delete / update 略...}

这种分层设计使得:

  • 业务层只与RecordPORecordModel交互;
  • 数据访问层(DBUtil)完全屏蔽了 SQL 和底层 API 细节;
  • 可测试性强:可 mockRecordModel返回模拟数据。

七、辅助模块:运动项管理与首选项

除了核心记录表,我们还维护了一个静态的运动项列表:

constkeeps:RecordItem[]=[newRecordItem(0,'跳绳',$r('app.media.home_ic_swimming'),'/小时',600),// ...];

并通过ItemModel提供查询:

classItemModel{getById(id:number){returnkeeps[id];}list(){returnkeeps;}}

🔄优化建议:未来可将keeps存入数据库,支持用户自定义运动项目。

同时,使用PreferenceUtil管理用户设置(如首次启动状态、目标提醒等),其基于@ohos.data.preferences实现,采用单例+异步加载模式,确保线程安全。


八、潜在问题与改进建议

尽管当前架构已满足 MVP 需求,但仍存在可优化空间:

问题建议
表名recode拼写错误改为record,并添加数据库版本迁移逻辑
amount类型不一致(INTEGER vs DOUBLE)统一为REAL或明确业务含义
RecordPO字段未校验非空添加构造函数或 Builder 模式
查询遍历逻辑冗余封装通用ResultSettoT[]工具
缺少索引create_timekeep_id添加索引提升查询性能
无事务支持在批量插入/更新时启用事务

九 功能展示

十、总结

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

这套方案不仅适用于健身记录场景,也可作为 HarmonyOS 应用本地数据库开发的参考模板。未来,我们将引入数据库版本管理、加密存储、以及云端同步能力,进一步提升数据安全与用户体验。

代码即文档,架构即承诺。在小V健身助手的演进之路上,稳健的数据基石,是我们交付可靠体验的底气所在。


附:关键常量与类型定义汇总

// 表名与列名constTABLE_NAME='recode';constID_COLUMN='id';constDATE_COLUMN='create_time';// 列信息constCOLUMNS: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}];// 建表语句constCREATE_TABLE_SQL=`( ... )`;

通过以上设计,小V健身助手得以在用户每一次点击“完成”按钮时,默默而可靠地将汗水转化为数据,为健康生活留下数字足迹。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/4/16 10:55:39

全球40分支一夜“上云”,SASE如何把网络与安全完美融合?

随着远程办公成为新常态&#xff0c;一位跨国企业的IT主管惊讶地发现&#xff0c;公司在全球40个分支机构的网络安全设备一夜之间整合成了一个基于云的统一平台&#xff0c;这正是SASE技术带来的变革。01 SASE技术解析SASE的核心价值在于将网络功能与安全能力融合于统一的云服务…

作者头像 李华
网站建设 2026/4/16 15:49:03

多模态Agent微服务协同难题破解:Docker Compose启动依赖配置全指南

第一章&#xff1a;多模态Agent微服务架构中的启动依赖挑战在构建多模态Agent系统时&#xff0c;微服务架构因其灵活性和可扩展性被广泛采用。然而&#xff0c;随着服务数量的增加&#xff0c;各模块间复杂的依赖关系导致系统启动阶段面临严峻挑战。典型问题包括服务启动顺序错…

作者头像 李华
网站建设 2026/4/16 10:54:13

相似度阈值设多少才合适?,99%工程师忽略的数据依据

第一章&#xff1a;相似度阈值设多少才合适&#xff1f;——视频帧字幕检索的核心难题在基于视觉内容的视频帧与字幕匹配系统中&#xff0c;相似度阈值是决定检索精度与召回率平衡的关键参数。该阈值用于判断两段特征向量&#xff08;如帧图像嵌入与文本嵌入&#xff09;之间的…

作者头像 李华