项目名称:懂吃(DongChi)
版本号:V1.1.0
开发工具:DevEco Studio 6.1.1 + AtomCode AI 编码助手
目标平台:HarmonyOS(ArkTS + ArkUI)
发布日期:2026年6月
字数统计:约 10000 字


目录

  1. 概述:从 V1.0.0 到 V1.1.0 的蜕变
  2. 功能一:TTS 朗读功能实现
  3. 功能二:登录功能的移除
  4. 功能三:冰箱食材管理的实现
  5. 功能四:购物清单生成的实现
  6. 功能五:极速随机模式的实现
  7. 功能六:本地菜品拓展至 500 道
  8. 功能七:菜品详情页的实现
  9. 功能八:饮食统计仪表盘的实现
  10. 架构总结与技术选型

1. 概述:从 V1.0.0 到 V1.1.0 的蜕变

「懂吃」是一款专为解决「今天吃什么」这一终极难题而生的 HarmonyOS 原生应用。V1.0.0 版本完成了从「口味选择 → 条件筛选 → 食材确认 → 智能推荐」的基础闭环,但功能相对单薄:推荐结果缺乏语音交互、登录系统形同虚设、每次推荐都需要重复选择食材、推荐菜品仅有寥寥数十道、菜品信息只有名称没有详情、用户行为数据缺少可视化分析。

V1.1.0 版本的核心理念是 「让推荐更智能、让交互更自然、让数据更生动」 。本次更新围绕 8 大功能模块展开,累计涉及 20+ 个源文件的创建与修改,新增代码约 5000 行。下面我们从实现原理的角度逐一深度解析。


2. 功能一:TTS 朗读功能实现

在这里插入图片描述

2.1 功能需求背景

在 V1.0.0 中,推荐结果以纯文本卡片的形式展示,用户需要逐条阅读菜品名称和推荐理由。在厨房场景下,用户往往腾不出手来滑动屏幕——手上可能正拿着锅铲或沾着面粉。TTS(Text To Speech,文本转语音)功能的引入,让用户可以在做菜的同时「听」到推荐结果,真正实现双手解放。

2.2 实现架构:三层解耦设计

TTS 功能采用 「服务层 → 业务层 → 表现层」 三层架构,职责清晰、易于维护:

┌─────────────────────────────────────┐
│     表现层 (Recommend.ets)          │
│  🔊/🔇 切换按钮 · 自动朗读触发      │
├─────────────────────────────────────┤
│     业务层 (TtsService.ets)         │
│  speak() / stop() / release()       │
│  文本拼接 · 生成计数器 · 竞态管理    │
├─────────────────────────────────────┤
│     引擎层 (@hms.ai.textToSpeech)   │
│  create() · speak() · stop()        │
│  在线模式(1) · 中文女声              │
└─────────────────────────────────────┘

2.3 引擎层封装

引擎层直接对接 HarmonyOS 的 @hms.ai.textToSpeech 系统能力(System API),它是华为 HMS SDK 的一部分,在华为设备上提供高质量的中文语音合成。TtsService 作为一个单例封装,对外暴露三个核心方法:

// TtsService.ets — 核心封装
import textToSpeech from '@hms.ai.textToSpeech';

class TtsService {
  private engine: textToSpeech.TextToSpeechEngine | null = null;
  private pendingFinish: ((result: boolean) => void) | null = null;

  async init(): Promise<void> {
    const engineConfig = { online: 1 }; // 在线模式
    this.engine = await textToSpeech.create(engineConfig);
  }

  speak(text: string, volume?: number): Promise<boolean> {
    // 配置并触发朗读
  }

  stop(): void {
    // 停止当前朗读并拒绝 pending 的 Promise
  }

  release(): void {
    // 释放引擎资源
  }
}

关键设计决策:

  • 在线模式 (online: 1):虽然 HMS 支持离线语音包,但在线模式的中文女声更加自然流畅,且当前场景始终有网络连接。
  • 异步防泄漏release() 在页面 aboutToDisappear() 生命周期中调用,确保页面退出时引擎资源被正确释放。
  • 友好降级:若设备不支持 HMS(如非华为设备或模拟器),create 会抛出异常,被 catch 捕获后仅记录日志,不影响页面其他功能。

2.4 业务层:生成计数器解决竞态问题

