HarmonyOS 6.1 开发者盛宴|《灵犀厨房》实战(九):状态管理与跨组件通信——实现食材勾选

摘要:上一篇我们为菜谱详情页装上了“涡轮推进器”,让用户能像翻菜谱一样滑动浏览烹饪步骤。但一个真正的厨房助手,光会看还不够——你得能“动手”。试想,站在菜市场对着手机勾选“已备食材”,所有步骤页同步亮起绿灯;或是勾选完一项,底部购物清单自动记录缺货。这篇,我们将用 ArkTS 状态管理 为食材清单注入“同步灵魂”。你将学会用 @ObservedV2 + @Trace 构建可观察的数据基因,用 @Provider() + @Consumer() 打通页面间的“任督二脉”,让食材勾选状态像连锁反应一样瞬间同步。


一、引言与系列定位

在第八篇,我们打通了首页到详情页的路由,用户终于能从推荐卡片进入沉浸式分步浏览。但有一个尴尬的场景:用户在步骤页看到“需要鸡胸肉”,却忘记了这是冰箱里有的还是上周过期的。

这一篇,就是来解决“食材掌控感”的。我们要为菜谱详情页的每个步骤附上食材清单,并让用户能像使用待办清单一样勾选已备食材。更重要的是,这个勾选状态不是孤立在单个页面的——它要为下一篇的购物清单埋下伏笔,打通跨页面的数据共享。

整套改造只新增 viewmodel/IngredientViewModel.ets 和少量修改 RecipeDetailPage.ets,原有的 Recipe 模型平滑扩展,首页和推荐引擎一行不动。


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

2.1 状态管理:给数据装上“神经末梢”

在传统开发中,你点了一个 Checkbox,需要手动写代码去更新UI、存储状态、通知其他页面。这好比每次眨眼都要用大脑刻意控制眼皮——累且容易出错。

HarmonyOS 的 ArkUI 提供了一套“响应式状态管理”机制。你可以把 @ObservedV2 想象成给数据对象植入“神经末梢”——一旦对象的某个属性发生变化(被 @Trace 标记),所有绑定该对象的UI组件会自动且精准地重绘,无需一行手动刷新代码。

用户勾选 Checkbox

触发 onClick 事件

修改 @Trace 属性值

ArkUI 状态管理引擎

查找该属性的所有绑定组件

当前页 Checkbox 更新

其他页同源数据同步刷新

金句:状态管理不是什么高深的魔法,而是让数据能“主动喊人”——“喂,我变了,用我的组件快更新!”

2.2 跨组件通信:从“打电话”到“广播站”

通信方式 比喻 适用场景 本篇选用
@Prop 父子传参 快递员一对一送货 父组件传值给直接子组件 ❌ 层级太深
@Provider() + @Consumer() 广场广播站 跨层级、跨页面的状态共享 主方案
AppStorage 云端记事本 全局持久化数据 ⚠️ 待第28篇持久化用
Emitter 事件总线 对讲机群呼 事件通知型通信 ❌ 不适合持续状态同步

本篇选用 @Provider() + @Consumer() 的组合拳,因为食材勾选状态需要跨越详情页内部多个层级(顶部步骤区、底部步骤列表、食材清单弹窗),并为下一篇购物清单页的跨页面共享做铺垫。

@Provider() 好比在广场中央架起一座广播塔,任何被 @Consume 标记的组件都可以“收听”同一个数据源。数据一变,所有听众同时更新。


三、关键知识点详解

3.1 ArkTS 状态管理装饰器对比(API 23 最新版)

装饰器 装饰对象 触发更新时机 跨组件能力 本篇使用场景
@State 组件内变量 变量值变化 仅本组件 当前步骤索引
@Prop 子组件变量 父组件传入新值 父→子单向
@Link 子组件变量 双向绑定,任意端变化 父↔子双向
@ObservedV2 自定义类 类中被 @Trace 标记的属性变化 配合其他装饰器 ✅ IngredientViewModel
@Trace 类的属性 该属性值变化 依赖父级 @ObservedV2 ✅ 食材勾选状态
@Provider() 组件内变量 变量值变化(可被后代消费) 跨层级向下广播 ✅ 食材列表数据
@Consumer() 子/后代组件变量 @Provider() 源同步变化 接收广播 ✅ 食材勾选区

