ArkTS 购物清单食材重复怎么办:中式美食 ingredientKey、单位和展示名怎么分开合并
2026/7/14 18:47:03 网站建设 项目流程

购物清单里出现两条“番茄”和一条“西红柿”,用户大概率不会觉得这是三种东西;但如果你直接按显示名称合并,又可能把“鸡蛋”和“土鸡蛋”合得太粗。中式美食做购物清单时,我更愿意把这件事拆清楚:给用户看的名字是一回事,用来合并的 key 是另一回事,单位能不能相加又是第三回事。拆开以后,页面不会乱,RDB 也能查得明白。

项目说明
应用名称中式美食
这篇解决什么多个菜谱加入购物清单后,同一个食材因为叫法或单位不同导致重复、误合并
谁会搜这篇做 HarmonyOS 菜谱、购物清单、本地持久化、RDB 合并逻辑的 ArkTS 开发者
点进来能拿到什么ingredientKey设计、单位策略、来源记录、Repository 合并和 ArkUI 展示方式
当前项目证据中式美食有菜谱详情、购物清单、收藏、最近浏览,清单会从多个入口被写入

本章导读

这篇只讲购物清单里一件很具体的事:两个食材到底该不该合并。不要一上来就把它写成“购物清单架构设计”,那样太虚。真正会出问题的是这些小场景:用户从番茄炒蛋加入“鸡蛋 2 个”,又从蛋炒饭加入“土鸡蛋 3 个”;用户从红烧牛腩加入“西红柿 500g”,又手动加了一条“番茄 2 个”。你如果只看名字,合并会乱;你如果完全不合并,清单又会膨胀。

章节重点
为什么不能只按名称合并displayName是展示字段,不是稳定业务 key
ingredientKey怎么定同义词归一、分类辅助、来源保留
单位怎么处理克、个、勺、把不是都能直接相加
RDB 怎么留记录清单项和来源明细分表,方便回退和解释
ArkUI 怎么展示用户看到的是熟悉叫法,不是内部 key

验证环境和文件边界

项目说明
DevEco Studio6.x 系列
HarmonyOS SDKAPI 12 目标工程
UI 层ArkUI 声明式页面,Stage 模型
语言ArkTS
数据层@ohos.data.relationalStore、RDB、RdbPredicates
相关模块ShoppingListPageShoppingListViewModelShoppingRepositoryIngredientNormalizer

我的拆法是:页面只关心“这一行怎么展示、能不能勾选”,ViewModel 关心“用户点了哪个菜谱、清单状态怎么刷新”,Repository 关心“怎么合并和写入”,RDB 负责把合并后的主记录和来源明细存下来。

层级负责什么不应该做什么
ArkUI 页面展示清单、勾选、删除、编辑数量不直接判断番茄和西红柿是不是同一种
ViewModel调 Repository,刷新状态,屏蔽重复提交不拼 SQL,不算单位换算
Repository归一化、合并、事务写入不决定页面文案怎么排版
RDB存主清单项和来源明细不写业务兜底判断

先把展示名和合并键分开

一个常见错误是直接用displayName合并。这样最省事,但一到真实菜谱就露馅。

functionmergeByDisplayName(items:ShoppingItem[]):ShoppingItem[]{constmap=newMap<string,ShoppingItem>()for(constitemofitems){constold=map.get(item.displayName)if(!old){map.set(item.displayName,item)continue}old.amount+=item.amount}returnArray.from(map.values())}

这段代码的问题很直接:番茄西红柿合不起来,鸡蛋土鸡蛋又可能被你用别的规则合过头。展示名本来就是给用户看的,它可以口语化,可以保留菜谱原文,但它不适合作为唯一合并依据。

字段用途示例是否用于合并
displayName给用户看西红柿、番茄、土鸡蛋不作为唯一依据
ingredientKey判断同一种食材tomato、egg、soy_sauce
unitType判断单位能否相加weight、count、spoon
sourceRecipeId解释来源tomato-egg-001不合并,只留明细
amountText保留原始描述适量、少许、半勺不直接相加

ingredientKey 要可维护,不要猜

我不会让页面层临时判断“西红柿是不是番茄”。这类规则应该进入归一化模块,后面也方便从后台配置或本地词表更新。

