【HarmonyOS 6.1 全场景】《灵犀厨房》实战(九):状态管理与跨组件通信——实现食材勾选
摘要:本文深入解析如何在HarmonyOS 6.1中实现食材勾选功能的状态管理与跨组件通信。通过@ObservedV2和@Trace构建响应式数据模型,利用@Provide+@Consume实现跨层级状态共享,使勾选状态能在步骤区、食材清单和购物清单间实时同步。文章包含核心原理图解、装饰器对比表、数据流架构设计,并给出具体实现方案,包括扩展Recipe模型、创建ViewModel和实现UI绑定,为
HarmonyOS 6.1 开发者盛宴|《灵犀厨房》实战(九):状态管理与跨组件通信——实现食材勾选
摘要:上一篇我们为菜谱详情页装上了“涡轮推进器”,让用户能像翻菜谱一样滑动浏览烹饪步骤。但一个真正的厨房助手,光会看还不够——你得能“动手”。试想,站在菜市场对着手机勾选“已备食材”,所有步骤页同步亮起绿灯;或是勾选完一项,底部购物清单自动记录缺货。这篇,我们将用 ArkTS 状态管理 为食材清单注入“同步灵魂”。你将学会用
@ObservedV2+@Trace构建可观察的数据基因,用@Provider()+@Consumer()打通页面间的“任督二脉”,让食材勾选状态像连锁反应一样瞬间同步。
一、引言与系列定位
在第八篇,我们打通了首页到详情页的路由,用户终于能从推荐卡片进入沉浸式分步浏览。但有一个尴尬的场景:用户在步骤页看到“需要鸡胸肉”,却忘记了这是冰箱里有的还是上周过期的。
这一篇,就是来解决“食材掌控感”的。我们要为菜谱详情页的每个步骤附上食材清单,并让用户能像使用待办清单一样勾选已备食材。更重要的是,这个勾选状态不是孤立在单个页面的——它要为下一篇的购物清单埋下伏笔,打通跨页面的数据共享。
整套改造只新增 viewmodel/IngredientViewModel.ets 和少量修改 RecipeDetailPage.ets,原有的 Recipe 模型平滑扩展,首页和推荐引擎一行不动。
二、核心原理与底层机制深度解读
2.1 状态管理:给数据装上“神经末梢”
在传统开发中,你点了一个 Checkbox,需要手动写代码去更新UI、存储状态、通知其他页面。这好比每次眨眼都要用大脑刻意控制眼皮——累且容易出错。
HarmonyOS 的 ArkUI 提供了一套“响应式状态管理”机制。你可以把 @ObservedV2 想象成给数据对象植入“神经末梢”——一旦对象的某个属性发生变化(被 @Trace 标记),所有绑定该对象的UI组件会自动且精准地重绘,无需一行手动刷新代码。
金句:状态管理不是什么高深的魔法,而是让数据能“主动喊人”——“喂,我变了,用我的组件快更新!”
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 食材勾选的数据流全景
图一解读:这张图展示了“一处勾选,处处响应”的核心机制。IngredientViewModel 作为唯一的数据源(Single Source of Truth),通过 @Provider() 广播给所有消费组件。用户在任何一处点击勾选,都会直接修改 @Trace 标记的 isChecked 属性,触发 ArkUI 引擎的精准重绘——只有用到该属性的组件会更新,其余组件不受影响。
4.2 跨页面通信的时序图
图二解读:注意关键的“单向数据流”——用户操作触发数据变更,数据变更驱动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,但name和amount也加了@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连锁反应:
Checkbox.onChange→ 调用ingredientVM.toggleCheck(index)toggleCheck修改@Trace isCheckeditem.isChecked被Text.fontColor和.decoration绑定 → 文本变灰 + 删除线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 操作步骤
-
部署 App,从首页点击进入“虾仁炒蛋”详情页。
-
滑动到第 1 步(或如果第 1 步自动展示了食材清单),观察食材勾选清单。

-
点击旁边的 Checkbox,观察:
- Checkbox 变为选中状态(橙色实心)
- 底部统计条从“✅ 已备 1/6”变为“✅ 已备 2/6”
- 缺货数从“🛒 缺货 4 项”变为“🛒 缺货 3 项”

-
滑动到其他步骤,再滑回来,确认勾选状态保持。
-
点击返回首页,再次进入详情页,确认状态重置(因为当前数据是 Mock 的,第 28 篇会做持久化)。
6.2 预期日志输出
[IngredientVM] 初始化食材清单,共 5 项
[RecipeDetail] 菜谱详情加载: 番茄炒蛋, 共4步
[IngredientVM] 食材 "番茄" 勾选状态 → true
[IngredientVM] 食材 "葱" 勾选状态 → true
[IngredientVM] 食材 "鸡蛋" 勾选状态 → false
6.3 日志解读
- 初始化日志:
aboutToAppear→initFromRecipe,打印清单项数,验证数据成功传入。 - 勾选日志:每次
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 状态管理最佳实践白皮书》电子版!
纯血鸿蒙,用心造厨。我们下一篇见!
更多推荐


所有评论(0)