HarmonyOS 6.1 开发者盛宴|《灵犀厨房》实战(十):【购物清单】一键生成并分组展示——把缺货食材变成超市采购导航图

摘要:上一篇我们为菜谱详情页接入了食材勾选功能,用户终于能像在厨房翻查食材一样,对着清单打钩已备项。但勾选完后的“然后”呢?站在超市里手忙脚乱地来回翻找缺了啥,这体验可算不上优雅。今天,我们要用分组算法将缺货食材一键生成购物清单,并按品类(蔬菜、肉类、调料)分块展示。你将学会如何用 Object.keys + 分类映射实现智能分组,用 ArkUI 的 List + ListItemGroup 打造折叠式清单卡片,让采购像逛超市一样按区索骥。


一、引言与系列定位

经过第九篇的打磨,我们的详情页拥有了食材勾选能力——点一点 Checkbox,“已备/缺货”数字实时跳动。但这是一个未完的闭环:当用户勾选完所有已备食材,那些还打着“待购”标签的食材,该如何一目了然地呈现在购物场景中?

这一篇,就是来接棒第九篇埋下的 getMissingCount() 伏笔。我们要新增一个 ShoppingListPage,它能根据用户在详情页的勾选结果,一键生成分组购物清单——蔬菜类、肉类、调料类各自成块,每块内列出缺货食材名称。你拿着手机在超市里,只需按分区扫一眼,采购效率翻倍。

整套改造只新增 pages/ShoppingListPage.etsviewmodel/ShoppingListViewModel.etsRecipeDetailPage 只加一个“生成清单”按钮,原有逻辑一行不动。


二、核心原理与底层机制深度解读

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 全局状态 跨页面污染 ⚠️ 需手动清理 ❌ 过度

核心原则:跨路由边界,永远只传纯净数据(stringnumberbooleanArrayRecord),在消费端重建响应式对象。这是第9篇用无数次编译报错换来的经验。


四、架构设计 / 核心逻辑图解

4.1 购物清单生成流水线

🖥️ 购物清单页

📤 输出

⚙️ 分组引擎

router.pushUrl params

📥 输入:详情页

IngredientViewModel
getMissingItems() → string[]

食材→品类映射表
Map<string, string>

分组逻辑: groupIngredients()

Record<string, string[]>
例: { 肉类: ['鸡胸肉'], 蔬菜: ['番茄'] }

List + ListItemGroup
每组分块展示

分组标题 + 数量角标

食材项 × N

图一解读:购物清单的生成过程是一个纯粹的函数式管道——输入缺货食材名称数组,经分类映射表转换,输出分组对象。整个过程中,Input 侧的 IngredientViewModelOutput 侧的购物清单 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 + @BuilderListItemGroup 是 ArkUI 原生分组列表组件,配合 { header: this.categoryHeader(category) } 参数,每个品类自动生成一个带标题的分组块。@Builder 方法封装了标题栏的 UI 构建逻辑,代码可读性高。
  • 品类色块:肉类用红色、蔬菜用绿色、调料用橙色——用户只需扫一眼色块,就能快速定位品类,不需要逐行读文字。这是从超市货架分区牌中借鉴的视觉设计。
  • 纯净数据渲染this.groupedItemsRecord<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 操作步骤

  1. 从首页点击“虾仁蒸蛋”进入详情页。

  2. 在食材勾选清单中,勾选“鸡蛋”、“温水”、“盐”、“香油”等已有食材。
    在这里插入图片描述

  3. 确认“葱花”保持未勾选状态。

  4. 点击底部绿色“生成购物清单”按钮。
    在这里插入图片描述

  5. 观察页面过渡到购物清单页,按品类分块展示缺货食材。

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 技术安全白皮书》电子版!
纯血鸿蒙,用心造厨。我们下一篇见!

Logo

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

更多推荐