constINGREDIENT_ALIAS:Record<string,string>={'西红柿':'tomato','番茄':'tomato','小番茄':'tomato','马铃薯':'potato','土豆':'potato','鸡蛋':'egg','土鸡蛋':'egg','生抽':'soy_sauce','酱油':'soy_sauce',}exportfunctionresolveIngredientKey(displayName:string):string{constname=displayName.trim()returnINGREDIENT_ALIAS[name]??name.toLowerCase()}

这里要注意一点:归一化不是越激进越好。比如“鸡肉”和“鸡胸肉”到底要不要合并,要看产品怎么展示。中式美食购物清单更偏实用,所以我会先把高频、低争议的词合并,争议词保留原样。

场景合并策略原因
番茄/西红柿合并用户基本认为是同一种
土豆/马铃薯合并常见别名
鸡蛋/土鸡蛋可合并但保留展示来源买菜时可能都归到蛋类,但来源要看得见
鸡肉/鸡胸肉默认不合并购买部位不同
生抽/酱油可根据词表合并家庭采购里经常混用,但要谨慎

单位不能硬相加

同一种食材也不代表数量一定能直接加。500g 番茄2 个番茄不是同一个单位体系,直接相加会很怪。

exporttypeUnitType='weight'|'count'|'spoon'|'bundle'|'text'exportinterfaceIngredientAmount{value?:numberunitText:stringunitType:UnitType rawText:string}exportfunctioncanMergeAmount(a:IngredientAmount,b:IngredientAmount):boolean{if(a.unitType==='text'||b.unitType==='text'){returnfalse}returna.unitType===b.unitType&&a.unitText===b.unitText}

我会把“能不能合并条目”和“数量能不能相加”分开。条目可以合并到同一个食材下,但数量不一定折算成一个数字。这样页面可以展示成“番茄:500g + 2 个”,比强行显示“502 个”靠谱得多。

输入ingredientKey数量处理页面展示
番茄 500g + 西红柿 300gtomato可相加番茄 800g
番茄 500g + 西红柿 2 个tomato不相加番茄 500g + 2 个
鸡蛋 2 个 + 土鸡蛋 3 个egg可相加但保留来源鸡蛋 5 个
葱 1 把 + 小葱 适量scallion不相加葱 1 把 + 适量

RDB 主表和来源表分开

如果只存一张清单表,后面用户想知道“这条番茄是哪些菜带进来的”,就查不出来了。我会拆成主清单项和来源明细。

constCREATE_SHOPPING_ITEM_TABLE=`CREATE TABLE IF NOT EXISTS shopping_item ( id TEXT PRIMARY KEY, ingredient_key TEXT NOT NULL, display_name TEXT NOT NULL, amount_summary TEXT, checked INTEGER DEFAULT 0, updated_at INTEGER NOT NULL )`constCREATE_SHOPPING_SOURCE_TABLE=`CREATE TABLE IF NOT EXISTS shopping_item_source ( id TEXT PRIMARY KEY, shopping_item_id TEXT NOT NULL, recipe_id TEXT, recipe_title TEXT, display_name TEXT NOT NULL, amount_value REAL, unit_text TEXT, unit_type TEXT, raw_amount_text TEXT, created_at INTEGER NOT NULL )`

主表负责列表展示和勾选,来源表负责解释和回放。比如用户删除某个菜谱带来的食材时,你可以根据来源表把这部分来源移掉,再重新计算主表摘要。

Repository 用事务写入

购物清单合并不是一条插入语句就完事。要先查有没有同ingredientKey的主项,再插来源,最后重新计算展示摘要。这里必须用事务,否则中间失败会留下半条清单。

exportclassShoppingRepository{constructor(privatereadonlystore:relationalStore.RdbStore){}asyncaddIngredient(input:AddIngredientInput):Promise<void>{constkey=resolveIngredientKey(input.displayName)constamount=parseAmount(input.amountText)awaitthis.store.beginTransaction()try{constitem=awaitthis.findOrCreateShoppingItem(key,input.displayName)awaitthis.insertSource(item.id,input,amount)constsources=awaitthis.querySources(item.id)constsummary=buildAmountSummary(sources)awaitthis.updateItemSummary(item.id,summary)awaitthis.store.commit()}catch(error){awaitthis.store.rollBack()throwerror}}}

