news 2026/2/3 12:55:34

小V健身助手开发手记(六):KeepService 的设计、实现与架构演进

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
小V健身助手开发手记(六):KeepService 的设计、实现与架构演进

  • 个人首页: VON

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

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

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

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

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

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

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

  • 本文atomgit地址:小V健身

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

  • 用服务层统一业务逻辑——`KeepService` 的设计、实现与架构演进
    • 一、为何需要服务层?——从混乱到有序的必然选择
    • 二、`KeepService` 的整体设计
      • 1. **输入输出明确**
      • 2. **无状态 & 无副作用**
      • 3. **领域驱动**
    • 三、核心功能详解
      • (1)新增运动记录:`insert(keepId: number, amount: number)`
      • (2)查询与转换:`selectRecordByDate(date: number)`
      • (3)统计信息生成:`calculateKeepInfo(records: RecordVO[])`
    • 四、服务层与各模块的协作关系
    • 五、工程实践与最佳建议
      • 1. **错误处理策略**
      • 2. **性能优化**
      • 3. **可测试性**
      • 4. **未来扩展方向**
    • 六、总结:服务层——架构成熟的标志

用服务层统一业务逻辑——KeepService的设计、实现与架构演进

在移动应用开发中,随着功能的不断丰富,代码复杂度呈指数级增长。若缺乏清晰的分层与职责划分,项目将迅速陷入“面条式代码”的泥潭——UI 层充斥着数据库操作、数据模型混杂业务规则、测试难以覆盖核心逻辑。这不仅拖慢迭代速度,更埋下维护隐患。

在「小V健身助手」的开发进程中,我们始终秉持“关注点分离”(Separation of Concerns)这一软件工程基本原则。前五篇中,我们完成了声明式 UI 构建、关系型数据库集成、历史记录展示等关键模块。但直到引入服务层(Service Layer),整个系统才真正实现了从“能跑”到“健壮”的跃迁。

本篇将以KeepService为核心,全面阐述服务层在 HarmonyOS ArkTS 项目中的设计思路、实现细节、工程价值与最佳实践,为构建可扩展、可测试、高内聚的健康类应用提供完整范式。


一、为何需要服务层?——从混乱到有序的必然选择

在早期版本中,运动记录的增删改查逻辑直接嵌入在首页组件(HomePageContent)中:

// ❌ 反模式:UI 层直接操作数据库asynconComplete(keepId:number,amount:number){constrecord=newRecordPO();record.keepId=keepId;record.amount=amount;record.createTime=Date.now();constid=awaitDBUtil.insert('recode',buildBucket(record),COLUMNS);if(id>0){// 更新成就...// 刷新列表...}}

这种写法看似简单,却带来三大问题:

  1. 逻辑复用困难:个人中心、历史页若需新增记录,必须复制粘贴相同代码;
  2. 测试成本高昂:无法对“新增记录并计算卡路里”这一业务单元进行独立测试;
  3. 变更风险集中:一旦数据库表结构变更,所有 UI 组件均需同步修改。

而服务层的引入,正是为了解决这些问题。它作为UI 与数据访问之间的协调者,承担以下核心职责:

  • 封装业务规则:如“完成量等于计划量才算成功”、“卡路里 = 时长 × 单位消耗”;
  • 协调多个数据源:组合RecordModel(RDB)与RecordItemModel(内存配置);
  • 提供统一接口:对外暴露语义清晰的方法(如insert,selectRecordByDate),隐藏底层实现细节。

✅ 服务层不是简单的“方法转发器”,而是业务语义的承载者


二、KeepService的整体设计

KeepService是一个单例类(通过new KeepService()实例化后全局共享),其设计遵循以下原则:

1.输入输出明确

  • 所有方法参数均为原始类型或轻量 VO(Value Object);
  • 返回值为 Promise,便于异步链式调用;
  • 抛出明确异常(如Error('记录ID不能为空')),便于上层处理。

2.无状态 & 无副作用

  • 不持有 UI 上下文(context);
  • 不直接操作 UI 状态(如@State);
  • 所有外部依赖(如RecordModel,RecordItemModel)通过模块导入,而非传参。

3.领域驱动

  • 方法命名体现业务意图(calculateKeepInfo而非getStats);
  • 数据转换在服务层完成(PO → VO),UI 层只消费最终结果。

三、核心功能详解

(1)新增运动记录:insert(keepId: number, amount: number)