为什么选 @ObservedV2 而不是上一代的 @Observed

API 23 推荐的 V2 版本支持更细粒度的属性追踪——你不需要让整个对象都“可观察”,只需用 @Trace 标记那些会变化的属性(如 isChecked)。这大大减少了不必要的重绘,性能更优。


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

4.1 食材勾选的数据流全景

🖥️ UI 消费层

📡 广播层

📦 数据层

用户点击勾选

@Trace 触发更新

IngredientViewModel
@ObservedV2

ingredients: IngredientItem[]
每个 item 的 isChecked 被 @Trace 标记

@Provider ingredientData:
IngredientViewModel

步骤区
显示该步所需食材

食材勾选清单
@Consumer 接收数据

购物清单徽章
统计缺货数量

图一解读:这张图展示了“一处勾选,处处响应”的核心机制。IngredientViewModel 作为唯一的数据源(Single Source of Truth),通过 @Provider() 广播给所有消费组件。用户在任何一处点击勾选,都会直接修改 @Trace 标记的 isChecked 属性,触发 ArkUI 引擎的精准重绘——只有用到该属性的组件会更新,其余组件不受影响。

4.2 跨页面通信的时序图

购物清单页(下篇) CheckboxItem 组件 IngredientViewModel (@Provider 源) RecipeDetailPage 👤 用户 购物清单页(下篇) CheckboxItem 组件 IngredientViewModel (@Provider 源) RecipeDetailPage 👤 用户 下篇实现:跨页面同步 点击勾选“鸡胸肉” 修改 IngredientItem.isChecked = true @Trace 检测到属性变化 通知该 Checkbox 刷新样式 通知步骤区“已备”标签刷新 购物清单页自动移除已备项

图二解读:注意关键的“单向数据流”——用户操作触发数据变更,数据变更驱动UI更新,而不是UI之间互相操作。这保证了状态的可预测性和可追溯性。


五、实战:实现食材勾选与状态同步

Step 1:扩展 Recipe 模型,加入食材子模型

首先,我们要为食谱添加结构化的食材数据。打开 model/Recipe.ts,覆盖原始接口:

// model/Recipe.ts
// 定义菜谱
// 新增于第8章:烹饪步骤详情
export interface StepDetail {
  stepNumber: number;  // 步骤序号,从 1 开始
  title: string;       // 步骤标题,如 '焯水'
  description: string; // 详细操作说明
  duration?: string;   // 预计耗时,如 '5分钟'
  tip?: string;        // 小贴士
}

/**
 * 单个食材项,支持勾选状态追踪
 */
@ObservedV2
export class IngredientItem {
  @Trace name: string;           // 食材名称,如 "鸡胸肉"
  @Trace amount: string;         // 用量,如 "200g"
  @Trace isChecked: boolean;     // 是否已备 → 这就是我们要追踪的核心状态!

  constructor(name: string, amount: string, isChecked: boolean = false) {
    this.name = name;
    this.amount = amount;
    this.isChecked = isChecked;
  }
}

/**
 * 为 Recipe 接口补充结构化食材字段(保持向后兼容)
 */
export interface Recipe {
  id: number;
  name: string;
  cover: Resource;
  // 食材(保留旧字段向前兼容)
  ingredients: string[];
  // 新增于第8章:带用量的食材明细
  ingredientItems?: IngredientItem[];  // 🆕 结构化食材列表,用于勾选
  // 步骤(保留旧字段向前兼容)
  steps: string[];
  // 新增于第8章:带详情的分步数据
  stepDetails?: StepDetail[];
  tags: string[];
  calories: number;
  seasonTags?: string[];
  // 新增于第8章:烹饪辅助信息
  cookTime?: string;   // 总耗时,如 '30分钟'
  difficulty?: string; // 难度,如 '简单'/'中等'/'困难'
  servings?: number;   // 份数/人数
}

