适用读者: 正在学习 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
}

在这里插入图片描述

为什么需要 onPageShowaboutToAppear 双重调用

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.inputonSubmit 在用户点击键盘的"确认/回车"键时触发。两个事件配合使用:onChange 负责实时同步输入内容,onSubmit 负责提交操作。

为什么不直接用 TextInputonSubmit 而要加"添加"按钮? 因为手机键盘上不是所有输入法都有明显的"确认"键。加一个"添加"按钮让交互更明确——用户既能按回车提交,也能点击按钮提交。

添加操作的防御性检查

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 实现定时器逻辑
收藏无批量操作 只能逐个取消收藏 加入"编辑模式"批量删除
Logo

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

更多推荐