懂吃(DongChi)V1.1.0 版本更新详解
项目名称:懂吃(DongChi)
版本号:V1.1.0
开发工具:DevEco Studio 6.1.1 + AtomCode AI 编码助手
目标平台:HarmonyOS(ArkTS + ArkUI)
发布日期:2026年6月
字数统计:约 10000 字
目录
- 概述:从 V1.0.0 到 V1.1.0 的蜕变
- 功能一:TTS 朗读功能实现
- 功能二:登录功能的移除
- 功能三:冰箱食材管理的实现
- 功能四:购物清单生成的实现
- 功能五:极速随机模式的实现
- 功能六:本地菜品拓展至 500 道
- 功能七:菜品详情页的实现
- 功能八:饮食统计仪表盘的实现
- 架构总结与技术选型
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 功能中最具技术含量的设计。考虑以下场景:
- 用户当前在「综合推荐」模式下,AI 正在朗读推荐结果
- 用户突然切换优先级标签到「食材优先」
- 新的推荐数据加载完毕,需要朗读新内容
- 旧的朗读任务尚未完成——新旧朗读会产生声音重叠
解决方案是 「代际计数器(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.ets 的 onPageShow() 方法中:
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 导出功能
购物清单支持两种导出方式:
-
复制到剪贴板:使用
@ohos.pasteboard系统 API,将清单格式化为易读的文本后复制:📝 懂吃购物清单 ================ 1. 木耳 50~100g(鱼香肉丝) 2. 胡萝卜 1~2根(鱼香肉丝) 3. 青椒 2~3个(鱼香肉丝) 4. 豆腐 1盒(麻婆豆腐) ... -
导出为 .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=-1 和 budget=-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 分片加载的挑战
分片方案虽然解决了单文件过大问题,但引入了新的挑战:
-
导出名冲突:
LOCAL_DISHES_0、LOCAL_DISHES_1等导出名必须在各文件中唯一,且 import 路径不能有.ets后缀。 -
跨文件类型引用:
DishTypes.ets中的DishInfo接口需要在所有分片文件中保持一致。若接口变更,所有分片文件都需要同步修改。 -
静态数据冗余: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 页面布局设计
详情页采用纵向滚动布局,从上到下依次为:
- 菜品头部:大号 Emoji 图标 + 菜名 + 难度/耗时标签
- 推荐理由区:带背景色的引言式推荐语
- 食材清单:带 Emoji 图标 + 数量的食材列表(如「🥩 猪肉 200g」)
- 烹饪步骤:有序步骤列表,每步带序号和描述
- 营养信息: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 手动绘制所有图表。选择手动绘制的原因:
- 包体积:避免引入第三方库带来的体积膨胀
- 性能:Canvas 2D 直接操作像素,性能优于 DOM 布局
- 精细控制:可以精确控制每一个像素的颜色、位置和动画
饼图绘制算法
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 的版本更新日志中,曾讨论过一些尚未实现的功能思路,可以作为后续版本的演进方向:
- AI 一键排菜:根据用户偏好 + 现有食材,用本地算法自动排出一周的菜谱(每天 3 道 + 购物清单)
- 桌面 Widget:利用 HarmonyOS Service Widget,每天自动刷新推荐一道菜
- 营养目标追踪:根据决策记录中的营养数据,追踪用户的蛋白质/碳水摄入比例
- 智能冰箱联动:通过扫码或语音添加冰箱食材
附录:文件变更清单
| 操作 | 文件路径 | 说明 |
|---|---|---|
| 🆕 新增 | 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 道本地菜品 + 加权评分算法 + 多维统计,为后续版本的迭代奠定了坚实基础。如开发者所言:「可能不是最好吃的,但一定是最懂你的。」
更多推荐



所有评论(0)