项目名称:懂吃(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 移除策略:渐进式而非一刀切

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

步骤 操作 影响范围
注释登录相关数据库表 & SQL DbService.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.pushUrl Navigation 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.ets TTS 语音引擎封装
🆕 新增 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.ets 500 道菜品分片数据
✏️ 修改 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、测试、元服务和应用上架分发等。

更多推荐