HarmonyOS菜谱——数据层的接口设计与操作函数实现
适用读者: 想深入理解 HarmonyOS ArkTS 数据层设计思路的开发者。本文从接口字段的选择理由出发,逐个分析 8 个操作函数的实现逻辑和设计取舍。
完整效果
一、Recipe 接口:12 个字段的设计逻辑
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
}
12 个字段可以分为四组:
基本信息组(5 个字段)
| 字段 | 类型 | 含义 | 为什么这样设计 |
|---|---|---|---|
| id | number | 唯一标识 | 数字比字符串更高效,路由传参用数字 id |
| name | string | 菜名 | 展示用,也是搜索匹配的目标字段 |
| icon | string | emoji 图标 | mock 阶段替代真实图片,真实 App 应改为 imageUrl |
| category | string | 所属分类 | 和 Category 的 name 字段做字符串匹配 |
| desc | string | 一句话描述 | 列表页展示,maxLines(1) 截断 |
id 为什么从 1 开始而不是 0? 因为 id = 0 在某些场景下容易和"未赋值"混淆。从 1 开始让 id 有明确的"有效值"语义。在 8 道菜谱的规模下,id 的连续性不是问题,但如果后续动态添加菜谱,需要注意避免 id 冲突。
icon 为什么用 string 而不是 ImageResource? ArkTS 的 Image 组件支持 $r('app.media.xxx') 引用本地图片,但 mock 阶段没有准备图片资源。emoji 是最轻量的视觉替代方案——不需要额外的图片文件,直接用 Unicode 字符就能显示。真实 App 应该把 icon 改为 imageUrl,从后端加载高清食物照片。
category 为什么用字符串匹配而不用数字 id? 因为分类数据很简单(只有 6 个),字符串匹配的可读性更好——RECIPES[i].category === '家常菜' 比 RECIPES[i].categoryId === 1 更直观。在原型阶段,可读性比性能更重要。
属性组(3 个字段)
| 字段 | 类型 | 含义 | 为什么是 string 不是 number |
|---|---|---|---|
| time | string | 烹饪时间 | "60分钟"比 60 更直观,包含了单位 |
| difficulty | string | 难度等级 | "简单/中等/困难"是离散值,用枚举字符串 |
| servings | string | 适用人数 | "3-4人"是一个范围,不是固定数字 |
这三个字段都用 string 而不是 number,原因是它们都包含"附加信息"。 time 包含"分钟"单位,servings 是一个范围("3-4人"而不是固定 3 人)。如果用 number,需要额外字段存储单位和范围,增加了接口复杂度。
但 string 也有缺点——不方便做数值比较和计算。比如"份量调整"功能需要根据人数重新计算食材用量,但 servings 是字符串,无法直接乘以倍数。真实 App 应该把 time 和 servings 拆分为 { value: number, unit: string } 对象,既保留直观性又支持数值计算。
内容组(3 个字段)
| 字段 | 类型 | 含义 | 嵌套原因 |
|---|---|---|---|
| ingredients | Ingredient[] | 食材列表 | 每个食材有独立的名称和用量 |
| steps | string[] | 步骤列表 | 每步是一个完整的操作说明 |
| tips | string | 小贴士 | 一段完整的文字,不需要拆分 |
ingredients 为什么是对象数组而不是字符串数组? 如果用 string[](如 ['五花肉 500g', '冰糖 30g']),展示时需要手动拆分名称和用量,代码会很啰嗦。用 { name, amount } 对象,展示时直接 ing.name 和 ing.amount,代码更清晰。
steps 为什么是字符串数组而不是对象数组? 因为每个步骤就是一个完整的操作说明——“五花肉切2cm方块,冷水下锅焯水去血沫”。不需要拆分为"动作+对象+参数"等结构。如果以后需要标记步骤状态(已完成/进行中),可以改为 { text: string, done: boolean } 对象。
tips 为什么是单个字符串而不是数组? 小贴士通常是一段完整的经验总结,不是分步骤的操作。用单个字符串存储,展示时用一个 Text 组件显示整段文字。如果以后需要多条小贴士,再改为 string[]。
二、Ingredient 接口:最简设计
export interface Ingredient { name: string; amount: string }
只有两个字段——名称和用量。没有 category(食材分类)、unit(单位)、price(价格)等字段。
为什么 amount 用 string 而不是 number + unit? “500g”“2勺”"适量"这三种格式无法用统一的 number + unit 表示——"适量"没有具体数字。用 string 直接存储原始格式最简单,展示时原样输出。
踩坑记录: 最初考虑过把 amount 拆分为 { value: number, unit: string },但发现"适量"无法表示为数字。改成 string 后代码更简洁,代价是无法做数值计算(比如根据份数倍增食材用量)。这个取舍在原型阶段是合理的。
三、Category 接口:颜色编码的设计
export interface Category { name: string; icon: string; color: string }
三个字段的设计决策:
name 用中文而不是英文 key。 "家常菜"比 “home_dish” 更直观,但中文在代码里有编码风险。在 ArkTS 的 mock 阶段这不是问题,但真实 App 应该用英文 key + 中文显示名的双字段设计。
color 用于分类图标的背景色。 每个分类有自己的颜色——家常菜=#FF6B6B(红)、汤粥=#FF9F43(橙)、面食=#FECA57(黄)等。颜色 + ‘12’(18% 透明度)作为分类图标的背景色,让用户一眼区分不同分类。
6 个分类的颜色形成了暖色系渐变。 从红到橙到黄到深橙,再到绿和蓝。前四个是暖色系(食物相关),后两个是冷色系(非热食)。这种颜色编码不是随意的——它暗示了分类的"温度"属性。
四、操作函数的逐个分析
函数一:getRecipeById
export function getRecipeById(id: number): Recipe | undefined {
for (let i: number = 0; i < RECIPES.length; i++) {
if (RECIPES[i].id === id) return RECIPES[i]
}
return undefined
}