insert(keepId:number,amount:number):Promise<number>{constselecterDate=AppStorage.Get('selecterDate')asnumber;constcreateTime=selecterDate!==undefined?selecterDate:DateUtil.beginTimeOfDay(newDate());constrecordPO=newRecordPO();recordPO.keepId=keepId;recordPO.amount=amount;recordPO.createTime=createTime;recordPO.successAmount=0;// 初始未完成returnRecordModel.insert(recordPO);}

设计亮点:

  • 日期上下文感知:通过AppStorage获取全局选中日期,确保记录归属正确;
  • 默认值安全successAmount显式初始化为 0,避免数据库空值歧义;
  • 解耦持久化:调用RecordModel.insert,不关心 SQL 或事务细节。

💡 此方法被首页“完成”按钮、快捷录入弹窗等多处调用,体现复用价值。


(2)查询与转换:selectRecordByDate(date: number)

这是服务层最典型的“数据聚合”场景:

asyncselectRecordByDate(date:number):Promise<RecordVO[]>{constrecordPOs=awaitRecordModel.queryByDate(date);returnrecordPOs.map((rp:RecordPO)=>{if(rp.id===undefined||rp.createTime===undefined){thrownewError('记录数据不完整');}constrecordItem=RecordItemModel.getById(rp.keepId);constcalorie=rp.amount*recordItem.calorie;returnnewRecordVO(rp.id,rp.keepId,calorie,recordItem,// 注入运动项元数据rp.amount,rp.successAmount,rp.createTime);});}

关键设计决策:

问题解决方案
如何获取运动项名称/图标?通过RecordItemModel.getById(keepId)查询内存中的配置列表(keeps数组)
卡路里何时计算?在服务层实时计算,避免 UI 层重复逻辑或数据库冗余存储
PO 与 VO 如何区分?RecordPO仅用于数据库映射;RecordVO包含业务字段(如calorie,recordItem),供 UI 直接使用

✅ 这种“PO → VO 转换”模式,是服务层的核心价值之一:将原始数据转化为业务就绪的数据


(3)统计信息生成:calculateKeepInfo(records: RecordVO[])

成就系统、首页概览卡片均依赖此方法:

calculateKeepInfo(records:RecordVO[]):KeepInfo{constinfo=newKeepInfo(0,0,0,0);if(!records||records.length<=0)returninfo;info.task=records.length;records.forEach((rv:RecordVO)=>{// 成功判定:计划量 > 0 且 完成量 = 计划量if(rv.amount!==0&&rv.successAmount===rv.amount){info.successTask+=1;}info.expend+=rv.calorie;info.day=rv.createTime;// 使用最后一条记录的日期});info.isSuccess=(info.task===info.successTask&&info.task!==0);Logger.debug(`KeepService`,`${info.task}/${info.successTask}, 任务时间:${info.day}`);returninfo;}

业务规则显性化:

  • 成功条件不再是“魔法数字”,而是清晰的布尔表达式;
  • 日志输出便于调试当日任务完成情况;
  • 返回KeepInfo对象,包含task,successTask,expend,isSuccess,day五个维度,满足多场景需求。

📌 注意:info.day取最后一条记录的时间,适用于“按日统计”场景。若需支持周/月视图,可扩展为传入时间范围。


四、服务层与各模块的协作关系

下图展示了KeepService在整体架构中的位置:

+------------------+ +------------------+ | HomePage | | HistoryPage | | PersonContent |<----->| Achievement... | +------------------+ +------------------+ ↑ 调用 | +--------------+ | KeepService | ←─── 业务规则、数据聚合 +--------------+ ↑ 调用 | +------------------+ +------------------+ | RecordModel | | RecordItemModel | | (RDB 操作封装) | | (内存配置列表) | +------------------+ +------------------+ ↑ | +--------------+ | Small_V_Health.db | | (SQLite 文件) | +--------------+

协作流程示例(用户点击“完成跳绳30分钟”):

  1. HomePageContent调用KeepService.insert(0, 30)
  2. KeepService创建RecordPO,设置createTime为今日0点;
  3. 调用RecordModel.insert(),后者通过DBUtil写入数据库;
  4. 返回主键 ID,HomePageContent刷新列表并触发成就检查;
  5. 成就页调用KeepService.selectRecordByDate(today)获取当日记录;
  6. KeepService查询 RDB + 查询RecordItemModel+ 计算卡路里 → 返回RecordVO[]
  7. 成就页根据RecordVO渲染详情。

✅ 整个过程无任何跨层调用,每一层只与相邻层交互。


五、工程实践与最佳建议

1.错误处理策略

  • 服务层抛出Error,由 UI 层捕获并展示友好提示(如AlertDialog);
  • 关键操作(如删除)应要求 UI 层二次确认,而非在服务层处理。

2.性能优化

  • RecordItemModel使用内存数组(keeps),避免频繁读取 Preferences;
  • 大数据量查询(如全年记录)应分页加载,KeepService可扩展queryByDateRange方法。

3.可测试性

虽然当前未展示测试代码,但KeepService天然支持单元测试:

// mock RecordModel 和 RecordItemModelconstmockRecordModel={queryByDate:jest.fn()};constmockItemModel={getById:jest.fn()};// 测试 calculateKeepInfo 逻辑constservice=newKeepService(mockRecordModel,mockItemModel);constresult=service.calculateKeepInfo([...]);expect(result.successTask).toBe(2);

4.未来扩展方向

  • 引入缓存:对selectRecordByDate结果做短期缓存,减少数据库查询;
  • 支持撤销:在insert后返回操作 ID,配合deleteById实现“撤回”;
  • 事件通知:通过Emitter通知 UI 层数据变更,替代手动刷新。

六、总结:服务层——架构成熟的标志

KeepService的引入,标志着「小V健身助手」从“功能堆砌”走向“架构驱动”。它不仅是代码组织方式的升级,更是开发思维的转变

  • 从前: “怎么把数据存进去、读出来?”
  • 现在: “用户的运动行为意味着什么?如何用数据讲述他的坚持故事?”

通过服务层,我们将零散的操作升华为连贯的业务叙事:

用户完成一次跳绳 → 系统记录计划与实际 → 计算卡路里消耗 → 更新当日成就状态 → 在历史中留下足迹。

这正是优秀应用体验的底层支撑。

下一站,我们将基于KeepService提供的丰富数据,构建可视化图表,让用户一眼看清自己的进步轨迹——敬请期待《小V健身助手开发手记(七):用曲线讲述你的坚持》。


附录:关键类说明

类名职责所在目录
KeepService业务逻辑协调、数据聚合、规则计算/service/KeepService.ts
RecordModel封装 RDB 表recode的 CRUD 操作/model/RecordModel.ts
RecordItemModel提供运动项元数据(名称、图标、单位、卡路里)/model/RecordItemModel.ts
RecordPO数据库实体映射对象(Persistence Object)/commond/tables/RecordPO.ts
RecordVO业务视图对象(View Object),含计算字段/viewmodel/RecordVO.ts
KeepInfo统计摘要信息(任务数、完成数、卡路里等)/viewmodel/KeepInfo.ts

代码部分

importRecordItemModel from'../model/RecordItemModel'importRecordModel from'../model/RecordModel'importRecordPO from'../commond/tables/RecordPO'importDateUtil from'../util/DateUtil'importLogger from'../util/Logger'importKeepInfo from'../viewmodel/KeepInfo'importRecordVO from'../viewmodel/RecordVO'class KeepService{// 新增运动记录 insert(keepId: number, amount: number): Promise<number>{// 获取选中日期或当前日期的开始时间戳 const selecterDate=AppStorage.Get('selecterDate')as number;const createTime=selecterDate!==undefined ? selecterDate:DateUtil.beginTimeOfDay(new Date());// 创建RecordPO对象并设置属性 const recordPO=new RecordPO();recordPO.keepId=keepId;recordPO.amount=amount;recordPO.createTime=createTime;recordPO.successAmount=0;// 初始完成量为0returnRecordModel.insert(recordPO);}// 根据ID删除运动记录 deleteById(id: number): Promise<number>{returnRecordModel.delete(id);}// 更新运动记录 update(record: RecordVO): Promise<number>{// 检查记录ID是否存在if(record.id===undefined){throw new Error('记录ID不能为空');}// 创建RecordPO对象并设置属性 const recordPO=new RecordPO();recordPO.id=record.id;recordPO.keepId=record.keepId;recordPO.amount=record.amount;recordPO.successAmount=record.successAmount;recordPO.createTime=record.createTime;returnRecordModel.update(recordPO, record.id);}// 查询所有运动记录 async selectAllRecord(): Promise<RecordVO[]>{// 查询所有RecordPO记录 const recordPOs=await RecordModel.queryAll();// 将PO转换为VO并补充相关信息returnrecordPOs.map((rp: RecordPO)=>{// 检查必要字段是否存在if(rp.id===undefined||rp.createTime===undefined){throw new Error('记录数据不完整,缺少必要字段');}// 通过运动项ID查询RecordItem对象 const recordItem=RecordItemModel.getById(rp.keepId);// 计算卡路里消耗=运动时长 × 单位卡路里 const calorie=rp.amount * recordItem.calorie;// 创建RecordVO对象,使用完整的构造函数参数 const recordVO=new RecordVO(rp.id, rp.keepId, calorie, recordItem, rp.amount, rp.successAmount, rp.createTime);returnrecordVO;});}// 根据日期查询运动记录 async selectRecordByDate(date: number): Promise<RecordVO[]>{// 查询指定日期的RecordPO记录 const recordPOs=await RecordModel.queryByDate(date);// 将PO转换为VO并补充相关信息returnrecordPOs.map((rp: RecordPO)=>{// 检查必要字段是否存在if(rp.id===undefined||rp.createTime===undefined){throw new Error('记录数据不完整,缺少必要字段');}// 通过运动项ID查询RecordItem对象 const recordItem=RecordItemModel.getById(rp.keepId);// 计算卡路里消耗=运动时长 × 单位卡路里 const calorie=rp.amount * recordItem.calorie;// 创建RecordVO对象,使用完整的构造函数参数 const recordVO=new RecordVO(rp.id, rp.keepId, calorie, recordItem, rp.amount, rp.successAmount, rp.createTime);returnrecordVO;});}// 将RecordVO数组转换为KeepInfo统计信息 calculateKeepInfo(records: RecordVO[]): KeepInfo{// 使用默认参数创建KeepInfo对象 const info=new KeepInfo(0,0,0,0);// 检查记录是否存在if(!records||records.length<=0){returninfo;}// 设置总任务数 info.task=records.length;// 统计各项指标 records.forEach((rv: RecordVO)=>{// 判断任务是否完成:运动量不为0且完成量等于计划量if(rv.amount!==0&&rv.successAmount===rv.amount){info.successTask+=1;}// 累计卡路里消耗 info.expend+=rv.calorie;// 设置日期(使用最后一条记录的日期) info.day=rv.createTime;});// 判断是否所有任务都完成if(info.task===info.successTask&&info.task!==0){info.isSuccess=true;}// 输出调试信息:总任务数/已完成任务数,任务时间 Logger.debug(`KeepService`,`${info.task}/${info.successTask},任务时间:${info.day},总任务/已完成任务`);returninfo;}}
版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/2/2 9:43:47

终极指南:3步搭建高性能饥荒服务器,告别卡顿困扰

终极指南&#xff1a;3步搭建高性能饥荒服务器&#xff0c;告别卡顿困扰 【免费下载链接】dst-admin-go Dont Starve Together server panel. Manage room with ease, featuring visual world and mod management, player log collection。饥荒联机服务器面板。轻松管理房间&am…

作者头像 李华
网站建设 2026/2/3 20:05:31

智能无人机开发技术实战:构建云端一体化应用新范式

智能无人机开发技术实战&#xff1a;构建云端一体化应用新范式 【免费下载链接】DJI-Cloud-API-Demo 项目地址: https://gitcode.com/gh_mirrors/dj/DJI-Cloud-API-Demo 在当今智能化浪潮中&#xff0c;智能无人机开发技术正成为推动行业数字化转型的关键力量。通过云端…

作者头像 李华
网站建设 2026/2/3 2:40:23

AI取代焦虑的真相你知道吗

原问题&#xff1a;为什么我们一边害怕被 AI 取代&#xff0c;一边又抱怨工作太累&#xff1f;若AI 真承担大部分工作&#xff0c;是该恐惧还是该庆祝&#xff1f;一个三角函数公式&#xff0c;有人洋洋洒洒的做了十几页的学习笔记资料&#xff0c;关键&#xff0c;月末考试考三…

作者头像 李华
网站建设 2026/2/3 14:52:05

掌握方法轻松将f4v格式转换成mpeg格式

F4V作为一种曾广泛用于网络流媒体的高清视频格式&#xff0c;凭借H.264编码带来了良好的画质与较小的体积。MPEG格式作为历史悠久且被广泛支持的国际视频标准&#xff0c;至今仍在各种终端设备中中稳定运行。本文将详细介绍如何将f4v格式转换成mpeg格式。 一、格式特性对比 MP…

作者头像 李华