这是整个 TTS 功能中最具技术含量的设计。考虑以下场景:

  1. 用户当前在「综合推荐」模式下,AI 正在朗读推荐结果
  2. 用户突然切换优先级标签到「食材优先」
  3. 新的推荐数据加载完毕,需要朗读新内容
  4. 旧的朗读任务尚未完成——新旧朗读会产生声音重叠

解决方案是 「代际计数器(Generation Counter)」 模式:

// Recommend.ets 中的状态变量
@State ttsGeneration: number = 0;

// 每次发起推荐请求时递增代际
fetchAIRecommendations(priority?: number, resetOffset?: boolean): void {
  this.ttsGeneration++;
  // ... 异步请求 ...
  const gen = this.ttsGeneration; // 捕获当前的代际值
  // 在异步回调中比较
}

autoSpeakRecommendations(gen: number): void {
  // 只有当 gen 仍然等于当前 ttsGeneration 时才执行朗读
  if (gen !== this.ttsGeneration) return;
  // ... 执行朗读 ...
}

同时,TtsService.stop() 方法也做了强化处理:它不仅调用了 engine.stop() 停止引擎,还通过 rejectCurrentSpeak() 拒绝了当前 pending 的 Promise,确保被中断的 speak 调用不会在后续触发意外回调。

2.5 表现层:用户交互设计

在 Recommendations.ets 的推荐结果卡片上方,放置了一个圆形的 TTS 控制按钮:

  • 自动朗读:AI 推荐结果加载完毕后,自动调用 autoSpeakRecommendations(),默认朗读前 3 道菜品的名称和推荐理由。
  • 手动切换:用户点击按钮可在「正在朗读 / 静音」状态间切换,切换时立即 stop() 当前朗读。
  • 生命周期绑定:页面离开时自动 release(),避免内存泄漏。

2.6 朗读内容模板

TTS 朗读的内容不是简单的文字拼接,而是经过精心设计的自然语言模板:

"为你推荐以下菜品:
第一道,鱼香肉丝。推荐理由:荤素搭配,酸甜可口,非常适合家常晚餐。
第二道,麻婆豆腐。推荐理由:麻辣鲜香,成本低廉,只需10分钟即可上桌。
第三道,番茄牛腩。推荐理由:营养丰富,西红柿富含维生素C,牛腩提供优质蛋白。
祝你用餐愉快!"

3. 功能二:登录功能的移除

在这里插入图片描述

3.1 功能需求背景

V1.0.0 版本包含一套完整的登录系统:账号密码输入、会话管理、退出登录。但实际使用场景中,「懂吃」是一款纯本地单机应用,没有任何网络接口和后端服务器,登录系统本质上是冗余的。用户每次打开应用都要面对一个毫无意义的登录页面,这严重影响了用户体验。

3.2 移除策略:渐进式而非一刀切

登录功能的移除并非简单删除文件,而是采取了渐进式剥离策略:

步骤操作影响范围
注释登录相关数据库表 & SQLDbService.ets
注释会话 CRUD 方法DbService.ets
删除登录页面路由main_pages.json
修改 Profile 页面为自动登录Profile.ets
移除「退出登录」按钮和状态检查Profile.ets

3.3 数据库层变更

DbService.ets 中,以下内容被注释或移除:

  • TABLE_SESSION 常量(会话表名)
  • CREATE_TABLE_SESSION_SQL(建表 SQL)
  • SessionData 接口定义
  • saveSession() / loadSession() / clearSession() 三个方法
  • init() 方法中的会话表创建语句

3.4 表现层变更

Profile.ets(个人中心页面)做了以下修改:

  • 移除登录状态的实时监测逻辑
  • 移除「退出登录」按钮
  • 默认处于「已登录」状态,直接显示 admin 用户信息
  • 「我的决策记录」「我的收藏」「我的冰箱」等入口直接可点击,不再判断登录状态

3.5 设计理念

这一改动背后反映了一个重要的产品理念:「不为不存在的问题设计功能」。在纯本地应用中,用户身份的唯一性是设备本身,而不是一个虚拟的账号。移除登录功能后,用户在首次打开应用时就能直达核心功能,启动路径从 4 步缩短为 1 步,大幅降低了使用门槛。


4. 功能三:冰箱食材管理的实现

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

4.1 功能需求背景

在 V1.0.0 中,每次使用推荐功能都必须在 StepThree 页面手动勾选家中现有的食材。如果一个用户家里常备鸡蛋、西红柿、葱姜蒜等 20 种食材,那么他每次使用都需要重新勾选这 20 项——这显然是不合理的。冰箱食材管理 功能应运而生:让用户一次性维护好家中的食材库存,后续推荐时一键加载。

