ArkTS 购物清单食材重复怎么办:中式美食 ingredientKey、单位和展示名怎么分开合并
购物清单里出现两条“番茄”和一条“西红柿”用户大概率不会觉得这是三种东西但如果你直接按显示名称合并又可能把“鸡蛋”和“土鸡蛋”合得太粗。中式美食做购物清单时我更愿意把这件事拆清楚给用户看的名字是一回事用来合并的 key 是另一回事单位能不能相加又是第三回事。拆开以后页面不会乱RDB 也能查得明白。项目说明应用名称中式美食这篇解决什么多个菜谱加入购物清单后同一个食材因为叫法或单位不同导致重复、误合并谁会搜这篇做 HarmonyOS 菜谱、购物清单、本地持久化、RDB 合并逻辑的 ArkTS 开发者点进来能拿到什么ingredientKey设计、单位策略、来源记录、Repository 合并和 ArkUI 展示方式当前项目证据中式美食有菜谱详情、购物清单、收藏、最近浏览清单会从多个入口被写入本章导读这篇只讲购物清单里一件很具体的事两个食材到底该不该合并。不要一上来就把它写成“购物清单架构设计”那样太虚。真正会出问题的是这些小场景用户从番茄炒蛋加入“鸡蛋 2 个”又从蛋炒饭加入“土鸡蛋 3 个”用户从红烧牛腩加入“西红柿 500g”又手动加了一条“番茄 2 个”。你如果只看名字合并会乱你如果完全不合并清单又会膨胀。章节重点为什么不能只按名称合并displayName是展示字段不是稳定业务 keyingredientKey怎么定同义词归一、分类辅助、来源保留单位怎么处理克、个、勺、把不是都能直接相加RDB 怎么留记录清单项和来源明细分表方便回退和解释ArkUI 怎么展示用户看到的是熟悉叫法不是内部 key验证环境和文件边界项目说明DevEco Studio6.x 系列HarmonyOS SDKAPI 12 目标工程UI 层ArkUI 声明式页面Stage 模型语言ArkTS数据层ohos.data.relationalStore、RDB、RdbPredicates相关模块ShoppingListPage、ShoppingListViewModel、ShoppingRepository、IngredientNormalizer我的拆法是页面只关心“这一行怎么展示、能不能勾选”ViewModel 关心“用户点了哪个菜谱、清单状态怎么刷新”Repository 关心“怎么合并和写入”RDB 负责把合并后的主记录和来源明细存下来。层级负责什么不应该做什么ArkUI 页面展示清单、勾选、删除、编辑数量不直接判断番茄和西红柿是不是同一种ViewModel调 Repository刷新状态屏蔽重复提交不拼 SQL不算单位换算Repository归一化、合并、事务写入不决定页面文案怎么排版RDB存主清单项和来源明细不写业务兜底判断先把展示名和合并键分开一个常见错误是直接用displayName合并。这样最省事但一到真实菜谱就露馅。functionmergeByDisplayName(items:ShoppingItem[]):ShoppingItem[]{constmapnewMapstring,ShoppingItem()for(constitemofitems){constoldmap.get(item.displayName)if(!old){map.set(item.displayName,item)continue}old.amountitem.amount}returnArray.from(map.values())}这段代码的问题很直接番茄和西红柿合不起来鸡蛋和土鸡蛋又可能被你用别的规则合过头。展示名本来就是给用户看的它可以口语化可以保留菜谱原文但它不适合作为唯一合并依据。字段用途示例是否用于合并displayName给用户看西红柿、番茄、土鸡蛋不作为唯一依据ingredientKey判断同一种食材tomato、egg、soy_sauce是unitType判断单位能否相加weight、count、spoon是sourceRecipeId解释来源tomato-egg-001不合并只留明细amountText保留原始描述适量、少许、半勺不直接相加ingredientKey 要可维护不要猜我不会让页面层临时判断“西红柿是不是番茄”。这类规则应该进入归一化模块后面也方便从后台配置或本地词表更新。constINGREDIENT_ALIAS:Recordstring,string{西红柿:tomato,番茄:tomato,小番茄:tomato,马铃薯:potato,土豆:potato,鸡蛋:egg,土鸡蛋:egg,生抽:soy_sauce,酱油:soy_sauce,}exportfunctionresolveIngredientKey(displayName:string):string{constnamedisplayName.trim()returnINGREDIENT_ALIAS[name]??name.toLowerCase()}这里要注意一点归一化不是越激进越好。比如“鸡肉”和“鸡胸肉”到底要不要合并要看产品怎么展示。中式美食购物清单更偏实用所以我会先把高频、低争议的词合并争议词保留原样。场景合并策略原因番茄/西红柿合并用户基本认为是同一种土豆/马铃薯合并常见别名鸡蛋/土鸡蛋可合并但保留展示来源买菜时可能都归到蛋类但来源要看得见鸡肉/鸡胸肉默认不合并购买部位不同生抽/酱油可根据词表合并家庭采购里经常混用但要谨慎单位不能硬相加同一种食材也不代表数量一定能直接加。500g 番茄和2 个番茄不是同一个单位体系直接相加会很怪。exporttypeUnitTypeweight|count|spoon|bundle|textexportinterfaceIngredientAmount{value?:numberunitText:stringunitType:UnitType rawText:string}exportfunctioncanMergeAmount(a:IngredientAmount,b:IngredientAmount):boolean{if(a.unitTypetext||b.unitTypetext){returnfalse}returna.unitTypeb.unitTypea.unitTextb.unitText}我会把“能不能合并条目”和“数量能不能相加”分开。条目可以合并到同一个食材下但数量不一定折算成一个数字。这样页面可以展示成“番茄500g 2 个”比强行显示“502 个”靠谱得多。输入ingredientKey数量处理页面展示番茄 500g 西红柿 300gtomato可相加番茄 800g番茄 500g 西红柿 2 个tomato不相加番茄 500g 2 个鸡蛋 2 个 土鸡蛋 3 个egg可相加但保留来源鸡蛋 5 个葱 1 把 小葱 适量scallion不相加葱 1 把 适量RDB 主表和来源表分开如果只存一张清单表后面用户想知道“这条番茄是哪些菜带进来的”就查不出来了。我会拆成主清单项和来源明细。constCREATE_SHOPPING_ITEM_TABLECREATE 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_TABLECREATE 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):Promisevoid{constkeyresolveIngredientKey(input.displayName)constamountparseAmount(input.amountText)awaitthis.store.beginTransaction()try{constitemawaitthis.findOrCreateShoppingItem(key,input.displayName)awaitthis.insertSource(item.id,input,amount)constsourcesawaitthis.querySources(item.id)constsummarybuildAmountSummary(sources)awaitthis.updateItemSummary(item.id,summary)awaitthis.store.commit()}catch(error){awaitthis.store.rollBack()throwerror}}}这段代码里我最看重的是buildAmountSummary。它不应该乱折算单位而是把能合的合不能合的保留。functionbuildAmountSummary(sources:ShoppingSource[]):string{constgroupsnewMapstring,number()constrawTexts:string[][]for(constsourceofsources){if(source.amountValueundefined||source.unitTypetext){rawTexts.push(source.rawAmountText)continue}constkey${source.unitType}:${source.unitText}groups.set(key,(groups.get(key)??0)source.amountValue)}constnumericPartsArray.from(groups.entries()).map(([key,value]){constunitTextkey.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):PromiseShoppingListItem[]{constpredicatesnewrelationalStore.RdbPredicates(shopping_item)predicates.orderByAsc(checked).orderByDesc(updated_at)constresultSetawaitstore.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 页面只展示用户能理解的结果。这样做比简单数组去重要多几步但后面不会越改越乱。最后留一个开发时真会遇到的问题你们的购物清单里“鸡蛋”和“土鸡蛋”会合并吗我现在倾向于默认合并到蛋类但在来源里保留原始名字这样买菜时不至于漏项回头查来源也能说清楚。