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

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

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

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

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

项目页面数服务层核心数据
智能家居5DeviceData17 个设备,4 个操作函数
旅行探索10TravelData + UserDataService6 个城市,2 个服务
菜谱5RecipeData8 道菜谱,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 个字段涵盖了菜谱的所有信息。Ingredient 和 Category 是辅助结构,分别服务于食材列表和分类导航。

接口的设计原则是"字段只存展示数据,不存行为"。Recipe 没有 isFavorite 或 cookTime(倒计时)字段——这些是运行时状态,应该在页面的 @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 _favs 和 let _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
数据需要按条件筛选模式二:查询+@StateCategoryPage 按分类筛选
数据需要跨页面同步模式三:共享状态收藏/采购清单

四、操作函数的设计模式

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。直接赋值就能触发刷新,不需要复制数组或重新查询。

四种模式的选择标准

数据类型修改方式模式例子
数组(需要筛选)查询函数ACategoryPage 的 recipes
数组(跨页面同步)操作函数+重新查询BFavoritesPage 的 recipes
布尔(跨页面同步)操作函数返回值CRecipeDetail 的 fav
基本类型(页面内)直接赋值Dservings/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 → CategoryPagecat: string分类名router.getParams()
Index → RecipeDetailid: number菜谱 idrouter.getParams()
CategoryPage → RecipeDetailid: number菜谱 idrouter.getParams()
FavoritesPage → RecipeDetailid: number菜谱 idrouter.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、测试、元服务和应用上架分发等。

更多推荐