适用读者: 已经读完前面几篇菜谱系列、想从全局理解项目架构的开发者。本文不重复讲解单个页面的实现细节,而是从"数据怎么流动"和"代码怎么组织"两个维度做整体分析。

完整效果
在这里插入图片描述

一、项目文件结构与职责划分

entry/src/main/ets/
├── pages/
│   ├── Index.ets              首页:搜索 + 分类导航 + 菜谱列表 + 底部导航
│   ├── CategoryPage.ets       分类页:按分类筛选菜谱列表
│   ├── RecipeDetail.ets       详情页:食材/步骤/贴士/份量/计时器/收藏/加入清单
│   ├── FavoritesPage.ets      收藏页:已收藏菜谱列表
│   └── ShoppingListPage.ets   采购清单:食材输入/删除/清空
└── services/
    └── RecipeData.ets         数据层:接口定义 + 静态数据 + 查询函数 + 共享状态操作

5 个页面 + 1 个服务层。和前面两个 App 对比:

项目 页面数 服务层 核心数据
智能家居 5 DeviceData 17 个设备,4 个操作函数
旅行探索 10 TravelData + UserDataService 6 个城市,2 个服务
菜谱 5 RecipeData 8 道菜谱,8 个操作函数

菜谱 App 的页面数量和智能家居一样,但数据层更复杂——RecipeData 同时承担了静态数据、查询函数、共享状态三种职责。这是项目规模决定的:5 个页面共享收藏和采购清单状态,需要在数据层统一管理。

二、数据层的三层结构

RecipeData.ets 的代码可以分为三层:

第一层:接口定义

export interface Recipe {
  id: number; name: string; icon: string; category: string; time: string; difficulty: string
  servings: string; desc: string; ingredients: Ingredient[]; steps: string[]; tips: string
}
export interface Ingredient { name: string; amount: string }
export interface Category { name: string; icon: string; color: string }

在这里插入图片描述

三个接口定义了三种数据结构。Recipe 是核心——12 个字段涵盖了菜谱的所有信息。IngredientCategory 是辅助结构,分别服务于食材列表和分类导航。

接口的设计原则是"字段只存展示数据,不存行为"。Recipe 没有 isFavoritecookTime(倒计时)字段——这些是运行时状态,应该在页面的 @State 里管理,不应该污染数据结构。

第二层:静态数据

export const CATS: Category[] = [ /* 6 个分类 */ ]
export const RECIPES: Recipe[] = [ /* 8 道菜谱 */ ]

两个 export const 数组是整个 App 的数据源。它们是"只读"的——没有操作函数会修改 CATS 或 RECIPES 的内容。这和智能家居的 DeviceData 不同——DeviceData 的 DEVICES 数组会被 toggleDevice 修改。

静态数据的设计决策:为什么不用本地 JSON 文件或网络 API?因为这是 demo 阶段,硬编码在代码里最简单。真实 App 应该从后端 API 加载数据,但在学习和原型阶段,mock 数据降低了开发成本。

第三层:共享状态 + 操作函数

// 收藏状态
let _favs: number[] = []
export function toggleFav(id: number): boolean { ... }
export function getFavs(): Recipe[] { ... }
export function isFav(id: number): boolean { ... }

// 采购清单状态
let _shop: string[] = []
export function addToShop(item: string): void { ... }
export function getShop(): string[] { ... }
export function clearShop(): void { ... }
export function removeFromShop(idx: number): void { ... }

在这里插入图片描述

两组操作函数分别管理收藏和采购清单。它们操作的是模块级变量(let _favslet _shop),不是 @State——@State 是页面级的,模块级变量是全局的。

三、数据流的三种模式

模式一:静态数据直读(无状态)

页面: Index、CategoryPage
数据: CATS、RECIPES
操作: 只读,不修改

Index.ets → import { CATS, RECIPES } → ForEach 直接遍历渲染
CategoryPage.ets → import { getRecipesByCat } → 查询后 @State 渲染

这两个页面读取静态数据,不需要在页面内维护状态副本。Index 用 ForEach(CATS, ...)ForEach(RECIPES, ...) 直接遍历原始数组。CategoryPage 用 getRecipesByCat(cat) 查询子集后赋值给 @State。

为什么 Index 不需要 @State 存菜谱列表? 因为首页显示的是全部菜谱,不需要筛选、排序、分页。直接遍历 RECIPES 就够了。如果以后加搜索过滤功能,就需要 @State filteredRecipes: Recipe[] 来存储过滤结果。

模式二:查询函数 + @State 副本

页面: CategoryPage、FavoritesPage
数据: getRecipesByCat、getFavs
操作: 查询后赋值 @State

// CategoryPage
this.recipes = getRecipesByCat(this.cat)

