HarmonyOS 6.1 开发者实战 | 《灵犀厨房》多设备烹饪并发:从反复踩坑到架构重构

摘要:在《灵犀厨房》App的开发过程中,我遇到了一个看似简单却反复折腾了数日的问题——多道菜同时烹饪时,任务会被意外终止、服务卡片数据紊乱、设备状态不一致。这篇文章将完整复盘这次从"头痛医头"到"全局重构"的排查历程,分享我在并发状态管理、定时器设计、服务卡片推送等方面的思考与最终方案。如果你也在做HarmonyOS应用的状态管理,相信这篇文章能帮你少走一些弯路。


一、问题现象

当用户按以下流程操作时,会出现严重的数据不一致:

  1. 选择第一道菜(回锅肉),绑定电磁炉01,启动烹饪(5分钟定时器正常运行)
  2. 返回首页,选择第二道菜(木须肉),绑定电磁炉02,确认启动
  3. 预期行为:两道菜各自独立倒计时,互不干扰
  4. 实际现象
    • 第一道菜的设备被错误释放,is_busy 变为 0
    • 第二道菜的设备也被随后释放,烹饪记录丢失
    • 服务卡片被复位,显示"等待连接…"
    • 控制台出现 [CookingProgress] 检测到烹饪自然结束,自动清理状态 的误判日志

从日志中可以看到,问题发生的时间线非常清晰:

10:09:33.695 [CookingProgress] 开始烹饪: 木须肉, 初始时间: 300秒
10:09:33.695 [CookingProgress] 当前活跃任务数: 2
10:09:33.877 [CookingProgress] 检测到设备 DE:VI:CE:IH:CT:02 的烹饪自然结束
10:09:33.878 [CookingProgress] 停止设备 DE:VI:CE:IH:CT:02 的烹饪

第二道菜从启动到被判定为"自然结束",仅用了 182 毫秒。


二、根因分析:三重数据源导致的"真相分裂"

2.1 最初的架构问题

这张图展示了重构前的三重数据源问题——CookingProgressManager 既维护自己的任务列表,又从 KitchenDeviceSimulator 读取定时器数据,两个数据源之间没有同步机制。

问题_多任务多道菜_旧架构.png

图例说明

  • 橙色节点:存在数据不一致的两套数据源
  • 红色节点:问题触发点——checkAllTasks() 读取到不可靠的 timerSeconds 后误判
  • 虚线箭头:不可靠的数据依赖关系

在问题发生时,App存在三套互相独立的数据源:

数据源 维护者 职责
this.devices[] 数组 MockDeviceDataSource(KitchenDeviceSimulator 内部) 设备状态 + 定时器倒计时
activeCookingTasks Map CookingProgressManager 烹饪任务绑定关系
kitchen_devices + user_cooking_history RelationalStoreHelper 数据库持久化

核心矛盾CookingProgressManager 自己维护任务列表,却从 KitchenDeviceSimulator 读取定时器数据。多任务并发时,KitchenDeviceSimulator 内部的 setInterval 对第二道菜的定时器初始化失败(timerSeconds 始终为 0),导致 CookingProgressManager 读取到不可靠的数据,误判为"自然结束"。

2.2 为什么定时器初始化失败?

KitchenDeviceSimulatorstartTimer 方法中,每台设备的定时器由独立的 setInterval 管理。但当多个定时器同时运行时,this.devices.find() 在回调中查找设备时出现了竞态问题——第二道菜的定时器启动时,第一道菜的定时器回调正在执行 this.devices.find(),导致第二个定时器的设备状态被意外覆盖。

2.3 反思:我们在修改过程中犯的错误

在长达数日的修改过程中,我多次陷入"打地鼠"式的修复:

  1. 第一次尝试:在 CookingProgressManager.checkAllTasks() 中增加"启动保护期"——新任务启动后 2 秒内不检测"自然结束"。这解决了误判问题,但治标不治本。
  2. 第二次尝试:让 CookingProgressManager 自己计算剩余时间(targetTimerSeconds - (Date.now() - startTime) / 1000),不再依赖 KitchenDeviceSimulator 的定时器。这个方向是对的,但第一次实现时没有彻底清理旧代码,导致两套定时器逻辑并存。
  3. 第三次尝试:彻底删除 KitchenDeviceSimulator 中的定时器逻辑,让 CookingProgressManager 成为唯一的定时器管理者。但此时又因为接口变更导致全量编译错误,花了大量时间修复调用方。

最深刻的教训:重构时应该一次到位,彻底消除数据源的不一致性,而不是在旧架构上打补丁。


三、最终方案:以数据库为唯一数据源的自管理定时器

3.1 设计原则

  • 单一数据源:所有设备状态和烹饪记录以数据库(kitchen_devices + user_cooking_history)为唯一真相来源
  • 定时器自管理CookingProgressManager 使用 Map<deviceId, setInterval句柄> 自己管理所有定时器
  • 剩余时间实时计算remainingSeconds = targetTimerSeconds - (Date.now() - startTime) / 1000,不依赖任何外部可变状态
  • 数据库与内存同步:定时器归零时自动调用 storeHelper.completeCookingRecord() + storeHelper.setDeviceIdle()

3.2 重构后的数据流

RecipeDetailPage.confirmStartCooking()
    │
    ├── 1. storeHelper.setDeviceBusy(deviceId, recipeId)       // 更新设备忙碌状态
    ├── 2. storeHelper.insertCookingRecord(...)                 // 写入烹饪记录
    └── 3. cookingProgressManager.startTask(...)                // 启动任务
            │
            └── activeTimers.set(deviceId, setInterval句柄)
                    │
                    ├── 每秒:计算剩余时间 → 服务卡片推送
                    └── 归零时:clearInterval + completeCookingRecord + setDeviceIdle