变化点解读

  • @ObservedV2 + @Trace:这是 API 23 的“黄金搭档”。@ObservedV2 让类本身具备被观察的能力,@Trace 则精准标记哪些属性的变化需要触发UI更新。这里我们只关心 isChecked,但 nameamount 也加了 @Trace,为将来动态修改食材名/用量预留能力。
  • ingredientItems? 可选字段:加 ? 是为了向后兼容——上一篇的首页代码不需要任何修改。如果 ingredientItems 不存在,详情页可以降级使用 ingredients 字符串数组展示。

Step 2:创建 IngredientViewModel,封装勾选逻辑与数据

新建目录viewmodel,再新建 viewmodel/IngredientViewModel.ets

// viewmodel/IngredientViewModel.ets
import { IngredientItem } from '../model/Recipe';

@ObservedV2
export class IngredientViewModel {
  @Trace items: IngredientItem[] = [];

  /**
   * 从纯净的字符串数组初始化食材清单(替代 initFromRecipe)
   */
  initFromIngredients(ingredients: string[]): void {
    this.items = ingredients.map(name =>
    new IngredientItem(name, '适量', false)
    );
    console.info(`[IngredientVM] 初始化食材清单(从字符串数组),共 ${this.items.length}`);
  }

  toggleCheck(index: number): void {
    if (index >= 0 && index < this.items.length) {
      this.items[index].isChecked = !this.items[index].isChecked;
      console.info(`[IngredientVM] 食材 "${this.items[index].name}" 勾选状态 → ${this.items[index].isChecked}`);
    }
  }

  getCheckedCount(): number {
    return this.items.filter(item => item.isChecked).length;
  }

  getMissingCount(): number {
    return this.items.length - this.getCheckedCount();
  }
}

核心点解读

  • initFromRecipe 的双轨兼容:这是“不破坏旧代码”的关键。如果新 Mock 数据提供了 ingredientItems 就用新的,没有就用旧的 ingredients 字符串数组兜底。这种渐进式升级策略在全系列中反复使用。
  • toggleCheck 的精髓:直接修改 this.items[index].isChecked,因为 items 数组和 IngredientItem.isChecked 都被 @Trace 标记,ArkUI 会自动且仅重绘绑定了这些属性的组件——不需要 notifyStateChange(),不需要手动刷新。
  • getCheckedCount/getMissingCount:这两个方法虽然不直接参与UI渲染,但为底部的“已备 X/总计 Y”指示器和下篇购物清单的徽章数字提供了数据源。

Step 3:改造 RecipeDetailPage,注入食材勾选 UI

回到 pages/RecipeDetailPage.ets,我们要做三处精准改造。

改造点 A:将 RecipeDetailPage 升级为 @ComponentV2,使用 @Provider()

// pages/RecipeDetailPage.ets
import { IngredientViewModel } from '../viewmodel/IngredientViewModel';
import { IngredientItem } from '../model/Recipe';

// ✅ 升级为 @ComponentV2,以支持 @Provider/@Consumer 装饰器
@ComponentV2
struct RecipeDetailPage {
  @Local recipe: Recipe = {
    id: 0,
    name: '',
    cover: $r('app.media.startIcon'),
    ingredients: [],
    steps: [],
    tags: [],
    calories: 0
  };
  @Local currentStepIndex: number = 0;
  private swiperController: SwiperController = new SwiperController();

  // ✅ @Provider() 可在 @ComponentV2 中正常使用
  @Provider() ingredientVM: IngredientViewModel = new IngredientViewModel();