4.2 数据模型设计

冰箱模块涉及三个核心数据结构:

// FridgeInfo — 冰箱信息
interface FridgeInfo {
  id: number;           // 主键
  name: string;         // 冰箱名称(如"厨房大冰箱")
  ingredients: string[]; // 食材数组(JSON 存储)
  createdAt: string;    // 创建时间
  updatedAt: string;    // 更新时间
}

// FridgeState — 全局状态(单例)
class FridgeState {
  static triggerMode: boolean = false;      // 是否为选择模式
  static selectedFridgeId: number = 0;      // 已选中的冰箱 ID
}

4.3 数据库扩展

DbService.ets 中新增 fridge_list 表:

CREATE TABLE IF NOT EXISTS fridge_list (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  name TEXT NOT NULL,
  ingredients TEXT DEFAULT '[]',
  createdAt TEXT DEFAULT '',
  updatedAt TEXT DEFAULT ''
)

新增的 CRUD 方法:

方法功能说明
getAllFridges()获取全部冰箱按更新时间降序
getFridgeById(id)根据 ID 获取冰箱用于 StepThree 加载食材
addFridge(name, ingredients)新增冰箱自动生成时间戳
updateFridge(id, name, ingredients)更新冰箱替代旧的 saveFridgeIngredients
deleteFridge(id)删除单个冰箱带确认对话框
deleteAllFridges()清空所有冰箱带二次确认
getNextFridgeName()生成默认名称“我的冰箱 1、2、3…”

4.4 页面流转逻辑

冰箱功能的页面流转是整个需求中最复杂的部分:

普通模式:
Profile → FridgePage(列表) → FridgeEditPage(编辑食材) → FridgePage(返回)

选择模式(从 StepThree 触发):
StepThree → FridgePage(选择模式) → 点击"使用此冰箱" → 自动返回 StepThree(食材已填入)

关键实现细节在 StepThree.etsonPageShow() 方法中:

onPageShow(): void {
  // 每次页面显示时检测是否有从冰箱选中的食材
  if (FridgeState.selectedFridgeId > 0) {
    this.loadSelectedFridgeIngredients(FridgeState.selectedFridgeId);
    FridgeState.selectedFridgeId = 0;   // 消费后立即重置
    FridgeState.triggerMode = false;    // 退出选择模式
  }
}

这里有一个容易被忽视的 Bug:当 loadSelectedFridgeIngredients 加载完食材后,如果没有将 hasNoIngredients 置为 false,UI 会卡在「你选择了家里没有任何食材」的状态,导致已加载的食材无法显示。这一 Bug 在 V1.1.0 开发过程中被发现并修复。

4.5 食材选取 UI

FridgeEditPage 复用了 StepThree 中的食材分类体系(7 大类、100+ 食材),并增加了:

  • 新建 / 编辑双模式:通过 fridgeId 参数区分
  • 自定义食材输入:用户可添加列表中不存在的食材
  • 冰箱命名:自动生成默认名称,支持用户自定义
  • 保存即返回:保存后自动跳转回冰箱列表页,带 Toast 提示

5. 功能四:购物清单生成的实现

在这里插入图片描述
在这里插入图片描述

5.1 功能需求背景

当 AI 推荐出 3 道菜后,用户可能想做其中某几道,但家里缺少部分食材。如果逐一核对每道菜的食材清单再手动记录缺少的食材,效率极低。购物清单功能的目标是:自动分析 3 道推荐菜品的食材需求,对比用户家中已有的食材,智能生成「还缺什么」的清单。

5.2 核心算法:三步推导

购物清单的生成逻辑分为三个步骤:

第一步:合并所有推荐菜品的必需食材(去重)
       ├── 鱼香肉丝 → 猪肉、木耳、胡萝卜、青椒
       ├── 麻婆豆腐 → 豆腐、牛肉末、豆瓣酱
       └── 番茄蛋汤 → 番茄、鸡蛋
       ↓
       合并集合 → {猪肉, 木耳, 胡萝卜, 青椒, 豆腐, 牛肉末, 豆瓣酱, 番茄, 鸡蛋}

第二步:排除用户已有食材
       用户已有 → {猪肉, 鸡蛋, 番茄}
       ↓
       购物清单 → {木耳, 胡萝卜, 青椒, 豆腐, 牛肉末, 豆瓣酱}

