HarmonyOS菜谱——收藏与采购清单的跨页面状态管理
适用读者: 正在学习 HarmonyOS ArkTS 开发、对跨页面数据同步有困惑的开发者。本文以菜谱 App 的收藏和采购清单功能为例,拆解"模块级共享状态"的完整实现方案。
完整效果


一、为什么需要跨页面状态管理
菜谱 App 有三个入口页面(底部导航栏的菜谱/收藏/清单),它们之间存在数据依赖:
- 用户在详情页点击"❤️ 收藏"→ 收藏页必须显示这道菜
- 用户在详情页点击"加入采购清单"→ 采购清单页必须出现这个食材
- 用户在采购清单页点击"✓"删除→ 列表必须立即更新
如果每个页面各自管理自己的数据,这三个同步场景都无法实现。解决方案是在数据层维护一份共享状态,所有页面读写同一份数据。
二、数据层设计:模块级变量 + 操作函数
// RecipeData.ets 新增部分
// 收藏数据:存储菜谱 id 数组
let _favs: number[] = []
export function toggleFav(id: number): boolean {
for (let i: number = 0; i < _favs.length; i++) {
if (_favs[i] === id) {
const n: number[] = []
for (let j: number = 0; j < _favs.length; j++) {
if (j !== i) n.push(_favs[j])
}
_favs = n
return false
}
}
_favs.push(id)
return true
}
export function getFavs(): Recipe[] {
const r: Recipe[] = []
for (let i: number = 0; i < RECIPES.length; i++) {
for (let j: number = 0; j < _favs.length; j++) {
if (RECIPES[i].id === _favs[j]) { r.push(RECIPES[i]); break }
}
}
return r
}
export function isFav(id: number): boolean {
for (let i: number = 0; i < _favs.length; i++) {
if (_favs[i] === id) return true
}
return false
}
// 采购清单数据:存储字符串数组
let _shop: string[] = []
export function addToShop(item: string): void { _shop.push(item) }
export function getShop(): string[] { return _shop }
export function clearShop(): void { _shop = [] }
export function removeFromShop(idx: number): void {
const n: string[] = []
for (let i: number = 0; i < _shop.length; i++) {
if (i !== idx) n.push(_shop[i])
}
_shop = n
}

设计决策分析
为什么用 let 而不是 export const?
收藏和采购清单的数据是动态变化的——用户随时会添加或删除。export const 保护的是引用不被替换,但 let 让操作函数可以重新赋值(_favs = n)。这里用 let 是因为操作函数需要"替换整个数组"来触发 @State 刷新(后面会详细解释)。
为什么收藏存 id 不存完整 Recipe?
_favs 存的是 number[](菜谱 id 列表),不是 Recipe[]。原因是:如果存完整 Recipe 对象,当菜谱数据更新时(比如修改了菜名),收藏里的数据不会同步。存 id 则每次调用 getFavs() 都会从最新的 RECIPES 里查询,保证数据一致性。
为什么采购清单存字符串不存对象?
采购清单的每项就是一个字符串(“五花肉 500g”),不需要额外的结构。如果以后需要"已购买"状态、数量等字段,再改成对象也不迟。当前的简单设计降低了理解成本。
两个数据层的对比
| 特性 | 收藏 (_favs) | 采购清单 (_shop) |
|---|---|---|
| 数据类型 | number[] | string[] |
| 存储内容 | 菜谱 id | 食材名称+用量 |
| 查询方式 | 遍历 RECIPES 匹配 id | 直接返回数组 |
| 删除方式 | 按 id 过滤重建数组 | 按索引过滤重建数组 |
| 清空方式 | 无(逐个取消收藏) | clearShop() 一键清空 |
三、FavoritesPage:收藏列表的双重刷新
@State recipes: Recipe[] = []
onPageShow(): void { this.refresh() }
aboutToAppear(): void { this.refresh() }
private refresh(): void {
const latest: Recipe[] = getFavs()
const copy: Recipe[] = []
for (let i: number = 0; i < latest.length; i++) { copy.push(latest[i]) }
this.recipes = copy
}