  aboutToAppear(): void {
    const params = this.getUIContext().getRouter().getParams() as Record<string, Object>;
    console.info(`首页Index原始参数:${JSON.stringify(params)}`)
    if (params) {
      const recipeId = params['recipeId'] as number;
      const recipeName = params['recipeName'] as string;
      const recipeIngredients = params['recipeIngredients'] as string[];

      // 从 MockData 获取完整菜谱信息(用于显示步骤、标签等)
      const fullRecipe = allRecipes.find(r => r.id === recipeId);
      if (fullRecipe) {
        this.recipe = fullRecipe;
        this.recipe.name = recipeName;
        this.recipe.ingredients = recipeIngredients;
      }

      // 用纯净的字符串数组初始化食材清单
      this.ingredientVM.initFromIngredients(recipeIngredients);
    }
    console.info(`[RecipeDetail] 菜谱详情加载: ${this.recipe.name}, 共${this.recipe.steps.length}`);
  }

  build() {
    Column() {
      // 自定义顶部导航栏(代码不变,略)
      // ...

      // Swiper 沉浸区(代码不变,略)
      // ...

      // 底部区域(代码不变,略)
      // ...
    }
    .width('100%')
    .height('100%')
  }
}

变化点解读

  • @Provider ingredientVM:这一行是整个改造的“信号塔”。它让 ingredientVM 成为Broadcast Source,页面内任何用 @Consume ingredientVM 标记的组件都会自动获得同一个实例的引用。
  • aboutToAppear 中初始化:确保数据在页面渲染前就绪,避免首帧空白闪烁。

改造点 B:在 Swiper 每个步骤页中,新增“本步所需食材”标签

找到 Swiper 内部的 ForEach 中的 Column 区域,在步骤文本下方插入食材标签:

// 在 Swiper 的 ForEach 内部,步骤文本 Text(step) 之后,新增以下代码:

// 🆕 本步所需食材标签(仅在第0步展示食材概览)
if (index === 0) {
  Column() {
    Text('📋 食材准备清单')
      .fontSize(13)
      .fontColor('#FF6B35')
      .fontWeight(FontWeight.Medium)
      .margin({ bottom: 8 })

    // 食材勾选清单 —— 独立子组件
    IngredientCheckList()
  }
  .width('90%')
  .padding(12)
  .backgroundColor(Color.White)
  .borderRadius(12)
  .shadow({ radius: 8, color: '#10000000' })
  .margin({ top: 20 })
}

改造点 C:创建 IngredientCheckList 子组件(放在同一个文件末尾),IngredientCheckList 子组件也用 @ComponentV2

/**
 * 食材勾选清单组件
 * 通过 @Consumer 直接“收听”父组件广播的 ingredientVM
 */
@ComponentV2
struct IngredientCheckList {
  @Consumer() ingredientVM: IngredientViewModel;

  build() {
    // ✅ 用 Column 作为唯一根节点,包裹 List 和底部统计条
    Column() {
      List({ space: 6 }) {
        ForEach(this.ingredientVM.items, (item: IngredientItem, index: number) => {
          ListItem() {
            Row({ space: 10 }) {
              Checkbox({ name: item.name, group: 'ingredient_check' })
                .select(item.isChecked)
                .selectedColor('#FF6B35')
                .onChange((value: boolean) => {
                  this.ingredientVM.toggleCheck(index);
                })

              Column({ space: 2 }) {
                Text(item.name)
                  .fontSize(14)
                  .fontColor(item.isChecked ? '#999' : '#333')
                  .decoration({ type: item.isChecked ? TextDecorationType.LineThrough : TextDecorationType.None })
                Text(item.amount)
                  .fontSize(11)
                  .fontColor('#999')
              }
              .alignItems(HorizontalAlign.Start)
            }
            .width('100%')
            .padding({ top: 6, bottom: 6 })
          }
        }, (item: IngredientItem, index: number) => index.toString())
      }
      .width('100%')
      .height(Math.min(this.ingredientVM.items.length * 44, 180))

      // 底部统计条
      Row() {
        Text(`✅ 已备 ${this.ingredientVM.getCheckedCount()}/${this.ingredientVM.items.length}`)
          .fontSize(11)
          .fontColor('#4CAF50')
        Blank()
        Text(`🛒 缺货 ${this.ingredientVM.getMissingCount()}`)
          .fontSize(11)
          .fontColor('#FF6B35')
      }
      .width('100%')
      .padding({ top: 8 })
    }
    .width('100%')
  }
}

