个人首页: 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){// 更新成就...// 刷新列表...}}这种写法看似简单,却带来三大问题:
- 逻辑复用困难:个人中心、历史页若需新增记录,必须复制粘贴相同代码;
- 测试成本高昂:无法对“新增记录并计算卡路里”这一业务单元进行独立测试;
- 变更风险集中:一旦数据库表结构变更,所有 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分钟”):
HomePageContent调用KeepService.insert(0, 30);KeepService创建RecordPO,设置createTime为今日0点;- 调用
RecordModel.insert(),后者通过DBUtil写入数据库; - 返回主键 ID,
HomePageContent刷新列表并触发成就检查; - 成就页调用
KeepService.selectRecordByDate(today)获取当日记录; KeepService查询 RDB + 查询RecordItemModel+ 计算卡路里 → 返回RecordVO[];- 成就页根据
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;}}