HarmonyOS菜谱——架构总览与数据层演进分析
适用读者: 已经读完前面几篇菜谱系列、想从全局理解项目架构的开发者。本文不重复讲解单个页面的实现细节,而是从"数据怎么流动"和"代码怎么组织"两个维度做整体分析。
完整效果
一、项目文件结构与职责划分
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 个字段涵盖了菜谱的所有信息。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 |
| 数据需要按条件筛选 | 模式二:查询+@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 加载菜谱 |
这六个方向的优先级:搜索功能 > 数据持久化 > 计时器 > 份量联动 > 去重 > 网络数据。搜索是用户最直接感知的功能缺失,数据持久化影响用户体验(重启后收藏丢失),计时器和份量联动是锦上添花,网络数据是产品化的最后一步。
更多推荐

所有评论(0)