第三步:智能预估数量
       木耳 → 50~100g
       胡萝卜 → 1~2根
       青椒 → 2~3个
       豆腐 → 1盒
       牛肉末 → 200~300g
       豆瓣酱 → 2~3勺

5.3 食材数量预估系统

ShoppingList.ets 中实现了 estimateQuantity() 函数,包含一个覆盖 100+ 食材的预估字典:

estimateQuantity(name: string): string {
  const quantityMap: Record<string, string> = {
    '猪肉': '200~300g',
    '牛肉': '200~300g',
    '鸡蛋': '2~3个',
    '番茄': '1~2个',
    '土豆': '1~2个',
    '葱': '2~3根',
    '姜': '1小块',
    '蒜': '3~4瓣',
    '大米': '200~300g',
    '面条': '150~200g',
    '豆腐': '1盒',
    '料酒': '1~2勺',
    '生抽': '1~2勺',
    '盐': '适量',
    // ... 覆盖 100+ 种常见食材
  };
  return quantityMap[name] || '适量';
}

5.4 多菜品食材合并(去重 + 溯源)

购物清单的一大亮点是 「食材溯源」 功能。合并后的每一条食材都会标注「哪些菜需要它」:

// 合并逻辑伪代码
function mergeIngredients(dishes: Dish[]): IngredientItem[] {
  const merged = new Map<string, string[]>();

  dishes.forEach(dish => {
    dish.requiredIngredients.forEach(ing => {
      const list = merged.get(ing) || [];
      if (list.indexOf(dish.name) < 0) {
        list.push(dish.name);
      }
      merged.set(ing, list);
    });
  });

  return Array.from(merged.entries()).map(([name, sources]) => ({
    name,
    quantity: estimateQuantity(name),
    sources,      // 如 ["鱼香肉丝", "麻婆豆腐"]
    alreadyHave: userHas(name),  // 家中已有?
  }));
}

5.5 导出功能

购物清单支持两种导出方式:

  1. 复制到剪贴板:使用 @ohos.pasteboard 系统 API,将清单格式化为易读的文本后复制:

    📝 懂吃购物清单
    ================
    1. 木耳 50~100g(鱼香肉丝)
    2. 胡萝卜 1~2根(鱼香肉丝)
    3. 青椒 2~3个(鱼香肉丝)
    4. 豆腐 1盒(麻婆豆腐)
    ...
    
  2. 导出为 .txt 文件:使用 fs.openSync() / fs.writeSync() 系统 API,将清单写入应用沙箱目录下的 .txt 文件,用户可通过文件管理器分享或打印。


6. 功能五:极速随机模式的实现

在这里插入图片描述
在这里插入图片描述

6.1 功能需求背景

传统的推荐流程是:口味选择 → 时间选择 → 预算选择 → 忌口选择 → 食材选择 → 查看推荐。整个过程需要 5 步操作,约 1~2 分钟。但在某些场景下——比如赶时间、选择困难症发作、或者纯粹想换个口味——用户希望一键直达结果。

极速随机模式的哲学是:「把选择权交给命运,让系统替你决定。」

6.2 实现方案

StepOne.ets 页面,「下一步」按钮的上方新增了一个 「🎲 随便吃点」按钮。它的实现逻辑极为简洁:

// StepOne.ets — 极速随机模式
goToRandomRecommend(): void {
  // 随机选择一个口味分类(0~3)
  const randomCategory = Math.floor(Math.random() * 4);

  router.pushUrl({
    url: 'pages/Recommend',
    params: {
      category: randomCategory,     // 随机口味
      time: -1,                     // 不限时间
      budget: -1,                   // 不限预算
      diet: [],                     // 无忌口
      ingredients: [],              // 无食材限制
      hasNoIngredients: false
    }
  });
}

6.3 跳过中间步骤的实现原理

极速随机模式跳过了 StepTwo(时间/预算/忌口)和 StepThree(食材选择)两个页面,从 StepOne 直接导航到 Recommend 页面。

关键在于 RecommendService.getRecommendations() 方法对 time=-1budget=-1 的处理——这两个值在评分计算中会被视为 「默认值」,不会产生额外的加分或扣分,相当于所有菜品在这些维度上评分一致。

6.4 与普通推荐的关系

维度常规推荐极速随机
操作步骤5 步1 步
口味偏好用户选择随机分配
时间限制用户选择不限
预算限制用户选择不限
忌口限制用户选择
食材匹配用户选择不匹配
耗时1~2 分钟3 秒

极速随机模式实际上是一条 「快捷通道」 ,它并没有创造新的推荐算法,而是用随机参数调用了同一套推荐引擎。


