HarmonyOS菜谱——数据层的接口设计与操作函数实现

📅 2026/7/15 3:43:36
HarmonyOS菜谱——数据层的接口设计与操作函数实现
适用读者想深入理解 HarmonyOS ArkTS 数据层设计思路的开发者。本文从接口字段的选择理由出发逐个分析 8 个操作函数的实现逻辑和设计取舍。完整效果一、Recipe 接口12 个字段的设计逻辑exportinterfaceRecipe{id:number;name:string;icon:string;category:string;time:string;difficulty:stringservings:string;desc:string;ingredients:Ingredient[];steps:string[];tips:string}12 个字段可以分为四组基本信息组5 个字段字段类型含义为什么这样设计idnumber唯一标识数字比字符串更高效路由传参用数字 idnamestring菜名展示用也是搜索匹配的目标字段iconstringemoji 图标mock 阶段替代真实图片真实 App 应改为 imageUrlcategorystring所属分类和 Category 的 name 字段做字符串匹配descstring一句话描述列表页展示maxLines(1) 截断id 为什么从 1 开始而不是 0因为 id 0 在某些场景下容易和未赋值混淆。从 1 开始让 id 有明确的有效值语义。在 8 道菜谱的规模下id 的连续性不是问题但如果后续动态添加菜谱需要注意避免 id 冲突。icon 为什么用 string 而不是 ImageResourceArkTS 的 Image 组件支持$r(app.media.xxx)引用本地图片但 mock 阶段没有准备图片资源。emoji 是最轻量的视觉替代方案——不需要额外的图片文件直接用 Unicode 字符就能显示。真实 App 应该把 icon 改为 imageUrl从后端加载高清食物照片。category 为什么用字符串匹配而不用数字 id因为分类数据很简单只有 6 个字符串匹配的可读性更好——RECIPES[i].category 家常菜比RECIPES[i].categoryId 1更直观。在原型阶段可读性比性能更重要。属性组3 个字段字段类型含义为什么是 string 不是 numbertimestring烹饪时间60分钟比 60 更直观包含了单位difficultystring难度等级简单/中等/困难是离散值用枚举字符串servingsstring适用人数3-4人是一个范围不是固定数字这三个字段都用 string 而不是 number原因是它们都包含附加信息。time 包含分钟单位servings 是一个范围3-4人而不是固定 3 人。如果用 number需要额外字段存储单位和范围增加了接口复杂度。但 string 也有缺点——不方便做数值比较和计算。比如份量调整功能需要根据人数重新计算食材用量但 servings 是字符串无法直接乘以倍数。真实 App 应该把 time 和 servings 拆分为{ value: number, unit: string }对象既保留直观性又支持数值计算。内容组3 个字段字段类型含义嵌套原因ingredientsIngredient[]食材列表每个食材有独立的名称和用量stepsstring[]步骤列表每步是一个完整的操作说明tipsstring小贴士一段完整的文字不需要拆分ingredients 为什么是对象数组而不是字符串数组如果用string[]如[五花肉 500g, 冰糖 30g]展示时需要手动拆分名称和用量代码会很啰嗦。用{ name, amount }对象展示时直接ing.name和ing.amount代码更清晰。steps 为什么是字符串数组而不是对象数组因为每个步骤就是一个完整的操作说明——“五花肉切2cm方块冷水下锅焯水去血沫”。不需要拆分为动作对象参数等结构。如果以后需要标记步骤状态已完成/进行中可以改为{ text: string, done: boolean }对象。tips 为什么是单个字符串而不是数组小贴士通常是一段完整的经验总结不是分步骤的操作。用单个字符串存储展示时用一个 Text 组件显示整段文字。如果以后需要多条小贴士再改为string[]。二、Ingredient 接口最简设计exportinterfaceIngredient{name:string;amount:string}只有两个字段——名称和用量。没有 category食材分类、unit单位、price价格等字段。为什么 amount 用 string 而不是 number unit“500g”“2勺”适量这三种格式无法用统一的 number unit 表示——适量没有具体数字。用 string 直接存储原始格式最简单展示时原样输出。踩坑记录最初考虑过把 amount 拆分为{ value: number, unit: string }但发现适量无法表示为数字。改成 string 后代码更简洁代价是无法做数值计算比如根据份数倍增食材用量。这个取舍在原型阶段是合理的。三、Category 接口颜色编码的设计exportinterfaceCategory{name:string;icon:string;color:string}三个字段的设计决策name 用中文而不是英文 key。家常菜比 “home_dish” 更直观但中文在代码里有编码风险。在 ArkTS 的 mock 阶段这不是问题但真实 App 应该用英文 key 中文显示名的双字段设计。color 用于分类图标的背景色。每个分类有自己的颜色——家常菜#FF6B6B红、汤粥#FF9F43橙、面食#FECA57黄等。颜色 ‘12’18% 透明度作为分类图标的背景色让用户一眼区分不同分类。6 个分类的颜色形成了暖色系渐变。从红到橙到黄到深橙再到绿和蓝。前四个是暖色系食物相关后两个是冷色系非热食。这种颜色编码不是随意的——它暗示了分类的温度属性。四、操作函数的逐个分析函数一getRecipeByIdexportfunctiongetRecipeById(id:number):Recipe|undefined{for(leti:number0;iRECIPES.length;i){if(RECIPES[i].idid)returnRECIPES[i]}returnundefined}返回类型Recipe | undefined的设计考量。如果传入的 id 不存在比如用户手动修改了 URL 参数返回 undefined 而不是 null。TypeScript 的undefined比null更安全——if (recipe)能同时判断 undefined 和 null但undefined在语义上表示不存在null表示空值。直接返回 RECIPES[i] 而不是复制。查询函数不需要复制——它返回的是原始数组里的元素引用调用方读取属性不会修改数据。如果调用方需要修改 Recipe 的属性目前没有这个需求才需要复制。函数二getRecipesByCatexportfunctiongetRecipesByCat(cat:string):Recipe[]{constr:Recipe[][]for(leti:number0;iRECIPES.length;i){if(RECIPES[i].categorycat)r.push(RECIPES[i])}returnr}返回新数组而不是过滤后的引用。r.push(RECIPES[i])创建了一个新数组和 RECIPES 完全独立。调用方修改返回的数组不会影响 RECIPES。这是查询函数的标准做法——读操作返回副本保证原始数据安全。时间复杂度 O(n)。遍历一次 RECIPES 数组每道菜检查 category 是否匹配。在 8 道菜的规模下性能不是问题。如果菜谱增加到上千道可以考虑建索引按 category 分组的 Map但原型阶段不需要。函数三toggleFavexportfunctiontoggleFav(id:number):boolean{for(leti:number0;i_favs.length;i){if(_favs[i]id){constn:number[][]for(letj:number0;j_favs.length;j){if(j!i)n.push(_favs[j])}_favsnreturnfalse}}_favs.push(id)returntrue}返回 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 道菜的规模下两种方式的性能差异可以忽略。函数四getFavsexportfunctiongetFavs():Recipe[]{constr:Recipe[][]for(leti:number0;iRECIPES.length;i){for(letj:number0;j_favs.length;j){if(RECIPES[i].id_favs[j]){r.push(RECIPES[i]);break}}}returnr}双重遍历的原因。外层遍历 RECIPES所有菜谱内层遍历 _favs收藏的 id 列表。对每道菜检查它的 id 是否在收藏列表里——在就加入结果数组。break在找到匹配后立即退出内层循环避免不必要的遍历。为什么不反过来遍历外层 _favs内层 RECIPES两种方式结果一样但外层 RECIPES 能保证返回的数组按 RECIPES 的原始顺序排列先收藏的排前面。如果反过来遍历顺序取决于 _favs 的添加顺序——虽然也是时间顺序但和 RECIPES 的顺序可能不一致。保持和 RECIPES 一致的顺序更可预测。函数五isFavexportfunctionisFav(id:number):boolean{for(leti:number0;i_favs.length;i){if(_favs[i]id)returntrue}returnfalse}最简单的查询函数——找到返回 true找不到返回 false。和getFavs的区别是isFav只判断是否存在返回 booleangetFavs返回完整的 Recipe 列表。isFav的时间复杂度 O(m)m 是收藏数getFavs是 O(n×m)n 是菜谱数。踩坑记录最初 RecipeDetail 的收藏按钮直接调toggleFav后再调isFav查询状态——两次遍历 _favs。改成toggleFav返回 boolean 后减少了一次遍历代码也更简洁。函数六addToShopexportfunctionaddToShop(item:string):void{_shop.push(item)}最简操作——直接 push。没有去重检查没有参数校验。多次调用会添加多个相同项。这是原型阶段的简化设计真实 App 需要在 push 前检查_shop.indexOf(item) -1。函数七getShopexportfunctiongetShop():string[]{return_shop}直接返回引用而不是复制。和getFavs不同——getFavs创建新数组getShop直接返回_shop的引用。原因是采购清单的消费方式和收藏不同收藏需要保持顺序稳定getFavs 每次返回相同顺序采购清单的顺序不重要先添加的先显示就行。这个差异揭示了一个设计原则如果调用方需要稳定的数据快照返回新数组如果调方只需要读取最新值返回引用更高效。函数八removeFromShopexportfunctionremoveFromShop(idx:number):void{constn:string[][]for(leti:number0;i_shop.length;i){if(i!idx)n.push(_shop[i])}_shopn}按索引删除而不是按内容删除。因为采购清单允许重复项——如果用户添加了两次鸡蛋按内容删除会删掉两个按索引删除只删一个。ForEach 的第二个参数idx就是当前项的索引直接传给removeFromShop即可。创建新数组而不是 splice。原因和toggleFav一样——splice 不改变引用创建新数组并替换引用能保证 State 检测到变化。五、八个函数的设计模式总结函数操作类型返回值是否创建新数组调用方getRecipeById查询Recipe | undefined否返回原始引用RecipeDetailgetRecipesByCat查询Recipe[]是CategoryPagetoggleFav修改boolean取消时是添加时否RecipeDetailgetFavs查询Recipe[]是FavoritesPageisFav查询boolean否RecipeDetailaddToShop修改void否直接 pushRecipeDetailgetShop查询string[]否返回引用ShoppingListPageremoveFromShop修改void是ShoppingListPageclearShop修改void否重置为空数组ShoppingListPage规律查询函数返回新数组保证数据快照修改函数创建新数组触发 State 刷新。两个例外getRecipeById返回原始引用因为只查单个元素不需要快照getShop返回引用因为采购清单顺序不重要。