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

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

  • [用服务层统一业务逻辑------`KeepService` 的设计、实现与架构演进](#用服务层统一业务逻辑——KeepService 的设计、实现与架构演进)

用服务层统一业务逻辑------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) {
    // 更新成就...
    // 刷新列表...
  }
}

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

  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)

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分钟"):

  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 天然支持单元测试:

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;
  }
}
相关推荐
前端不太难8 小时前
RN Navigation vs Vue Router 的架构对比
javascript·vue.js·架构
走在路上的菜鸟8 小时前
Android学Dart学习笔记第二十节 类-枚举
android·笔记·学习·flutter
自由生长20248 小时前
领域驱动设计(DDD):从业务复杂性到代码结构的系统性解法
架构
YJlio8 小时前
ZoomIt 学习笔记(11.9):绘图模式——演示时“手写板”:标注、圈画、临时白板
服务器·笔记·学习
专注于大数据技术栈8 小时前
java学习--String
java·开发语言·学习
周杰伦_Jay8 小时前
【tRPC-Go 框架】深度解析:特性、架构及与主流RPC框架对比
rpc·架构·golang
deng-c-f8 小时前
Linux C/C++ 学习日记(50):连接池
数据库·学习·连接池
一水鉴天8 小时前
整体设计 定稿 之6 完整设计文档讨论及定稿 之2 模块化设计体系规范(工具作为首批践行者)(豆包助手)
运维·人工智能·重构·架构
海姐软件测试8 小时前
如何实现 “右移”的智能监控,快速定位和恢复线上事故?
架构