7. 功能六:本地菜品拓展至 500 道

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

7.1 功能需求背景

V1.0.0 版本仅有约 30~50 道本地菜品,每次推荐的可选范围非常有限。用户连续使用几次后就会看到重复的菜品,推荐体验大打折扣。将菜品库拓展到 500 道,意味着每次推荐的组合可能性呈指数级增长,用户的「新鲜感持久度」大幅提升。

7.2 数据架构演进

500 道菜品的静态数据若全部放在一个文件中,单个文件将达到数千行,在 ArkTS 的编译器和 IDE 中会产生严重的性能问题。解决方案是 「分片加载」

V1.0.0:
  └── LocalDishes.ets          ← 单文件,50 道菜

V1.1.0:
  ├── LocalDishes.ets           ← 入口文件,import 4 个分片
  ├── LocalDishes_0.ets         ← 130 道菜(口味 0:家常清淡)
  ├── LocalDishes_1.ets         ← 109 道菜(口味 1:重口过瘾)
  ├── LocalDishes_2.ets         ← 134 道菜(口味 2:健康轻食)
  └── LocalDishes_3.ets         ← 127 道菜(口味 3:快餐速食)

总计:130 + 109 + 134 + 127 = 500 道菜

7.3 菜品数据结构

每道菜品的数据结构定义在 DishTypes.ets 中:

interface DishInfo {
  id: number;           // 唯一 ID
  name: string;         // 菜品名称
  icon: string;         // Emoji 图标
  category: number;     // 口味分类 (0~3)
  cost: string;         // 价格区间
  cookCost: number;     // 烹饪耗时(分钟)
  difficulty: string;   // 难度等级
  ingredients: string[]; // 所需食材列表
  reason: string;       // 推荐理由
}

每个分片文件 export 一个数组:

// LocalDishes_0.ets
export const LOCAL_DISHES_0: DishInfo[] = [
  {
    id: 1,
    name: '番茄炒蛋',
    icon: '🍅',
    category: 0,
    cost: '5~10元',
    cookCost: 10,
    difficulty: '简单',
    ingredients: ['番茄', '鸡蛋', '葱'],
    reason: '国民家常菜,酸甜可口,营养均衡。'
  },
  // ... 130 道菜
];

7.4 分片加载的挑战

分片方案虽然解决了单文件过大问题,但引入了新的挑战:

  1. 导出名冲突LOCAL_DISHES_0LOCAL_DISHES_1 等导出名必须在各文件中唯一,且 import 路径不能有 .ets 后缀。

  2. 跨文件类型引用DishTypes.ets 中的 DishInfo 接口需要在所有分片文件中保持一致。若接口变更,所有分片文件都需要同步修改。

  3. 静态数据冗余:500 道菜全部存在于 APK 包中,没有按需加载——但这对于本地应用而言是可接受的(500 条 JSON 数据约 50~80KB,微乎其微)。

7.5 重复数据清理

在开发过程中,DishDetailData.ets 中被发现存在菜品重复问题:L3727-3736 的「牛排汉堡」和 L4997-5006 的「黑椒牛柳意大利面」各出现了两次。这些重复项被逐一排查并删除,最终菜品详情数据从 500 道调整为 498 道(去重后)。


8. 功能七:菜品详情页的实现

在这里插入图片描述
在这里插入图片描述

8.1 功能需求背景

V1.0.0 的推荐卡片只能展示菜品名称、图标和简短推荐理由。用户想知道「这道菜需要什么食材?」「怎么做的?」「热量高不高?」——这些信息在旧版本中完全缺失。

菜品详情页 的定位是:一道菜的 「完整档案」 ,包含食材清单、烹饪步骤、营养估算三大核心模块。

8.2 数据模型

// DishDetailData.ets 中的完整菜品数据
interface DishDetailInfo {
  dishName: string;           // 菜名
  dishIcon: string;           // 图标 Emoji
  dishReason: string;         // 推荐理由
  dishDifficulty: string;     // 难度
  ingredients: string[];      // 食材清单
  steps: string[];            // 烹饪步骤(有序数组)
  nutrition: {                // 营养估算
    calories: string;         // 热量(千卡)
    protein: string;          // 蛋白质(g)
    fat: string;              // 脂肪(g)
    carbs: string;            // 碳水化合物(g)
  };
}

8.3 页面路由与数据传递

从推荐页面的菜品卡片跳转到详情页,采用 router.pushUrl() 传递参数:

