【HarmonyOS 6.1 全场景实战】《灵犀厨房》实战(十):【购物清单】一键生成并分组展示——把缺货食材变成超市采购导航图
摘要:本文介绍如何在HarmonyOS 6.1应用中实现智能购物清单功能。通过分组算法将缺货食材按品类(蔬菜、肉类、调料等)自动分类,使用ArkUI的List+ListItemGroup组件实现折叠式清单展示。核心是客户端本地映射表实现零延迟分组,避免网络依赖。文章详细解析了分组算法原理、UI方案选型及数据传递方式,并提供了完整的代码实现路径,帮助开发者打造更高效的超市采购导航体验。
HarmonyOS 6.1 开发者盛宴|《灵犀厨房》实战(十):【购物清单】一键生成并分组展示——把缺货食材变成超市采购导航图
摘要:上一篇我们为菜谱详情页接入了食材勾选功能,用户终于能像在厨房翻查食材一样,对着清单打钩已备项。但勾选完后的“然后”呢?站在超市里手忙脚乱地来回翻找缺了啥,这体验可算不上优雅。今天,我们要用分组算法将缺货食材一键生成购物清单,并按品类(蔬菜、肉类、调料)分块展示。你将学会如何用
Object.keys+ 分类映射实现智能分组,用 ArkUI 的List+ListItemGroup打造折叠式清单卡片,让采购像逛超市一样按区索骥。
一、引言与系列定位
经过第九篇的打磨,我们的详情页拥有了食材勾选能力——点一点 Checkbox,“已备/缺货”数字实时跳动。但这是一个未完的闭环:当用户勾选完所有已备食材,那些还打着“待购”标签的食材,该如何一目了然地呈现在购物场景中?
这一篇,就是来接棒第九篇埋下的 getMissingCount() 伏笔。我们要新增一个 ShoppingListPage,它能根据用户在详情页的勾选结果,一键生成分组购物清单——蔬菜类、肉类、调料类各自成块,每块内列出缺货食材名称。你拿着手机在超市里,只需按分区扫一眼,采购效率翻倍。
整套改造只新增 pages/ShoppingListPage.ets 和 viewmodel/ShoppingListViewModel.ets,RecipeDetailPage 只加一个“生成清单”按钮,原有逻辑一行不动。
二、核心原理与底层机制深度解读
2.1 分组算法:给超市购物车画一张“导航地图”
你在超市采购时,脑子里的清单其实不是线性列表,而是一张分区地图:先去蔬菜区拿番茄和葱,再去肉品区买鸡胸肉,最后到调味品区补一瓶酱油。
我们的分组算法,本质是把这个“人脑分区地图”代码化。核心数据结构是一个 Map<string, string[]>——键是品类名(如“蔬菜”),值是该品类下的缺货食材名数组。
金句:分组算法不是在“排序”,而是在“归类”——它模拟的是你站在超市入口时,脑子里那张按货架顺序排列的采购路线图。
2.2 为什么不用服务端分组?
| 方案 | 实现方式 | 延迟 | 离线能力 | 选型 |
|---|---|---|---|---|
| 客户端本地映射 | 代码内置食材→品类 Map | 零 | ✅ 完全离线 | ✅ 本篇采用 |
| 服务端 API 分组 | 每次请求后端分组接口 | 取决于网络 | ❌ 需联网 | ❌ 过度设计 |
| AI 智能分组 | 调用云端 NLP 分类 | 高 | ❌ | ⏳ V3.0 预留 |
当前阶段,《灵犀厨房》的食谱数据都在本地 MockData 中,食材种类有限(百余种常见食材),用客户端静态映射表即可覆盖 95% 的使用场景,且零延迟、零网络依赖。
三、关键知识点详解
3.1 购物清单 UI 方案对比
| 方案 | 核心组件 | 优点 | 缺点 | 选型分析 |
|---|---|---|---|---|
List + ListItemGroup |
分组列表 | 天然支持分组展示,每组有独立标题,可折叠 | 学习成本稍高 | ✅ 本篇采用,主打分区视图 |
Scroll + 手写分组 |
线性布局 | 完全自定义 | 需手写大量布局代码,重复造轮子 | ❌ |
Grid 网格 |
网格布局 | 视觉紧凑 | 无法清晰区分类别边界 | ❌ 适合照片墙,不适合清单 |
Tabs 标签页 |
分类 Tab | 切换方便 | 同一品类可能内容很少,Tab 显得空 | ❌ |
3.2 数据传递方式对比(吸取第9篇教训)
| 方案 | 传递内容 | 退化风险 | 编译安全 | 选型 |
|---|---|---|---|---|
传递 Record<string, string[]> |
纯净的键值对 | 无 | ✅ 完全合规 | ✅ 本次采用 |
传递 @ObservedV2 对象 |
响应式类实例 | 属性名变 __ob_ |
❌ 第9篇已验证不可行 | ❌ |
| AppStorage | 全局状态 | 跨页面污染 | ⚠️ 需手动清理 | ❌ 过度 |
核心原则:跨路由边界,永远只传纯净数据(
string、number、boolean、Array、Record),在消费端重建响应式对象。这是第9篇用无数次编译报错换来的经验。
四、架构设计 / 核心逻辑图解
4.1 购物清单生成流水线
图一解读:购物清单的生成过程是一个纯粹的函数式管道——输入缺货食材名称数组,经分类映射表转换,输出分组对象。整个过程中,Input 侧的 IngredientViewModel 和 Output 侧的购物清单 UI 完全解耦,通过纯净的 Record<string, string[]> 连接。
五、实战:实现购物清单生成与分组展示
Step 1:创建食材→品类映射表
新建 viewmodel/ShoppingListViewModel.ets:
// viewmodel/ShoppingListViewModel.ets
/**
* 食材→品类映射表
* 覆盖常见食材,可随时扩展
*/
const INGREDIENT_CATEGORY_MAP: Map<string, string> = new Map([
// 肉类
['牛腩', '肉类'], ['排骨', '肉类'], ['五花肉', '肉类'], ['牛肉', '肉类'],
['鸡胸肉', '肉类'], ['鸡腿', '肉类'], ['虾仁', '肉类'], ['虾', '肉类'],
['午餐肉', '肉类'], ['鲈鱼', '肉类'],
// 蔬菜
['番茄', '蔬菜'], ['洋葱', '蔬菜'], ['西兰花', '蔬菜'], ['土豆', '蔬菜'],
['冬瓜', '蔬菜'], ['黄瓜', '蔬菜'], ['藕片', '蔬菜'], ['葱', '蔬菜'],
['葱花', '蔬菜'],
// 调料
['姜', '调料'], ['蒜', '调料'], ['酱油', '调料'], ['老抽', '调料'],
['蚝油', '调料'], ['料酒', '调料'], ['生抽', '调料'], ['醋', '调料'],
['盐', '调料'], ['糖', '调料'], ['冰糖', '调料'], ['花椒', '调料'],
['干辣椒', '调料'], ['辣椒油', '调料'], ['香油', '调料'],
['照烧汁', '调料'], ['蒸鱼豉油', '调料'], ['番茄酱', '调料'],
['麻辣香锅底料', '调料'],
// 主食/干货
['米饭', '主食'], ['薏米', '干货'], ['枸杞', '干货'], ['八角', '干货'],
['桂皮', '干货'], ['鸡蛋', '蛋奶'], ['温水', '水'],
// 兜底
['适量', '其他'], ['少许', '其他'], ['几滴', '其他']
]);
/**
* 缺货食材分组函数
* @param missingItems 缺货食材名称数组
* @returns 分组后的对象,键为品类名,值为该品类下的食材数组
*/
export function groupIngredients(missingItems: string[]): Record<string, string[]> {
const grouped: Record<string, string[]> = {};
for (const item of missingItems) {
// 查映射表,找不到归入「其他」
const category = INGREDIENT_CATEGORY_MAP.get(item) ?? '其他';
if (!grouped[category]) {
grouped[category] = [];
}
grouped[category].push(item);
}
console.info(`[ShoppingListVM] 分组完成,共 ${Object.keys(grouped).length} 个品类`);
return grouped;
}
变化点解读:
INGREDIENT_CATEGORY_MAP静态映射:这是整个分组逻辑的“字典”。覆盖了 MockData 中出现的所有食材,不会因为某个食材找不到分类而出错(有?? '其他'兜底)。groupIngredients纯函数:输入string[],输出Record<string, string[]>,无副作用、无状态依赖。这意味着你可以随时调整映射表,函数逻辑完全不受影响。- 完全解耦:这个文件不依赖任何 ArkUI 组件、不依赖
@ObservedV2,可以在任何地方调用,也为未来的服务端分组预留了替换接口。
Step 2:改造 RecipeDetailPage,添加“生成购物清单”入口
找到了 RecipeDetailPage.ets 底部的“开始烹饪”按钮区域,在其旁边新增一个按钮:
// pages/RecipeDetailPage.ets
// 找到底部操作区 Row,在“开始烹饪”按钮后新增:
Button('生成购物清单')
.type(ButtonType.Capsule)
.fontSize(14)
.fontWeight(FontWeight.Medium)
.backgroundColor('#4CAF50') // 绿色,与“开始烹饪”的橙色形成视觉区分
.fontColor(Color.White)
.layoutWeight(1)
.onClick(() => {
// 获取缺货食材名称数组
const missingNames = this.ingredientVM.items
.filter(item => !item.isChecked)
.map(item => item.name);
if (missingNames.length === 0) {
promptAction.showToast({
message: '所有食材已备齐,无需生成购物清单!',
duration: 2000
});
return;
}
// 调用分组函数
const grouped = groupIngredients(missingNames);
console.info(`[RecipeDetail] 生成购物清单,缺货 ${missingNames.length} 项`);
// 路由跳转,传递分组后的纯净数据
router.pushUrl({
url: 'pages/ShoppingListPage',
params: {
groupedItems: grouped,
recipeName: this.recipe.name
}
}).catch((err: Error) => {
console.error(`[RecipeDetail] 跳转购物清单页失败: ${JSON.stringify(err)}`);
});
})
核心点解读:
- 数据提取:
this.ingredientVM.items.filter(item => !item.isChecked).map(item => item.name)—— 从 ViewModel 中筛选未勾选项,只提取name字符串。传递给路由的是纯净的string[],不会触发任何@ObservedV2序列化问题。- 分组后再传递:传给
ShoppingListPage的是已经处理好的Record<string, string[]>对象,购物清单页只需渲染,无需再做任何分类逻辑。- 防御判空:如果所有食材都已勾选,直接 Toast 提示“无需生成”,避免空清单页面。
Step 3:创建购物清单展示页
新建 pages/ShoppingListPage.ets:
// pages/ShoppingListPage.ets
import { router } from '@kit.ArkUI';
@Entry
@Component
struct ShoppingListPage {
@State groupedItems: Record<string, string[]> = {};
@State recipeName: string = '';
aboutToAppear(): void {
const params = router.getParams() as Record<string, Object>;
if (params) {
this.groupedItems = params['groupedItems'] as Record<string, string[]>;
this.recipeName = params['recipeName'] as string;
}
console.info(`[ShoppingList] 购物清单加载: ${this.recipeName}, ${Object.keys(this.groupedItems).length} 个品类`);
}
build() {
Column() {
// 顶部导航
Row() {
Button({ type: ButtonType.Circle }) {
SymbolGlyph($r('sys.symbol.chevron_left'))
.fontSize(20)
.fontColor([Color.White])
}
.width(36).height(36)
.backgroundColor('rgba(0,0,0,0.1)')
.onClick(() => router.back())
Text('购物清单')
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor('#333')
.layoutWeight(1)
.textAlign(TextAlign.Center)
Row().width(36).height(36) // 占位,保持标题居中
}
.width('100%')
.padding({ left: 16, right: 16, top: 12 })
.margin({ top: 24 })
// 菜谱名称副标题
Text(`来自「${this.recipeName}」的缺货食材`)
.fontSize(13)
.fontColor('#999')
.padding({ left: 16, top: 4, bottom: 12 })
// 分组清单
List() {
ForEach(Object.keys(this.groupedItems), (category: string) => {
ListItemGroup({ header: this.categoryHeader(category) }) {
ForEach(this.groupedItems[category], (item: string) => {
ListItem() {
Row({ space: 10 }) {
// 品类色块指示器
Circle()
.width(8).height(8)
.fill(this.getCategoryColor(category))
Text(item)
.fontSize(15)
.fontColor('#333')
}
.width('100%')
.padding({ left: 16, top: 8, bottom: 8 })
}
}, (item: string) => item)
}
}, (category: string) => category)
}
.width('100%')
.layoutWeight(1)
.backgroundColor('#F8F9FA')
}
.width('100%')
.height('100%')
.backgroundColor(Color.White)
}
/**
* 分组标题构建器
*/
@Builder
categoryHeader(category: string) {
Row({ space: 8 }) {
Text(category)
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(this.getCategoryColor(category))
Text(`( ${this.groupedItems[category].length} 项 )`)
.fontSize(12)
.fontColor('#999')
}
.width('100%')
.padding({ left: 16, top: 14, bottom: 6 })
.backgroundColor('#F8F9FA')
}
/**
* 品类对应色卡
*/
getCategoryColor(category: string): string {
const colorMap: Record<string, string> = {
'肉类': '#E53935',
'蔬菜': '#43A047',
'调料': '#FB8C00',
'主食': '#8D6E63',
'干货': '#795548',
'蛋奶': '#FDD835',
'其他': '#78909C'
};
return colorMap[category] ?? '#78909C';
}
}
核心点解读:
ListItemGroup+@Builder:ListItemGroup是 ArkUI 原生分组列表组件,配合{ header: this.categoryHeader(category) }参数,每个品类自动生成一个带标题的分组块。@Builder方法封装了标题栏的 UI 构建逻辑,代码可读性高。- 品类色块:肉类用红色、蔬菜用绿色、调料用橙色——用户只需扫一眼色块,就能快速定位品类,不需要逐行读文字。这是从超市货架分区牌中借鉴的视觉设计。
- 纯净数据渲染:
this.groupedItems是Record<string, string[]>,已在路由传递前就完成了分组,本页只负责渲染,逻辑干净、渲染高效。ForEach的 key 函数:(item: string) => item和(category: string) => category提供了稳定的唯一键,ArkUI 可以精准追踪每一项,避免不必要的重绘。
Step 4:检查注册新页面路由(新建page默认会添加)
在 src/main/resources/base/profile/main_pages.json 中新增一行:
{
"src": [
"pages/Index",
"pages/RecipeDetailPage",
"pages/ShoppingListPage"
]
}
变化点解读:DevEco Studio 在新建 Page 时通常会自动注册,但如果你手动创建了
.ets文件,务必检查此配置。如果路径不在这里,router.pushUrl会失败。注意:router.pushUrl过时的方法,最新的是this.getUIContext().getRouter().pushUrl
六、运行与结果验证
6.1 操作步骤
-
从首页点击“虾仁蒸蛋”进入详情页。
-
在食材勾选清单中,勾选“鸡蛋”、“温水”、“盐”、“香油”等已有食材。