// FavoritesPage
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
}

这两个页面都需要根据条件筛选数据——CategoryPage 按分类筛选,FavoritesPage 按收藏状态筛选。筛选结果赋值给 @State,UI 根据 @State 渲染。

FavoritesPage 的 refresh 为什么要复制数组? getFavs() 返回的是新数组(函数内部 r.push(RECIPES[i]) 创建的),理论上直接赋值也能触发 @State 刷新。但复制是防御性编程——保证 @State 的引用一定和上次不同。这个模式在前面的智能家居和旅行探索项目里也反复出现。

模式三:共享状态 + 操作函数

页面: RecipeDetail、FavoritesPage、ShoppingListPage
数据: _favs、_shop
操作: 读写同一份模块级变量

RecipeDetail → toggleFav(id) → 修改 _favs
FavoritesPage → getFavs() → 读取 _favs

RecipeDetail → addToShop(item) → 修改 _shop
ShoppingListPage → getShop() → 读取 _shop

这是跨页面数据同步的核心模式——一个页面修改数据,另一个页面读取数据,通过共享的模块级变量实现一致性。

三种模式的判断标准:

条件 模式 例子
数据只读、不需要筛选 模式一:直读 Index 读 CATS/RECIPES
数据需要按条件筛选 模式二:查询+@State CategoryPage 按分类筛选
数据需要跨页面同步 模式三:共享状态 收藏/采购清单

四、操作函数的设计模式

8 个操作函数可以分为三类:

查询类:getFavs、getRecipesByCat、getRecipeById

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
}

查询函数的共同特点:遍历数组、条件过滤、返回新数组。不修改原始数据,只读取。返回新数组而不是引用——保证调用方修改返回值不会影响原始数据。

getFavs 有双重遍历——外层遍历 RECIPES,内层遍历 _favs。时间复杂度 O(n×m),n 是菜谱数,m 是收藏数。在 8 道菜谱的规模下完全不是问题,但如果数据量增大,可以用 Set 优化:

// 优化版本:用 Set 降低时间复杂度到 O(n+m)
export function getFavs(): Recipe[] {
  const favSet = new Set(_favs)
  const r: Recipe[] = []
  for (let i: number = 0; i < RECIPES.length; i++) {
    if (favSet.has(RECIPES[i].id)) r.push(RECIPES[i])
  }
  return r
}

但 ArkTS 对 Set 的支持不如 TypeScript 完善,当前用双重遍历更稳妥。

修改类:toggleFav、addToShop、clearShop

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
}

在这里插入图片描述

修改函数的共同特点:先判断当前状态,再决定添加或删除,返回新状态toggleFav 返回 boolean,让调用方直接赋值 this.fav = toggleFav(id),不需要额外查询。

为什么不用 splice _favs.splice(i, 1) 会原地修改数组,不改变引用。如果页面用 this.recipes = getFavs() 刷新,getFavs() 返回新数组,@State 能检测到变化。但如果以后有页面直接读取 _favs,splice 不会触发任何通知。创建新数组并替换引用是最安全的做法。

删除类:removeFromShop

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
}

删除函数用"索引过滤"——遍历数组,跳过指定索引,创建新数组。这和 toggleFav 的"id 过滤"思路一样,只是定位方式不同(索引 vs id)。

五、@State 刷新的完整分类

整个项目里 @State 的刷新可以归纳为四种模式:

模式 A:重新查询赋值

// CategoryPage
this.recipes = getRecipesByCat(this.cat)

// FavoritesPage
this.recipes = copy  // getFavs() + 复制

适用于需要按条件筛选的数组。查询函数返回新数组,赋值给 @State 触发刷新。

模式 B:操作函数 + 重新查询

// FavoritesPage
this.refresh()  // 内部调用 getFavs() + 复制

// ShoppingListPage
addToShop(this.input.trim())
this.refresh()  // 内部调用 getShop() + 复制

适用于"先修改共享数据、再刷新页面"的场景。操作函数修改模块级变量,refresh 重新查询最新数据赋值给 @State。

模式 C:操作函数 + 返回值赋值

// RecipeDetail
this.fav = toggleFav(this.recipe.id)

适用于操作函数返回新状态的场景。toggleFav 返回 boolean,直接赋值给 @State,不需要额外查询。

模式 D:直接赋值

// RecipeDetail
this.servings--
this.servings++
this.timerOn = !this.timerOn
this.timerMin = 3; this.timerSec = 0; this.timerOn = true

// ShoppingListPage
this.input = ''

适用于基本类型(number、boolean、string)的 @State。直接赋值就能触发刷新,不需要复制数组或重新查询。

四种模式的选择标准