// Recommend.ets — 点击菜品卡片
goToDishDetail(dish: DishInfo): void {
  router.pushUrl({
    url: 'pages/DishDetail',
    params: {
      dishName: dish.name,
      dishIcon: dish.icon,
      dishReason: dish.reason,
      dishDifficulty: dish.difficulty,
      ingredients: dish.ingredients,
      // 从 DishDetailData 中查找 steps 和 nutrition
    }
  });
}

详情页接收参数:

// DishDetail.ets — aboutToAppear
aboutToAppear(): void {
  const params = router.getParams() as Record<string, Object>;
  this.dishName = params['dishName'] as string;
  this.dishIcon = params['dishIcon'] as string;
  // ... 从 DishDetailData 查找完整信息
}

8.4 页面布局设计

详情页采用纵向滚动布局,从上到下依次为:

  1. 菜品头部:大号 Emoji 图标 + 菜名 + 难度/耗时标签
  2. 推荐理由区:带背景色的引言式推荐语
  3. 食材清单:带 Emoji 图标 + 数量的食材列表(如「🥩 猪肉 200g」)
  4. 烹饪步骤:有序步骤列表,每步带序号和描述
  5. 营养信息:4 宫格展示热量、蛋白质、脂肪、碳水化合物

8.5 与推荐引擎的解耦

菜品详情数据(DishDetailData.ets)与推荐引擎(RecommendService.ets)是完全解耦的。推荐引擎只关心 DishInfo 中的评分维度(分类、耗时、成本、食材),而详情数据只在用户点击卡片后才按需加载。这种设计使得:

  • 推荐引擎保持轻量,500 道菜的详情数据不会影响推荐性能
  • 详情数据的增删改不影响推荐逻辑
  • 未来可以独立扩展详情数据(如增加用户评论、图片等)

9. 功能八:饮食统计仪表盘的实现

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

9.1 功能需求背景

「懂吃」的每一次推荐都会生成一条决策记录,包含:选择了什么口味、限定了什么条件、推荐了哪些菜品。日积月累,这些记录成为了一份珍贵的 「个人饮食大数据」 。V1.0.0 对此毫无利用——记录只是静静地躺在数据库中。

饮食统计仪表盘 的使命是:将这些沉睡的数据唤醒,通过可视化的图表,让用户直观地看到自己的饮食偏好和消费趋势。

9.2 统计维度

仪表盘从 6 个维度对决策记录进行统计分析:

维度图表类型数据来源洞察价值
🎨 各口味占比饼图每次决策的 category了解自己的口味倾向
🏆 最常选的菜 Top 10横向柱状图解析 results JSON 中的菜品名发现复购率最高的菜品
💰 平均花费趋势折线图菜品 cookCost 按月聚合观察饮食成本变化
⏰ 时间偏好分布饼图每次决策的 timeLabel了解自己的烹饪时间偏好
💵 预算偏好分布柱状图每次决策的 budgetLabel了解自己的消费区间
🚫 饮食限制统计进度条列表每次决策的 dietLabels关注自己的忌口趋势

9.3 数据聚合算法

DietDashboard.computeStats() 是统计仪表盘的核心方法,它接收 DecisionRecord[] 数组,经过一次遍历完成所有维度的数据聚合:

computeStats(records: DecisionRecord[]): void {
  // 1. 初始化计数器
  const dishCountMap = new Map<string, number>();
  const categoryCountMap = [0, 0, 0, 0];  // 4 种口味
  const budgetCountMap = new Map<string, number>();
  const timeCountMap = new Map<string, number>();
  const dietCountMap = new Map<string, number>();
  const costByMonth = new Map<string, number[]>();

  // 2. 一次遍历完成所有维度的统计
  for (const record of records) {
    // 口味统计
    categoryCountMap[record.category]++;

    // 预算/时间/忌口统计
    // ...

    // 解析 results JSON,统计菜品频次和花费
    const dishes = JSON.parse(record.results);
    for (const dish of dishes) {
      dishCountMap.accumulate(dish.name);
      costByMonth.accumulate(monthKey, dish.cookCost);
    }
  }

  // 3. 排序和截取
  this.topDishes = sortedDishes.slice(0, 10);  // Top 10
  this.categoryStats = categoryCountMap.map(...); // 转为 PieSlice
  this.costTrend = monthKeys.sort().map(...);  // 按月排序
}

9.4 Canvas 2D 图表绘制