所有展示页面(DeviceCookingPage / CookingMonitorPage / DeviceTabContent)
    └── 直接从 storeHelper 或 cookingProgressManager 查询,不经过任何中间层

如下这张图展示了重构后的完整数据流——所有状态以数据库为唯一数据源,CookingProgressManager 自管理定时器,不依赖任何外部可变状态。

问题_多任务多道菜_新架构.png

图例说明

  • 核心改动:定时器由 CookingProgressManager 自管理(setInterval),不再依赖 KitchenDeviceSimulator
  • 数据一致性:所有展示页面(DeviceTabContentCookingMonitorPage、服务卡片)都从 CookingProgressManagerstoreHelper 读取数据,同一个数据源
  • 多任务支持:每个任务有独立的 startTime 和定时器句柄,互不干扰

3.3 核心代码实现

CookingProgressManager.startTask()

async startTask(
  recipeId: number, recipeName: string, deviceId: string,
  deviceName: string, totalSteps: number, targetTimerSeconds: number
): Promise<number> {
  const userId = authViewModel.userId;
  await storeHelper.setDeviceBusy(deviceId, recipeId);
  const recordId = await storeHelper.insertCookingRecord(
    userId, recipeId, recipeName, deviceId, deviceName, totalSteps, targetTimerSeconds
  );

  // 启动自管理定时器
  const startTime = Date.now();
  const handle = setInterval(async () => {
    const elapsed = Math.floor((Date.now() - startTime) / 1000);
    if (elapsed >= targetTimerSeconds) {
      clearInterval(handle);
      this.activeTimers.delete(deviceId);
      await storeHelper.completeCookingRecord(recordId);
      await storeHelper.setDeviceIdle(deviceId);
      if (this.activeTimers.size === 0) {
        this.stopPolling();
      }
    }
  }, 1000);
  this.activeTimers.set(deviceId, handle);

  this.startPolling();
  return recordId;
}

CookingProgressManager.getAllActiveSnapshots()

async getAllActiveSnapshots(): Promise<CookingProgressSnapshot[]> {
  const records = await storeHelper.getActiveCookingRecords(authViewModel.userId);
  const snapshots: CookingProgressSnapshot[] = [];

  for (const record of records) {
    const targetTimerSeconds = (record['target_timer_seconds'] as number) ?? 0;
    const startedAt = (record['started_at'] as number) ?? 0;
    const elapsed = Math.floor(Date.now() / 1000) - startedAt;
    const remainingSeconds = Math.max(0, targetTimerSeconds - elapsed);

    // ... 组装 snapshot,计算步骤进度
    snapshots.push({ ... });
  }
  return snapshots;
}

四、服务卡片推送的独立问题排查

在架构重构完成后,服务卡片推送出现了独立的问题——EntryAbility 读取到的卡片ID列表始终为空(数据库原始字符串: [])。

4.1 排查过程

  1. 初期误判:一度怀疑是跨进程 Preferences 同步问题(EntryFormAbility 写、EntryAbility 读),但代码未做任何修改
  2. 关键日志onNewWantonAddForm 均无日志输出,说明系统根本没有触发卡片事件
  3. 真相:模拟器环境问题——多次部署后应用沙箱与系统服务的绑定关系错乱

4.2 解决方案

最终通过清除模拟器数据 + 重启模拟器 + 重新部署解决了卡片推送问题。这也提醒我们:遇到间歇性、不可复现的问题时,优先检查运行环境而非代码逻辑。


五、经验总结

5.1 架构层面

  1. 单一数据源是并发安全的基石。多个数据源必然导致一致性维护困难,尤其在多任务场景下。
  2. 定时器应该由业务逻辑层自己管理,而非依赖模拟器或外部模块。基于时间戳的计算方式比 setInterval 递减更可靠。
  3. 数据库持久化比内存缓存更适合需要恢复的场景(如 App 被杀后重启)。烹饪任务是临时状态,但绑定关系需要持久化以支持状态恢复。

5.2 调试层面

  1. 日志要精确到每次状态变更的上下文。这次排查中,timerSeconds 从 300 突变为 0 的关键线索就是在日志中发现的。
  2. 区分代码问题和环境问题。当功能间歇性失效时,优先检查环境(模拟器缓存、签名、系统服务状态)。
  3. 重构要彻底,不要打补丁。多次"头痛医头"的修改浪费了大量时间,最终仍然是完全重构解决了问题。

5.3 最终成果

经过架构重构后,多任务并发烹饪功能运行稳定:

  • 第一道菜烹饪中,启动第二道菜后,两道菜各自独立倒计时,互不干扰
  • 服务卡片同时显示多道菜的进度
  • 任一道菜定时结束后,只释放对应设备,不影响其他菜
  • KitchenDeviceManager(原 Simulator)只保留设备基础操作(连接/断开/温度/功率),不再管理任何定时器

📚 本系列持续更新中:下一篇将继续完善设备管理、烹饪监控等UI细节,并准备上架材料。

🔗 专栏入口:[《HarmonyOS6.1全场景实战》合集]
📦 获取基线版本源码包包括第1-15篇所有代码 + 架构文档 + Flask 后端
如果你发现本文还有任何不严谨之处,欢迎随时指出,我们一起共建最优质的 HarmonyOS 6.1 学习内容!如果觉得有帮助,请不要吝啬你的点赞 👍、收藏 ⭐ 和评论 💬,我们下一篇见~

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