
-
个人首页: VON
-
鸿蒙系列专栏: 鸿蒙开发小型案例总结
-
综合案例 :鸿蒙综合案例开发
-
鸿蒙6.0:从0开始的开源鸿蒙6.0.0
-
鸿蒙5.0:鸿蒙5.0零基础入门到项目实战
-
Electron适配开源鸿蒙专栏:Electron for OpenHarmony
-
Flutter 适配开源鸿蒙专栏:Flutter for OpenHarmony
-
本文所属专栏:鸿蒙综合案例开发
-
本文atomgit地址:小V健身
小V健身助手开发手记(六)
- [用服务层统一业务逻辑------`KeepService` 的设计、实现与架构演进](#用服务层统一业务逻辑——
KeepService的设计、实现与架构演进) -
- 一、为何需要服务层?------从混乱到有序的必然选择
- [二、`KeepService` 的整体设计](#二、
KeepService的整体设计) -
- [1. **输入输出明确**](#1. 输入输出明确)
- [2. **无状态 & 无副作用**](#2. 无状态 & 无副作用)
- [3. **领域驱动**](#3. 领域驱动)
- 三、核心功能详解
-
- [(1)新增运动记录:`insert(keepId: number, amount: number)`](#(1)新增运动记录:
insert(keepId: number, amount: number)) - [(2)查询与转换:`selectRecordByDate(date: number)`](#(2)查询与转换:
selectRecordByDate(date: number)) - [(3)统计信息生成:`calculateKeepInfo(records: RecordVO[])`](#(3)统计信息生成:
calculateKeepInfo(records: RecordVO[]))
- [(1)新增运动记录:`insert(keepId: number, amount: number)`](#(1)新增运动记录:
- 四、服务层与各模块的协作关系
- 五、工程实践与最佳建议
-
- [1. **错误处理策略**](#1. 错误处理策略)
- [2. **性能优化**](#2. 性能优化)
- [3. **可测试性**](#3. 可测试性)
- [4. **未来扩展方向**](#4. 未来扩展方向)
- 六、总结:服务层------架构成熟的标志

用服务层统一业务逻辑------KeepService 的设计、实现与架构演进
在移动应用开发中,随着功能的不断丰富,代码复杂度呈指数级增长。若缺乏清晰的分层与职责划分,项目将迅速陷入"面条式代码"的泥潭------UI 层充斥着数据库操作、数据模型混杂业务规则、测试难以覆盖核心逻辑。这不仅拖慢迭代速度,更埋下维护隐患。
在「小V健身助手」的开发进程中,我们始终秉持 "关注点分离" (Separation of Concerns)这一软件工程基本原则。前五篇中,我们完成了声明式 UI 构建、关系型数据库集成、历史记录展示等关键模块。但直到引入 服务层(Service Layer),整个系统才真正实现了从"能跑"到"健壮"的跃迁。
本篇将以 KeepService 为核心,全面阐述服务层在 HarmonyOS ArkTS 项目中的设计思路、实现细节、工程价值与最佳实践,为构建可扩展、可测试、高内聚的健康类应用提供完整范式。
一、为何需要服务层?------从混乱到有序的必然选择
在早期版本中,运动记录的增删改查逻辑直接嵌入在首页组件(HomePageContent)中:
ts
// ❌ 反模式:UI 层直接操作数据库
async onComplete(keepId: number, amount: number) {
const record = new RecordPO();
record.keepId = keepId;
record.amount = amount;
record.createTime = Date.now();
const id = await DBUtil.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)
ts
insert(keepId: number, amount: number): Promise<number> {
const selecterDate = AppStorage.Get('selecterDate') as number;
const createTime = selecterDate !== undefined
? selecterDate
: DateUtil.beginTimeOfDay(new Date());
const recordPO = new RecordPO();
recordPO.keepId = keepId;
recordPO.amount = amount;
recordPO.createTime = createTime;
recordPO.successAmount = 0; // 初始未完成
return RecordModel.insert(recordPO);
}
设计亮点:
- 日期上下文感知 :通过
AppStorage获取全局选中日期,确保记录归属正确; - 默认值安全 :
successAmount显式初始化为 0,避免数据库空值歧义; - 解耦持久化 :调用
RecordModel.insert,不关心 SQL 或事务细节。
💡 此方法被首页"完成"按钮、快捷录入弹窗等多处调用,体现复用价值。
(2)查询与转换:selectRecordByDate(date: number)
这是服务层最典型的"数据聚合"场景:
ts
async selectRecordByDate(date: number): Promise<RecordVO[]> {
const recordPOs = await RecordModel.queryByDate(date);
return recordPOs.map((rp: RecordPO) => {
if (rp.id === undefined || rp.createTime === undefined) {
throw new Error('记录数据不完整');
}
const recordItem = RecordItemModel.getById(rp.keepId);
const calorie = rp.amount * recordItem.calorie;
return new RecordVO(
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[])
成就系统、首页概览卡片均依赖此方法:
ts
calculateKeepInfo(records: RecordVO[]): KeepInfo {
const info = new KeepInfo(0, 0, 0, 0);
if (!records || records.length <= 0) return info;
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}`);
return info;
}
业务规则显性化:
- 成功条件不再是"魔法数字",而是清晰的布尔表达式;
- 日志输出便于调试当日任务完成情况;
- 返回
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 天然支持单元测试:
ts
// mock RecordModel 和 RecordItemModel
const mockRecordModel = { queryByDate: jest.fn() };
const mockItemModel = { getById: jest.fn() };
// 测试 calculateKeepInfo 逻辑
const service = new KeepService(mockRecordModel, mockItemModel);
const result = 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 |
代码部分
bash
import RecordItemModel from '../model/RecordItemModel'
import RecordModel from '../model/RecordModel'
import RecordPO from '../commond/tables/RecordPO'
import DateUtil from '../util/DateUtil'
import Logger from '../util/Logger'
import KeepInfo from '../viewmodel/KeepInfo'
import RecordVO 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; // 初始完成量为0
return RecordModel.insert(recordPO);
}
// 根据ID删除运动记录
deleteById(id: number): Promise<number> {
return RecordModel.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;
return RecordModel.update(recordPO, record.id);
}
// 查询所有运动记录
async selectAllRecord(): Promise<RecordVO[]> {
// 查询所有RecordPO记录
const recordPOs = await RecordModel.queryAll();
// 将PO转换为VO并补充相关信息
return recordPOs.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
);
return recordVO;
});
}
// 根据日期查询运动记录
async selectRecordByDate(date: number): Promise<RecordVO[]> {
// 查询指定日期的RecordPO记录
const recordPOs = await RecordModel.queryByDate(date);
// 将PO转换为VO并补充相关信息
return recordPOs.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
);
return recordVO;
});
}
// 将RecordVO数组转换为KeepInfo统计信息
calculateKeepInfo(records: RecordVO[]): KeepInfo {
// 使用默认参数创建KeepInfo对象
const info = new KeepInfo(0, 0, 0, 0);
// 检查记录是否存在
if (!records || records.length <= 0) {
return info;
}
// 设置总任务数
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},总任务/已完成任务`);
return info;
}
}