返回类型 Recipe | undefined 的设计考量。 如果传入的 id 不存在(比如用户手动修改了 URL 参数),返回 undefined 而不是 null。TypeScript 的 undefined 比 null 更安全——if (recipe) 能同时判断 undefined 和 null,但 undefined 在语义上表示"不存在",null 表示"空值"。
直接返回 RECIPES[i] 而不是复制。 查询函数不需要复制——它返回的是原始数组里的元素引用,调用方读取属性不会修改数据。如果调用方需要修改 Recipe 的属性(目前没有这个需求),才需要复制。
函数二:getRecipesByCat
export function getRecipesByCat(cat: string): Recipe[] {
const r: Recipe[] = []
for (let i: number = 0; i < RECIPES.length; i++) {
if (RECIPES[i].category === cat) r.push(RECIPES[i])
}
return r
}

返回新数组而不是过滤后的引用。 r.push(RECIPES[i]) 创建了一个新数组,和 RECIPES 完全独立。调用方修改返回的数组不会影响 RECIPES。这是查询函数的标准做法——读操作返回副本,保证原始数据安全。
时间复杂度 O(n)。 遍历一次 RECIPES 数组,每道菜检查 category 是否匹配。在 8 道菜的规模下性能不是问题。如果菜谱增加到上千道,可以考虑建索引(按 category 分组的 Map),但原型阶段不需要。
函数三:toggleFav
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
}