「懂吃」没有依赖任何第三方图表库,而是直接使用 HarmonyOS 的 Canvas 组件 + CanvasRenderingContext2D API 手动绘制所有图表。选择手动绘制的原因:

  1. 包体积:避免引入第三方库带来的体积膨胀
  2. 性能:Canvas 2D 直接操作像素,性能优于 DOM 布局
  3. 精细控制:可以精确控制每一个像素的颜色、位置和动画
饼图绘制算法
drawPieChart(canvas: CanvasRenderingContext2D,
  data: PieSlice[], cx: number, cy: number, radius: number): void {
  const total = data.reduce((s, d) => s + d.value, 0);
  if (total === 0) return;

  let startAngle = -Math.PI / 2; // 从 12 点钟方向开始

  for (const item of data) {
    const sliceAngle = (item.value / total) * 2 * Math.PI;

    // 绘制扇形
    canvas.beginPath();
    canvas.moveTo(cx, cy);
    canvas.arc(cx, cy, radius, startAngle, startAngle + sliceAngle);
    canvas.closePath();
    canvas.fillStyle = item.color;
    canvas.fill();

    // 标注百分比(只在 >=5% 时显示,避免文字拥挤)
    const midAngle = startAngle + sliceAngle / 2;
    const labelR = radius * 0.65;
    const lx = cx + labelR * Math.cos(midAngle);
    const ly = cy + labelR * Math.sin(midAngle);
    const pct = Math.round((item.value / total) * 100);
    if (pct >= 5) {
      canvas.font = '18px sans-serif';
      canvas.fillStyle = '#FFFFFF';
      canvas.textAlign = 'center';
      canvas.fillText(`${pct}%`, lx, ly);
    }

    startAngle += sliceAngle;
  }
}
横向柱状图绘制算法

Top 10 菜品使用横向柱状图,每根柱子从左到右延伸,长度与频次成正比:

drawHorizontalBarChart(/* ... */): void {
  // 柱体使用圆角矩形(quadraticCurveTo 实现)
  // 柱体长度 = (value / maxValue) * maxBarLen
  // 菜品名在柱体内部(柱体足够长时)或右侧
  // 频次数值标注在柱体末端
}
折线图绘制算法

花费趋势使用折线图,X 轴为月份,Y 轴为平均花费:

drawLineChart(/* ... */): void {
  // 绘制坐标轴 + 网格线
  // 使用 lineTo 绘制折线段
  // 在每个数据点绘制圆形标记(内白外橙双色)
  // 标注具体数值在标记上方
}

9.5 卡片式布局的响应式设计

仪表盘的每一张图表都包裹在一个圆角卡片中:

Column() {
  // 图表标题
  Text('🎨 各口味占比')
    .fontSize(17).fontWeight(FontWeight.Bold)

  // Canvas 图表
  Canvas(this.pieCtx)
    .width(this.pieSize)
    .height(this.pieSize)

  // 图例
  Flex({ wrap: FlexWrap.Wrap }) {
    ForEach(data, (item) => {
      Row() {
        Row().width(10).height(10).backgroundColor(item.color)
        Text(`${item.name}: ${item.count}`)
      }
    })
  }
}
.backgroundColor($r('app.color.card_bg'))
.borderRadius(16)
.padding(16)
.margin({ left: 16, right: 16, top: 16 })

卡片设计遵循 「信息密度可调节」 原则:无数据时显示引导提示,有数据时按模块展示图表,每个图表卡片的出现条件是 if (data.length > 0)


10. 架构总结与技术选型

10.1 V1.1.0 整体架构图

┌──────────────────────────────────────────────────┐
│                    表现层 (Pages)                  │
│  Index  StepOne  StepTwo  StepThree  Recommend    │
│  Favorites  ShoppingList  DishDetail  Profile     │
│  FridgePage  FridgeEditPage  DecisionRecords     │
│  DietDashboard                                    │
├──────────────────────────────────────────────────┤
│                   业务层 (Service)                 │
│  RecommendService  TtsService  DbService         │
│  DishTypes  DishDetailData  FridgeState          │
├──────────────────────────────────────────────────┤
│                    数据层                         │
│  ┌─────────────┐  ┌─────────────────────────┐    │
│  │ LocalDishes │  │ relationalStore (SQLite) │    │
│  │ _0~3 (500道)│  │ └─ decision_records     │    │
│  └─────────────┘  │ └─ favorites            │    │
│                   │ └─ fridge_list          │    │
│                   └─────────────────────────┘    │
├──────────────────────────────────────────────────┤
│                 系统能力 (System API)              │
│  textToSpeech · pasteboard · fs · router         │
└──────────────────────────────────────────────────┘