-
确认“葱花”保持未勾选状态。
-
点击底部绿色“生成购物清单”按钮。

-
观察页面过渡到购物清单页,按品类分块展示缺货食材。
6.2 预期日志输出
……
[ShoppingListVM] 分组完成,共 1 个品类
[RecipeDetail] 生成购物清单,缺货 1 项
[ShoppingList] 购物清单加载: 虾仁蒸蛋, 1 个品类
……
6.3 日志解读
生成购物清单:打印缺货总数,验证筛选逻辑正确。分组完成:打印品类数,验证分组函数工作正常。购物清单加载:打印菜谱名和品类数,验证路由数据传递成功,页面准备渲染。
💡 测试提示:你可以在不同菜谱的详情页测试——勾选不同比例的食材,观察购物清单页的内容变化,验证分组算法对各类食材的覆盖情况。
七、本阶段总结与下篇预告
今天,我们完成了食材勾选到购物清单的闭环:
- 分组引擎:通过静态映射表
INGREDIENT_CATEGORY_MAP,将字符串数组按品类分组,零延迟、离线可用。 - 纯净数据传递:吸取第9篇教训,只传
Record<string, string[]>,避开@ObservedV2序列化陷阱。 - 分组 UI:用
List+ListItemGroup打造分区清单卡片,色块视觉让品类一目了然。 - 渐进集成:只在详情页加一个按钮,原有勾选逻辑一行不改。
现在,用户可以在详情页勾选已备食材,一键生成按蔬菜、肉类、调料分组的购物清单,拿着手机去超市时,就像有了导航地图——先去蔬菜区、再去肉品区、最后调味区,效率翻倍。
但目前的购物清单还只是在虚拟的屏幕上。真正的智慧厨房,应该能感知你的身体状态——比如,你今天走了多少步?需要多少热量?
下篇预告:第11篇《【数据打通】访问 Health Kit 获取健康数据》。我们将首次接入系统级数据能力,读取用户的步数、卡路里消耗等健康数据,为第12篇的营养分析引擎提供计算依据——让推荐菜谱不仅能“好吃”,还能“吃对”。
📚 本系列持续更新中:下一篇将带你打通 Health Kit,让菜谱推荐更懂你的身体。
🔗 专栏入口:[《从0到1开发灵犀厨房App》合集] | ⭐ 源码:Gitee 仓库(第10章代码已同步更新)
💎 开发者福利:
点赞+收藏本专栏,评论区留言“购物清单,分组出行”,私信我即可领取《HarmonyOS 6.1 技术安全白皮书》电子版!
纯血鸿蒙,用心造厨。我们下一篇见!
更多推荐


所有评论(0)