返回 boolean 的设计——让调用方直接赋值 @State。 toggleFav 返回切换后的状态(true=已收藏,false=未收藏)。调用方 this.fav = toggleFav(id) 一行代码完成操作和状态同步,不需要先操作再查询。
取消收藏时的数组重建。 当找到匹配的 id 时,创建新数组 n,跳过第 i 项,然后 _favs = n。这比 _favs.splice(i, 1) 更安全——splice 原地修改数组,不改变引用;创建新数组并替换引用能保证任何读取 _favs 的代码都能看到最新数据。
添加收藏时直接 push。 _favs.push(id) 在数组末尾添加新元素。push 会修改原数组,但因为 toggleFav 返回了 boolean,调用方通过返回值更新 @State,不需要依赖 _favs 的引用变化。
两种操作的不对称设计: 取消收藏创建新数组,添加收藏直接 push。这种不对称是因为:取消收藏需要"精确删除指定位置",创建新数组是最清晰的方式;添加收藏只需要"在末尾追加",push 最简单。在 8 道菜的规模下,两种方式的性能差异可以忽略。
函数四:getFavs
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
}
双重遍历的原因。 外层遍历 RECIPES(所有菜谱),内层遍历 _favs(收藏的 id 列表)。对每道菜检查它的 id 是否在收藏列表里——在就加入结果数组。break 在找到匹配后立即退出内层循环,避免不必要的遍历。
为什么不反过来遍历(外层 _favs,内层 RECIPES)? 两种方式结果一样,但外层 RECIPES 能保证返回的数组按 RECIPES 的原始顺序排列(先收藏的排前面)。如果反过来遍历,顺序取决于 _favs 的添加顺序——虽然也是时间顺序,但和 RECIPES 的顺序可能不一致。保持和 RECIPES 一致的顺序更可预测。
函数五:isFav
export function isFav(id: number): boolean {
for (let i: number = 0; i < _favs.length; i++) {
if (_favs[i] === id) return true
}
return false
}
最简单的查询函数——找到返回 true,找不到返回 false。 和 getFavs 的区别是:isFav 只判断是否存在,返回 boolean;getFavs 返回完整的 Recipe 列表。isFav 的时间复杂度 O(m)(m 是收藏数),getFavs 是 O(n×m)(n 是菜谱数)。
踩坑记录: 最初 RecipeDetail 的收藏按钮直接调 toggleFav 后再调 isFav 查询状态——两次遍历 _favs。改成 toggleFav 返回 boolean 后减少了一次遍历,代码也更简洁。
函数六:addToShop
export function addToShop(item: string): void { _shop.push(item) }
最简操作——直接 push。 没有去重检查,没有参数校验。多次调用会添加多个相同项。这是原型阶段的简化设计,真实 App 需要在 push 前检查 _shop.indexOf(item) === -1。
函数七:getShop
export function getShop(): string[] { return _shop }
直接返回引用而不是复制。 和 getFavs 不同——getFavs 创建新数组,getShop 直接返回 _shop 的引用。原因是采购清单的消费方式和收藏不同:收藏需要保持顺序稳定(getFavs 每次返回相同顺序),采购清单的顺序不重要(先添加的先显示就行)。
这个差异揭示了一个设计原则: 如果调用方需要稳定的数据快照,返回新数组;如果调方只需要读取最新值,返回引用更高效。
函数八: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
}
按索引删除而不是按内容删除。 因为采购清单允许重复项——如果用户添加了两次"鸡蛋",按内容删除会删掉两个,按索引删除只删一个。ForEach 的第二个参数 idx 就是当前项的索引,直接传给 removeFromShop 即可。
创建新数组而不是 splice。 原因和 toggleFav 一样——splice 不改变引用,创建新数组并替换引用能保证 @State 检测到变化。
五、八个函数的设计模式总结
| 函数 | 操作类型 | 返回值 | 是否创建新数组 | 调用方 |
|---|---|---|---|---|
| getRecipeById | 查询 | Recipe | undefined | 否(返回原始引用) | RecipeDetail |
| getRecipesByCat | 查询 | Recipe[] | 是 | CategoryPage |
| toggleFav | 修改 | boolean | 取消时是,添加时否 | RecipeDetail |
| getFavs | 查询 | Recipe[] | 是 | FavoritesPage |
| isFav | 查询 | boolean | 否 | RecipeDetail |
| addToShop | 修改 | void | 否(直接 push) | RecipeDetail |
| getShop | 查询 | string[] | 否(返回引用) | ShoppingListPage |
| removeFromShop | 修改 | void | 是 | ShoppingListPage |
| clearShop | 修改 | void | 否(重置为空数组) | ShoppingListPage |
规律:查询函数返回新数组(保证数据快照),修改函数创建新数组(触发 @State 刷新)。 两个例外:getRecipeById 返回原始引用(因为只查单个元素,不需要快照),getShop 返回引用(因为采购清单顺序不重要)。
更多推荐

所有评论(0)