购物清单里出现两条“番茄”和一条“西红柿”,用户大概率不会觉得这是三种东西;但如果你直接按显示名称合并,又可能把“鸡蛋”和“土鸡蛋”合得太粗。中式美食做购物清单时,我更愿意把这件事拆清楚:给用户看的名字是一回事,用来合并的 key 是另一回事,单位能不能相加又是第三回事。拆开以后,页面不会乱,RDB 也能查得明白。
| 项目 | 说明 |
|---|---|
| 应用名称 | 中式美食 |
| 这篇解决什么 | 多个菜谱加入购物清单后,同一个食材因为叫法或单位不同导致重复、误合并 |
| 谁会搜这篇 | 做 HarmonyOS 菜谱、购物清单、本地持久化、RDB 合并逻辑的 ArkTS 开发者 |
| 点进来能拿到什么 | ingredientKey设计、单位策略、来源记录、Repository 合并和 ArkUI 展示方式 |
| 当前项目证据 | 中式美食有菜谱详情、购物清单、收藏、最近浏览,清单会从多个入口被写入 |
本章导读
这篇只讲购物清单里一件很具体的事:两个食材到底该不该合并。不要一上来就把它写成“购物清单架构设计”,那样太虚。真正会出问题的是这些小场景:用户从番茄炒蛋加入“鸡蛋 2 个”,又从蛋炒饭加入“土鸡蛋 3 个”;用户从红烧牛腩加入“西红柿 500g”,又手动加了一条“番茄 2 个”。你如果只看名字,合并会乱;你如果完全不合并,清单又会膨胀。
| 章节 | 重点 |
|---|---|
| 为什么不能只按名称合并 | displayName是展示字段,不是稳定业务 key |
ingredientKey怎么定 | 同义词归一、分类辅助、来源保留 |
| 单位怎么处理 | 克、个、勺、把不是都能直接相加 |
| RDB 怎么留记录 | 清单项和来源明细分表,方便回退和解释 |
| ArkUI 怎么展示 | 用户看到的是熟悉叫法,不是内部 key |
验证环境和文件边界
| 项目 | 说明 |
|---|---|
| DevEco Studio | 6.x 系列 |
| HarmonyOS SDK | API 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[]{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 + 西红柿 300g | tomato | 可相加 | 番茄 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 页面只展示用户能理解的结果。这样做比简单数组去重要多几步,但后面不会越改越乱。
最后留一个开发时真会遇到的问题:你们的购物清单里,“鸡蛋”和“土鸡蛋”会合并吗?我现在倾向于默认合并到蛋类,但在来源里保留原始名字,这样买菜时不至于漏项,回头查来源也能说清楚。