这段代码里我最看重的是buildAmountSummary。它不应该乱折算单位,而是把能合的合,不能合的保留。

functionbuildAmountSummary(sources:ShoppingSource[]):string{constgroups=newMap<string,number>()constrawTexts:string[]=[]for(constsourceofsources){if(source.amountValue===undefined||source.unitType==='text'){rawTexts.push(source.rawAmountText)continue}constkey=`${source.unitType}:${source.unitText}`groups.set(key,(groups.get(key)??0)+source.amountValue)}constnumericParts=Array.from(groups.entries()).map(([key,value])=>{constunitText=key.split(':')[1]return`${value}${unitText}`})return[...numericParts,...rawTexts].join(' + ')}

ArkUI 展示要让用户看得懂

页面展示不要暴露ingredientKey。用户关心的是买什么、买多少、来自哪些菜。

@Componentstruct ShoppingItemRow{@Propitem:ShoppingListItemonCheckedChange:(id:string,checked:boolean)=>void=()=>{}build(){Row({space:12}){Checkbox().select(this.item.checked).onChange((checked:boolean)=>this.onCheckedChange(this.item.id,checked))Column({space:4}){Text(this.item.displayName).fontSize(17).fontWeight(FontWeight.Medium)Text(this.item.amountSummary||'适量').fontSize(13).fontColor('#777777')Text(this.item.sourceTitles.join('、')).fontSize(12).fontColor('#999999').maxLines(1).textOverflow({overflow:TextOverflow.Ellipsis})}.layoutWeight(1)}.padding({left:16,right:16,top:12,bottom:12})}}

这个展示方式有个好处:合并逻辑可以很严谨,但用户看到的是正常购物清单,不会觉得页面在跟他说数据库字段。

错误和边界怎么处理

这块不能偷懒,购物清单一旦合错,用户会直接失去信任。

边界处理方式原因
别名不确定不合并,保留原条目宁愿多一条,也不要买错
单位不可换算合并食材,不合并数量保护真实采购语义
来源删除先删来源,再重算主项避免主项残留旧数量
勾选状态主项维护,来源不维护用户勾的是最终采购项
手动改名更新展示名,不随便改 key防止历史来源全部错乱

再加一个查询方法,页面进入时只读主项,点详情再查来源,列表会轻很多。

asyncfunctionqueryShoppingItems(store:relationalStore.RdbStore):Promise<ShoppingListItem[]>{constpredicates=newrelationalStore.RdbPredicates('shopping_item')predicates.orderByAsc('checked').orderByDesc('updated_at')constresultSet=awaitstore.query(predicates,['id','ingredient_key','display_name','amount_summary','checked',])returnmapShoppingItems(resultSet)}

工程验收记录

这类功能不能只靠点两下页面,要用几组用例反复压。

用例操作预期
同义词合并加入“番茄 500g”和“西红柿 300g”清单只有一条番茄,数量 800g
单位不一致加入“番茄 500g”和“番茄 2 个”清单一条番茄,展示 500g + 2 个
不确定食材加入“鸡肉”和“鸡胸肉”默认两条,不强行合并
来源删除删除某个菜谱来源主项重新计算数量,空来源时删除主项
勾选持久化勾选后退出再进入checked 状态从 RDB 恢复

这套验收过了,购物清单才算能放心继续扩展。后面无论是做云同步、菜谱推荐,还是做“按超市分区排序”,都不会被基础合并逻辑拖住。

本章小结

购物清单的合并逻辑不要从 UI 开始写,也不要只靠显示名。中式美食这里的核心做法是:displayName给用户看,ingredientKey判断是否同一种食材,单位策略决定数量能不能相加,RDB 里保留来源明细,ArkUI 页面只展示用户能理解的结果。这样做比简单数组去重要多几步,但后面不会越改越乱。

最后留一个开发时真会遇到的问题:你们的购物清单里,“鸡蛋”和“土鸡蛋”会合并吗?我现在倾向于默认合并到蛋类,但在来源里保留原始名字,这样买菜时不至于漏项,回头查来源也能说清楚。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询