核心点解读

  • @ComponentV2 全面升级@Provider()@Consumer() 只能在 @ComponentV2 装饰的结构体中使用。这一升级不仅解决了 ArkTSCheck 报错,还带来了 V2 体系的其他红利——比如更精细的渲染控制。
  • @Local 替代 @State:在 @ComponentV2 中,组件内部状态不再使用 @State,而是用 @Local 装饰器。功能完全相同(变量变化触发重绘),但语义上更准确——它确实是一个“本地”状态。
  • build 根节点唯一化:原代码 List 和底部 Row 并列在 build 方法中,导致 ArkTSCheck 报“只能有一个根节点”。修正后用 Column 包裹两者,既符合语法要求,又保持了原有的纵向排列布局。
  • @Consumer() 无需改变用法:将 struct 升级为 @ComponentV2 后,@Consumer() 的工作方式完全不变——它依然会接收离它最近的祖先节点中 @Provider() 广播的同一类型对象。
  • 勾选后的UI连锁反应
    1. Checkbox.onChange → 调用 ingredientVM.toggleCheck(index)
    2. toggleCheck 修改 @Trace isChecked
    3. item.isCheckedText.fontColor.decoration 绑定 → 文本变灰 + 删除线
    4. getCheckedCount() 返回新值 → 底部统计条自动更新
      全程零手动刷新代码
  • 删除线细节TextDecorationType.LineThrough 给已勾选食材加上删除线,视觉上清晰区分“已备”和“待备”,这是从主流待办清单应用学来的交互范式。

改造点 D:修改Index.ets中的路由跳转函数,直接先传递字符串及其字符串类型的数组(由于ArkTS传递对象会被序列化,在详情页面中取不到具体值,这里先保证效果,后续真实的数据也是通过ID调用后端接口得到):

  // 修改于第8章:卡片点击 → 路由跳转到菜谱详情页
  private handleRecipeTap(recipe: Recipe): void {
    ToastUtil.showToast(this.getUIContext(),`[Index] 点击了菜谱:${recipe.name},跳转到详情页`);
    // 通过路由参数传递菜谱ID,详情页根据ID加载数据
    this.getUIContext()
      .getRouter()
      .pushUrl({
      url: 'pages/RecipeDetailPage',
      params: {
        recipeId: recipe.id,
        recipeName: recipe.name,
        recipeIngredients: recipe.ingredients  // 只传纯字符串数组
      }
    }).catch((err: Error) => {
      console.error(`[Index] 路由跳转失败: ${JSON.stringify(err)}`);
      ToastUtil.showToast(this.getUIContext(), '页面跳转失败');
    });
  }

Step 4:更新 Mock 数据,为新功能提供测试用例

找到数据文件(通常是 model/RecipeData.ets 或类似位置),为其中一到两个菜谱补充 ingredientItems 字段:

// model/RecipeData.ets —— 示例:为“番茄炒蛋”补充结构化食材
{
  id: 1,
  name: '番茄炒蛋',
  cover: $r('app.media.recipe_tomato_egg'),
  ingredients: ['番茄', '鸡蛋', '葱', '盐', '糖'],  // 旧字段保留
  ingredientItems: [                                 // 🆕 新字段
    new IngredientItem('番茄', '2个', false),
    new IngredientItem('鸡蛋', '3个', true),        // 假设用户已有鸡蛋
    new IngredientItem('葱', '1根', false),
    new IngredientItem('盐', '适量', false),
    new IngredientItem('糖', '少许', false),
  ],
  steps: [ /* ...原有步骤... */ ],
  tags: ['家常', '快手'],
  calories: 280,
  // ...
}