数据类型 修改方式 模式 例子
数组(需要筛选) 查询函数 A CategoryPage 的 recipes
数组(跨页面同步) 操作函数+重新查询 B FavoritesPage 的 recipes
布尔(跨页面同步) 操作函数返回值 C RecipeDetail 的 fav
基本类型(页面内) 直接赋值 D servings/timerOn/input

六、导航与路由的设计

底部导航栏:Index 的三个 tab

Index (首页/菜谱)
  ├→ 菜谱 tab:不跳转(当前页)
  ├→ 收藏 tab → FavoritesPage
  └→ 清单 tab → ShoppingListPage

底部导航栏用 @Builder NavBtn 实现,通过 router.pushUrl 跳转。“菜谱” tab 不跳转——因为 Index 本身就是菜谱页面。

踩坑记录: 最初"菜谱" tab 也写了跳转 router.pushUrl({ url: 'pages/Index' }),点击后页面重建,搜索框内容丢失。修复方式是去掉"菜谱" tab 的跳转逻辑。这和智能家居首页的问题一样——@Entry 页面重复 push 会导致页面重建。

页面间跳转的参数设计

跳转 参数 类型 接收方式
Index → CategoryPage cat: string 分类名 router.getParams()
Index → RecipeDetail id: number 菜谱 id router.getParams()
CategoryPage → RecipeDetail id: number 菜谱 id router.getParams()
FavoritesPage → RecipeDetail id: number 菜谱 id router.getParams()
Index → FavoritesPage
Index → ShoppingListPage

只有两种参数模式:传分类名(字符串)和传菜谱 id(数字)。收藏页和采购清单页不需要参数——它们的数据来自共享状态,不依赖入口参数。

和前面 App 的路由对比

菜谱 App 的路由比旅行探索简单得多——旅行探索有 10 个页面、多种参数模式(城市 id、工具页直接跳转等)。菜谱只有 5 个页面,路由关系清晰。简单项目的路由不需要复杂设计——router.pushUrl + params 就够了。

七、Builder 的使用策略

整个项目只有 1 个 Builder:

@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' })
  })
}

只在首页的底部导航栏用了一个 Builder。其他页面没有抽取 Builder——因为菜谱卡片虽然在 Index、CategoryPage、FavoritesPage 三个页面重复出现,但每个页面的卡片结构有微小差异(首页第三行用三个独立 Text,其他页面用拼接字符串),强行统一需要处理这些差异。

Builder 抽取的判断标准: 出现 3 次以上且结构完全一致时才抽取。菜谱卡片出现 3 次但结构有差异,不满足"完全一致"条件。在 5 个页面的规模下,重复代码的维护成本可以接受。

八、颜色系统

const A: string = '#FF6B6B'   // 主色:红色,代表食欲、热情
const BG: string = '#F8F7FC'   // 背景:极浅灰紫
const CW: string = '#FFFFFF'   // 卡片:白色
const T1: string = '#1E1B2E'   // 标题:深色
const T2: string = '#888888'   // 副文字:灰色
const T3: string = '#BBBBBB'   // 辅助:浅灰

主色选择的逻辑

菜谱 App 选了 #FF6B6B(红色),智能家居选了 #6C5CE7(紫色),旅行探索选了 #FF6B35(橙色)。三个 App 的主色选择不是随意的——红色让人联想到食物和食欲(菜谱),紫色给人科技和智能感(智能家居),橙色代表旅行和活力(旅行探索)。

功能颜色的使用规律

颜色 使用场景
主色 A #FF6B6B 强调按钮、选中状态、食材数量、计时器
绿色 #00B894 凉菜分类(间接使用)
橙色 #FF9F43 汤粥分类、渐变头像
灰色 #F5F5FA 按钮背景、emoji 底座

功能颜色在菜谱 App 里用得比智能家居少——因为菜谱的内容更偏向"展示"而不是"状态"。智能家居需要用绿色/红色编码"安全/警告"状态,菜谱没有这种需求。

九、后续可优化的方向

方向 当前状态 优化方案
数据持久化 内存数据,重启清空 引入 Preferences 本地存储
搜索功能 搜索框有 UI 无逻辑 实现 getRecipesBySearch(text)
计时器 只有状态切换 实现 setInterval 倒计时
采购清单去重 可重复添加 addToShop 里检查重复
份量联动 只改显示数字 根据份数重新计算食材用量
网络数据 硬编码 mock 从后端 API 加载菜谱

这六个方向的优先级:搜索功能 > 数据持久化 > 计时器 > 份量联动 > 去重 > 网络数据。搜索是用户最直接感知的功能缺失,数据持久化影响用户体验(重启后收藏丢失),计时器和份量联动是锦上添花,网络数据是产品化的最后一步。

Logo

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

更多推荐