适用读者: 想深入理解 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.nameing.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 的 undefinednull 更安全——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 返回引用(因为采购清单顺序不重要)。

Logo

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

更多推荐