为什么需要 onPageShow 和 aboutToAppear 双重调用
aboutToAppear 在页面首次创建时触发——用户第一次进入收藏页时调用。onPageShow 在页面每次显示时触发——用户从详情页返回收藏页时也会调用。
如果只写 aboutToAppear,用户在详情页取消收藏后返回收藏页,列表不会更新——因为页面已经创建过了,aboutToAppear 不会再执行。加上 onPageShow 保证每次显示都重新查询最新数据。
为什么 refresh 里要复制数组
// 不推荐:直接赋值引用
this.recipes = getFavs()
// 推荐:复制后赋值
const latest: Recipe[] = getFavs()
const copy: Recipe[] = []
for (let i: number = 0; i < latest.length; i++) { copy.push(latest[i]) }
this.recipes = copy
getFavs() 返回的是一个新数组(函数内部 r.push(RECIPES[i]) 创建的),理论上直接赋值给 @State 也能触发刷新。但复制一遍是防御性编程——如果以后 getFavs() 的实现改为返回缓存引用,复制操作能保证 @State 检测到引用变化。在 8 道菜谱的规模下,复制的性能开销可以忽略。
空状态的条件渲染
if (this.recipes.length === 0) {
Column() {
Text('❤️').fontSize(64).opacity(0.2).margin({ bottom: 16 })
Text('还没有收藏').fontSize(16).fontColor(T2)
Text('浏览菜谱时点击 ❤️ 即可收藏').fontSize(13).fontColor(T3).margin({ top: 4 })
}.width('100%').layoutWeight(1).justifyContent(FlexAlign.Center)
} else {
Scroll() {
Column({ space: 10 }) {
ForEach(this.recipes, (r: Recipe) => { /* 菜谱卡片 */ })
}
}
}