变化点解读:用 new IngredientItem(...) 构造数据,其中第三个参数 isChecked 可以预设为 true 来模拟“用户已备”的场景,方便测试勾选框的初始状态和统计计数。


六、运行与结果验证

6.1 操作步骤

  1. 部署 App,从首页点击进入“虾仁炒蛋”详情页。

  2. 滑动到第 1 步(或如果第 1 步自动展示了食材清单),观察食材勾选清单。
    在这里插入图片描述

  3. 点击旁边的 Checkbox,观察:

    • Checkbox 变为选中状态(橙色实心)
    • 底部统计条从“✅ 已备 1/6”变为“✅ 已备 2/6”
    • 缺货数从“🛒 缺货 4 项”变为“🛒 缺货 3 项”
      在这里插入图片描述
  4. 滑动到其他步骤,再滑回来,确认勾选状态保持。

  5. 点击返回首页,再次进入详情页,确认状态重置(因为当前数据是 Mock 的,第 28 篇会做持久化)。

6.2 预期日志输出

[IngredientVM] 初始化食材清单,共 5 项
[RecipeDetail] 菜谱详情加载: 番茄炒蛋, 共4步
[IngredientVM] 食材 "番茄" 勾选状态 → true
[IngredientVM] 食材 "葱" 勾选状态 → true
[IngredientVM] 食材 "鸡蛋" 勾选状态 → false

6.3 日志解读

  • 初始化日志aboutToAppearinitFromRecipe,打印清单项数,验证数据成功传入。
  • 勾选日志:每次 toggleCheck 都打印食材名+新状态,方便追踪用户操作轨迹。
  • 状态变化序列:如果连续勾选“番茄”和“葱”,然后取消“鸡蛋”,日志会清晰呈现 +true/+true/+false 的顺序。

💡 调试技巧:如果在真机上测试,可以通过 hdc shell hilog | grep IngredientVM 实时过滤日志,观察状态变化。


七、本阶段总结与下篇预告

今天,我们为《灵犀厨房》注入了食材勾选的“数据灵魂”:

  • 响应式基因:用 @ObservedV2 + @Trace 把普通的 TypeScript 类变成了“活的”数据模型,属性一变,UI自动响应。
  • 跨组件广播:用 @Provider() + @Consumer() 架设了数据广播塔,打破了组件层级壁垒,让食材清单、步骤区、底部统计条共享同一份状态。
  • 渐进式扩展:通过 ingredientItems? 可选字段和后兼容的 initFromRecipe,确保旧代码零破坏,新功能平滑插入。
  • 下篇伏笔getMissingCount() 方法已经在静静等待,当下篇创建购物清单页时,只需一行 @Consume ingredientVM 就能直接获取缺货数据。

但截至目前,食材数据还是我们写死的 Mock,购物清单也还只是一个数字。真正的厨房助手,应该能自动生成购物清单,并按品类(蔬菜、肉类、调料)智能分组。

下篇预告:第10篇《【购物清单】一键生成并分组展示》。我们将基于本篇的 ingredientVM.getMissingCount(),创建一个全新的购物清单页。你将学会用 GroupBy 算法将缺货食材按分类聚合,并用 List + ListItemGroup 实现折叠分组展示——让用户带着手机去超市时,一眼就能找到“蔬菜区该买啥”、“调料区该补啥”。


📚 本系列持续更新中:下一篇将带你生成智能分组购物清单,告别手写便签的买菜时代。

🔗 专栏入口:[《从0到1开发灵犀厨房App》合集] | ⭐ 源码Gitee 仓库(第9章代码已同步更新)

💎 开发者福利点赞+收藏本专栏,评论区留言“食材勾选,状态为王”,私信我即可领取《HarmonyOS 6.1 状态管理最佳实践白皮书》电子版!
纯血鸿蒙,用心造厨。我们下一篇见!

Logo

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

更多推荐