个人首页: 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)存储,便于跨平台处理。
🔍注意:虽然
amount在RecordPO中是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}];⚠️潜在问题:
amount在COLUMNS中被标记为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 略...}这种分层设计使得:
- 业务层只与
RecordPO和RecordModel交互; - 数据访问层(DBUtil)完全屏蔽了 SQL 和底层 API 细节;
- 可测试性强:可 mock
RecordModel返回模拟数据。
七、辅助模块:运动项管理与首选项
除了核心记录表,我们还维护了一个静态的运动项列表:
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_time和keep_id添加索引提升查询性能 |
| 无事务支持 | 在批量插入/更新时启用事务 |
九 功能展示
十、总结
小V健身助手的数据库模块通过分层架构 + 类型安全映射 + 单例工具封装,实现了高内聚、低耦合的设计目标。从RecordPO到DataRecord,再到ValuesBucket和ResultSet,每一步转换都经过精心抽象,既保证了代码可读性,又提升了可维护性。
这套方案不仅适用于健身记录场景,也可作为 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健身助手得以在用户每一次点击“完成”按钮时,默默而可靠地将汗水转化为数据,为健康生活留下数字足迹。