if-else 控制两种状态的切换——数组为空时显示空状态提示,不为空时显示菜谱列表。空状态用了 opacity(0.2) 让心形图标变淡,避免抢走文字的视觉焦点。layoutWeight(1) 让空状态区域占据所有剩余空间,内容居中显示。
踩坑记录: 最初空状态没加 layoutWeight(1),心形图标和文字贴在导航栏下方,视觉上很拥挤。加了 layoutWeight(1) 后,Column 会占据 Scroll 的所有剩余高度,配合 justifyContent(FlexAlign.Center) 实现垂直居中。
四、ShoppingListPage:输入、添加、删除的完整交互
数据输入:TextInput + onSubmit
@State input: string = ''
Row() {
TextInput({ placeholder: '添加采购项...', text: this.input })
.layoutWeight(1).height(40).fontSize(14)
.backgroundColor('#F5F5F7').borderRadius(20).padding({ left: 14 })
.onChange((v: string) => { this.input = v })
.onSubmit(() => { this.add() })
Text('添加').fontSize(13).fontWeight(FontWeight.Bold).fontColor(A)
.margin({ left: 8 }).onClick(() => { this.add() })
}
.onChange vs .onSubmit 的区别: onChange 在每次输入变化时触发——用户打一个字就更新 this.input。onSubmit 在用户点击键盘的"确认/回车"键时触发。两个事件配合使用:onChange 负责实时同步输入内容,onSubmit 负责提交操作。
为什么不直接用 TextInput 的 onSubmit 而要加"添加"按钮? 因为手机键盘上不是所有输入法都有明显的"确认"键。加一个"添加"按钮让交互更明确——用户既能按回车提交,也能点击按钮提交。
添加操作的防御性检查
private add(): void {
if (this.input.trim().length === 0) return
addToShop(this.input.trim())
this.input = ''
this.refresh()
}
.trim().length === 0 检查——防止添加空项。 用户可能误触"添加"按钮,或者输入空格后点击添加。trim() 去除首尾空格后再检查长度,保证不会添加空白项。
this.input = '' 清空输入框。 添加成功后必须清空,否则用户会看到输入框里还残留着刚才的文字,误以为没有添加成功。
删除操作:按索引过滤
Text('✓').fontSize(16).fontWeight(FontWeight.Bold).fontColor(A)
.onClick(() => { removeFromShop(idx); this.refresh() })
export function removeFromShop(idx: number): void {
const n: string[] = []
for (let i: number = 0; i < _shop.length; i++) {
if (i !== idx) n.push(_shop[i])
}
_shop = n
}
删除操作用"索引"定位——ForEach 的第二个参数 idx 就是当前项的索引。removeFromShop 创建一个新数组,跳过指定索引的项,然后替换 _shop。
为什么不用 splice? _shop.splice(idx, 1) 能原地删除元素,但不会触发 @State 刷新——因为数组引用没变。创建新数组并替换引用是 ArkTS 的标准刷新模式。
踩坑记录: 最初 removeFromShop 用的是 _shop.splice(idx, 1),删除后列表不更新。查了半小时才发现是 @State 引用没变化的问题。改成"创建新数组 + 替换引用"后正常。这个坑在前面的智能家居项目里也遇到过——RoomPage 的设备列表也是同样的模式。
清空操作
Text('清空').fontSize(13).fontColor(A)
.onClick(() => { clearShop(); this.refresh() })
export function clearShop(): void { _shop = [] }
清空操作最简单——把 _shop 重置为空数组。注意 clearShop 没有调用 this.refresh(),因为清空后 getShop() 返回空数组,refresh() 会把 this.items 也设为空数组,UI 自动切换到空状态。
五、RecipeDetail 的三个交互功能
功能一:收藏切换
@State fav: boolean = false
// 初始化时查询收藏状态
aboutToAppear(): void {
const p = router.getParams() as Record<string, Object>
if (p && p['id']) {
const rid: number = p['id'] as number
this.recipe = getRecipeById(rid)
this.fav = isFav(rid) // 查询是否已收藏
}
}
// 导航栏右侧的心形按钮
Text(this.fav ? '❤️' : '🤍').fontSize(20)
.onClick(() => { if (this.recipe) { this.fav = toggleFav(this.recipe.id) } })
toggleFav 的返回值设计——返回 boolean 让调用方直接赋值。 toggleFav(id) 内部切换收藏状态后返回新的状态值(true=已收藏,false=未收藏)。调用方直接 this.fav = toggleFav(...) 就能同步 UI,不需要额外调用 isFav 查询。
踩坑记录: 最初 toggleFav 返回 void,调用方需要先调 toggleFav 再调 isFav 查询状态,两次遍历数组。改成返回 boolean 后减少了一次遍历,代码也更简洁。
功能二:加入采购清单
Text('🛒 加入清单').fontSize(12).fontColor('#FF6B6B').fontWeight(FontWeight.Medium)
.padding({ left: 10, right: 10, top: 5, bottom: 5 })
.backgroundColor('#FFF0F0').borderRadius(10)
.onClick(() => {
if (this.recipe) {
for (let i: number = 0; i < this.recipe.ingredients.length; i++) {
addToShop(this.recipe.ingredients[i].name + ' ' + this.recipe.ingredients[i].amount)
}
}
})
批量添加——遍历食材数组逐个调用 addToShop。 一道菜的所有食材一次性加入清单,不需要用户逐个点击。拼接格式是 "名称 用量"(如"五花肉 500g"),作为一个字符串存入 _shop。
没有检查重复——当前的简化设计。 用户多次点击"加入清单"会重复添加相同食材。真实 App 需要在 addToShop 里检查是否已存在,或者用 Set 去重。但在原型阶段,简化处理降低了代码复杂度。
功能三:份量调整和计时器
@State servings: number = 1
@State timerOn: boolean = false
@State timerMin: number = 0
@State timerSec: number = 0
// 份量调整
Text('−').onClick(() => { if (this.servings > 1) this.servings-- })
Text(this.servings.toString() + ' 人份').fontSize(18).fontWeight(FontWeight.Bold).fontColor(A)
Text('+').onClick(() => { if (this.servings < 8) this.servings++ })
// 计时器
Text('3分钟').onClick(() => { this.timerMin = 3; this.timerSec = 0; this.timerOn = true })
Text('5分钟').onClick(() => { this.timerMin = 5; this.timerSec = 0; this.timerOn = true })
Text('10分钟').onClick(() => { this.timerMin = 10; this.timerSec = 0; this.timerOn = true })
Text(this.timerOn ? '⏸ 停止' : '▶ 开始')
.onClick(() => { this.timerOn = !this.timerOn })
份量调整和计时器都是页面内部的 @State——不涉及跨页面同步。份量用 if (this.servings > 1) 和 if (this.servings < 8) 做边界检查,防止减到 0 或加到过大。计时器的倒计时逻辑(setInterval)当前没有实现——点击"开始"只设置 timerOn = true,没有实际倒计时。这是后续可以完善的功能点。
六、Index 底部导航栏的路由设计
@Builder NavBtn(label: string, active: boolean) {
Column({ space: 2 }) {
Text(label === '菜谱' ? '📖' : (label === '收藏' ? '❤️' : '🛒')).fontSize(18)
Text(label).fontSize(9).fontColor(active ? '#FF6B6B' : '#C0BFC6')
}.onClick(() => {
if (label === '收藏') router.pushUrl({ url: 'pages/FavoritesPage' })
else if (label === '清单') router.pushUrl({ url: 'pages/ShoppingListPage' })
})
}
三元表达式嵌套选择 emoji 图标。 label === '菜谱' ? '📖' : (label === '收藏' ? '❤️' : '🛒') 根据标签名选择对应的图标。这种写法比 if-else 链更紧凑,但超过三个分支时可读性会下降。
"菜谱"tab 不跳转——点击首页无反应。 因为 Index 本身就是菜谱页面,不需要跳转。只有"收藏"和"清单"才执行 router.pushUrl。这是底部导航栏的标准设计——当前 tab 点击无反应,其他 tab 点击跳转。
踩坑记录: 最初"菜谱"tab 也写了 router.pushUrl({ url: 'pages/Index' }),点击后页面闪烁——因为 Index 是 @Entry 页面,重新 push 会重建页面,搜索框内容丢失。去掉跳转后问题解决。
七、跨页面数据流的完整链路
以"用户收藏一道菜并查看收藏列表"为例:
1. 用户在 RecipeDetail 点击 ❤️
↓
2. this.fav = toggleFav(recipe.id)
↓
3. toggleFav 内部:_favs.push(id) 或 _favs = 过滤后的新数组
↓
4. 用户点击底部导航"收藏"
↓
5. router.pushUrl → FavoritesPage 创建
↓
6. aboutToAppear → refresh() → getFavs()
↓
7. getFavs 遍历 RECIPES,匹配 _favs 里的 id,返回 Recipe[]
↓
8. refresh 复制数组 → this.recipes = copy
↓
9. @State 触发重新渲染 → ForEach 显示收藏的菜谱
关键在第 3 步和第 7 步:第 3 步修改的是模块级变量 _favs,第 7 步读取的也是同一个 _favs。因为是同一个变量,所以数据一致。如果 _favs 是页面级变量,第 7 步就读不到第 3 步写入的数据。
八、适用边界与后续优化
当前方案的局限
| 问题 | 原因 | 优化方向 |
|---|---|---|
| 数据不持久化 | _favs 和 _shop 是内存变量,App 重启后清空 |
引入 Preferences 本地存储 |
| 采购清单可重复添加 | addToShop 不检查重复 |
加入 Set 去重或重复提示 |
| 计时器无实际倒计时 | 只设置了 timerOn 状态,没有 setInterval |
实现定时器逻辑 |
| 收藏无批量操作 | 只能逐个取消收藏 | 加入"编辑模式"批量删除 |
更多推荐


所有评论(0)