10.2 关键技术决策

决策方案替代方案选择理由
本地数据库relationalStore (SQLite)文件存储 / SharedPreferences支持 SQL 查询、事务、数据完整性
图表绘制Canvas 2D 手绘第三方图表库 (ECharts)避免包体积膨胀、精细控制
TTS 引擎HMS textToSpeech自定义语音合成系统原生支持、质量稳定
菜品数据分片静态常量动态加载 / API 请求纯本地、零延迟、无网络依赖
页面路由router.pushUrlNavigation API简单直接、参数传递方便
状态管理@State + 静态类AppStorage / 全局变量简洁、类型安全

10.3 数据流示例:一次完整推荐流程

用户点击"AI 推荐"
  ↓
StepThree 收集: category=1, time=30, budget=低, diet=[不吃辣], ingredients=[鸡蛋,番茄]
  ↓  router.pushUrl
Recommend.aboutToAppear()
  ↓
fetchAIRecommendations(priority=0, resetOffset=true)
  ↓
RecommendService.getRecommendations(1, 30, 低, [不吃辣], [鸡蛋,番茄], 0)
  ↓
加权评分: 
  ─ category 匹配 +40 (基础权重)
  ─ cookCost ≤ 30    +50 (时间匹配)
  ─ cost = 低        +50 (预算匹配)
  ─ diet 过滤          ✗ (排除含辣的菜)
  ─ ingredient 匹配   +70 (食材匹配)
  ─ 所有 offset=0~2 的菜品
  ↓
返回 3 道菜品 [番茄炒蛋, 紫菜蛋花汤, 木须肉]
  ↓
UI 渲染推荐卡片 + 自动触发 TTS 朗读
  ↓
用户点击"番茄炒蛋" → router.pushUrl → DishDetail 页面
  ↓
DishDetail 展示:食材清单、烹饪步骤、营养信息

10.4 展望:未来可能的演进方向

在 V1.1.0 的版本更新日志中,曾讨论过一些尚未实现的功能思路,可以作为后续版本的演进方向:

  1. AI 一键排菜:根据用户偏好 + 现有食材,用本地算法自动排出一周的菜谱(每天 3 道 + 购物清单)
  2. 桌面 Widget:利用 HarmonyOS Service Widget,每天自动刷新推荐一道菜
  3. 营养目标追踪:根据决策记录中的营养数据,追踪用户的蛋白质/碳水摄入比例
  4. 智能冰箱联动:通过扫码或语音添加冰箱食材

附录:文件变更清单

操作文件路径说明
🆕 新增service/TtsService.etsTTS 语音引擎封装
🆕 新增service/FridgeState.ets冰箱全局状态管理
🆕 新增pages/FridgeEditPage.ets冰箱食材编辑页
🆕 新增pages/ShoppingList.ets购物清单页(613 行)
🆕 新增pages/DishDetail.ets菜品详情页
🆕 新增pages/DietDashboard.ets饮食统计仪表盘(1057 行)
🆕 新增service/DishDetailData.ets菜品详情数据(498 道菜)
🆕 新增service/DishTypes.ets菜品类型定义
🆕 新增service/LocalDishes_0~3.ets500 道菜品分片数据
✏️ 修改pages/Recommend.ets添加 TTS 按钮、购物清单按钮、偏好切换
✏️ 修改pages/Profile.ets移除登录、添加冰箱/仪表盘入口
✏️ 修改pages/StepThree.ets添加冰箱加载功能、修复 bug
✏️ 修改pages/StepOne.ets添加「极速随机模式」按钮
✏️ 修改service/DbService.ets注释登录相关、添加冰箱 CRUD
✏️ 修改service/RecommendService.ets添加优先级评分、偏移管理
✏️ 修改main_pages.json注册新页面路由

后记
「懂吃」V1.1.0 的开发历时 8 天(2026.6.21 ~ 2026.6.28),从需求调研到功能实现均由 AtomCode AI 编码助手与开发者协作完成。本次更新不仅新增了 8 大功能,更重要的是建立了一套可扩展的本地智能推荐架构——500 道本地菜品 + 加权评分算法 + 多维统计,为后续版本的迭代奠定了坚实基础。

如开发者所言:「可能不是最好吃的,但一定是最懂你的。」

Logo

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

更多推荐