ArkUI 搜索不到同义词食材怎么办:中式美食番茄、西红柿和土豆别名怎么统一命中
用户搜“番茄”结果页空了换成“西红柿”又能出来。用户搜“土豆”菜谱里却写着“马铃薯”。这种问题在菜谱、买菜清单、食材库里很常见表面看是搜索体验不好实际是数据写入时没有把“展示名”和“查询名”分开。中式美食后面做 HarmonyOS 版本时搜索框不能只把用户输入原样丢给列表过滤而要先把食材名归一化再让 RDB 按稳定字段查。应用名称中式美食这篇解决什么用户输入常用食材名但菜谱或购物清单里用了另一个叫法导致搜索不到谁会搜这篇做 HarmonyOS 菜谱、买菜清单、食材库、本地搜索的开发者点进来能拿到什么同义词字典、归一化字段、RDB 索引、Repository 查询和验收用例当前项目证据SearchViewModel、ShoppingListState、RecipeRepository、购物清单勾选状态HarmonyOS 目标能力ArkUI、ArkTS、relationalStore、RdbPredicates、CREATE INDEX本章导读这篇不讨论“大搜索架构”只处理一个开发时马上会遇到的小坑同一个食材有多个叫法时怎么让用户用任何一个常见叫法都能搜到结果。中式美食现在已经有搜索历史、热门词、搜索结果状态也有购物清单和菜谱仓储边界。后面迁到 HarmonyOS 时应该把这个坑提前收掉。章节重点问题从哪里来为什么只在页面里contains(keyword)会漏结果字段怎么拆displayName给用户看normalizedName给查询用RDB 怎么建哪些字段值得建索引哪些字段先不要动Repository 怎么写页面不直接碰 RDB只拿已经整理好的结果ArkUI 怎么验收搜索、清空、返回、购物清单合并都要能复现当前验证环境和技术栈项目说明DevEco Studio6.x 系列HarmonyOS SDKAPI 12/ArkTS 工程UI 层ArkUI 声明式页面数据层ohos.data.relationalStore、RDB、RdbPredicates当前项目参考中式美食 Flutter 模块中的搜索、购物清单、菜谱仓储边界验收方式固定几组食材别名验证搜索页、菜谱详情和购物清单结果一致当前中式美食项目里搜索 ViewModel 已经有三个状态历史词、热门词、搜索结果。这个拆法是对的因为页面不用猜现在应该显示什么。但如果下一步只是把keyword直接拿去查菜谱标题就会漏掉食材别名。classSearchViewModel{SearchViewModel(this._repository);finalSearchRepository_repository;SearchStatestateconstSearchState();Futurevoidsearch(Stringkeyword,{int page1})async{statestate.copyWith(keyword:keyword,results:constUiState.loading());try{finalresultawait_repository.search(keyword,page:page);statestate.copyWith(results:result.items.isEmpty?constUiState.empty(没有找到相关菜谱):UiState.success(result),);}catch(error){statestate.copyWith(results:UiState.error(error.toString()));}}}这段代码暴露出一个很实际的问题keyword进入 Repository 之前还没有被整理。用户输入“番茄”数据里如果存的是“西红柿炒蛋”那就要看 Repository 有没有能力把两者对上。先别急着做全文搜索先把名字拆清楚菜谱类应用里食材名至少有两种用途一种是给用户看的另一种是给查询、合并、排序用的。混在一起写前期省事后期很难收。字段给谁用例子要不要参与查询displayName用户看西红柿、土豆、鸡蛋可以展示不作为唯一查询字段normalizedName查询和合并番茄、土豆、鸡蛋应该建索引aliasText辅助匹配西红柿/番茄/圣女果可选看数据量category筛选蔬菜、肉类、调味料高频筛选时建索引recipeId回到菜谱tomato-egg-001必须稳定我会把归一化放在写入和搜索入口两边都做一遍。写入时保证数据干净搜索时保证用户输入也走同一套规则。constingredientAlias:Recordstring,string{西红柿:番茄,小番茄:番茄,圣女果:番茄,马铃薯:土豆,洋芋:土豆,土鸡蛋:鸡蛋,姜葱蒜:葱姜蒜};exportfunctionnormalizeIngredientName(raw:string):string{constvalueraw.trim();if(!value){return;}returningredientAlias[value]??value;}这里有个取舍别一开始就搞很复杂的 NLP。中式美食现阶段更需要一套可控、可验收、可维护的别名字典。等真实搜索词积累多了再把高频失败词补进去。RDB 表结构要为“搜得到”服务如果只存一个name字段后面一定会纠结它到底是展示字段还是查询字段我更倾向于一开始就拆开。constCREATE_RECIPE_INGREDIENT_TABLECREATE TABLE IF NOT EXISTS recipe_ingredient ( id TEXT PRIMARY KEY, recipe_id TEXT NOT NULL, display_name TEXT NOT NULL, normalized_name TEXT NOT NULL, category TEXT, amount_text TEXT, updated_at INTEGER NOT NULL );constCREATE_SHOPPING_ITEM_TABLECREATE TABLE IF NOT EXISTS shopping_item ( id TEXT PRIMARY KEY, plan_id TEXT NOT NULL, recipe_id TEXT, display_name TEXT NOT NULL, normalized_name TEXT NOT NULL, category TEXT, checked INTEGER DEFAULT 0, updated_at INTEGER NOT NULL );这两张表都保留display_name和normalized_name原因很简单菜谱详情里要尊重原始叫法搜索和购物清单合并时要用稳定叫法。场景用哪个字段为什么菜谱详情展示display_name用户看到的是自然菜谱文案搜索食材normalized_name同义词要命中同一个标准名购物清单合并normalized_name西红柿和番茄不能分成两条分类筛选category让肉类、蔬菜、调料能分组返回菜谱recipe_id搜索结果要能跳回详情页索引只给高频查询不要见字段就建别名搜索最常用的是normalized_name购物清单还会按plan_id和checked查。这个时候索引应该跟着查询方式来。constCREATE_INGREDIENT_INDEXES[CREATE INDEX IF NOT EXISTS idx_ingredient_normalized ON recipe_ingredient(normalized_name),CREATE INDEX IF NOT EXISTS idx_ingredient_recipe ON recipe_ingredient(recipe_id),CREATE INDEX IF NOT EXISTS idx_shopping_plan_checked ON shopping_item(plan_id, checked, updated_at DESC),CREATE INDEX IF NOT EXISTS idx_shopping_normalized ON shopping_item(normalized_name)];我不会一开始就给amount_text、display_name、updated_at全部建索引。索引不是越多越好写入、迁移、调试都会变重。中式美食这种本地应用先让核心查询稳定后面根据真实数据再补。查询推荐索引说明搜番茄相关菜谱normalized_name解决番茄/西红柿命中查某个菜谱食材recipe_id详情页和购物清单都要用查某个计划未买食材plan_id checked updated_at购物清单高频入口按分类看食材category只有分类筛选变高频时再加按展示名模糊搜暂不作为主索引展示名不稳定容易误伤Repository 负责把用户输入变成查询条件ArkUI 页面不应该知道“西红柿等于番茄”。页面只负责拿用户输入ViewModel 负责触发动作Repository 负责把输入转成数据层能懂的条件。importrelationalStorefromohos.data.relationalStore;exportinterfaceIngredientSearchHit{recipeId:string;displayName:string;normalizedName:string;category:string;}exportclassIngredientSearchRepository{constructor(privatereadonlystore:relationalStore.RdbStore){}asyncsearchIngredient(keyword:string):PromiseIngredientSearchHit[]{constnormalizednormalizeIngredientName(keyword);if(!normalized){return[];}constpredicatesnewrelationalStore.RdbPredicates(recipe_ingredient);predicates.equalTo(normalized_name,normalized);predicates.orderByDesc(updated_at);constresultSetawaitthis.store.query(predicates,[recipe_id,display_name,normalized_name,category]);consthits:IngredientSearchHit[][];while(resultSet.goToNextRow()){hits.push({recipeId:resultSet.getString(resultSet.getColumnIndex(recipe_id)),displayName:resultSet.getString(resultSet.getColumnIndex(display_name)),normalizedName:resultSet.getString(resultSet.getColumnIndex(normalized_name)),category:resultSet.getString(resultSet.getColumnIndex(category)),});}resultSet.close();returnhits;}}这段代码的重点不是 API 多复杂而是边界清楚页面输入“西红柿”Repository 先转成“番茄”再查normalized_name。这样同一套逻辑可以同时服务搜索页、购物清单页和推荐页。ArkUI 页面只关心三种结果页面不用知道具体查了哪张表。它只需要知道当前是加载中、空结果、还是有结果。中式美食的搜索页后面可以继续保留历史词和热门词但结果列表要从统一状态里拿。ObservedexportclassSearchAliasViewModel{keyword:string;loading:booleanfalse;emptyText:string;hits:IngredientSearchHit[][];constructor(privatereadonlyrepository:IngredientSearchRepository){}asyncsearch(keyword:string){this.keywordkeyword;this.loadingtrue;this.emptyText;try{constnextawaitthis.repository.searchIngredient(keyword);this.hitsnext;this.emptyTextnext.length0?没有找到相关食材可以换个叫法试试:;}finally{this.loadingfalse;}}}页面状态越少问题越好查。不要让组件自己去判断“是不是同义词”“是不是要展示空结果”“是不是要从购物清单再查一遍”。这些都应该在 ViewModel 和 Repository 之间处理掉。页面状态用户看到什么开发检查什么loading搜索中或骨架占位旧结果是否被错误清空empty明确告诉用户没找到输入是否先归一化过ready食材和菜谱结果recipeId是否能跳详情error可重试提示RDB 异常是否被吞掉cleared回到热门词/历史词清空后是否还有残留过滤条件购物清单也要吃这套规则搜索不是唯一入口。购物清单更容易暴露这个问题两个菜谱分别用了“西红柿”和“番茄”买菜时如果出现两条用户会觉得应用很不聪明。exportfunctionmergeShoppingItems(items:ShoppingItemEntity[]):ShoppingItemEntity[]{constmapnewMapstring,ShoppingItemEntity();for(constitemofitems){constkeynormalizeIngredientName(item.displayName);constexistedmap.get(key);if(!existed){map.set(key,{...item,normalizedName:key});continue;}map.set(key,{...existed,amountText:[existed.amountText,item.amountText].filter(Boolean).join( / ),recipeId:existed.recipeId,updatedAt:Math.max(existed.updatedAt,item.updatedAt)});}returnArray.from(map.values());}这里不要把合并逻辑写在 ArkUI 列表里。页面只负责渲染合并后的结果避免每个入口都重复写一套“西红柿和番茄算同一个”的判断。失败和边界要提前写进验收同义词搜索最容易出现“看起来能搜其实漏了一半”的情况。验收时不能只搜一个正常词要专门准备几组容易出错的词。验收项操作通过标准同义词命中搜“番茄”能命中displayName西红柿的食材反向命中搜“西红柿”和“番茄”返回同一批核心结果购物清单合并两个菜谱分别加入番茄/西红柿清单里只出现一条归一化食材空输入输入空格后搜索不查库回到历史词或热门词清空搜索点击清空按钮结果、关键词、空文案都回到默认态返回详情从结果点进菜谱再返回搜索词和结果列表不丢asyncfunctionverifyAliasSearch(repository:IngredientSearchRepository){consttomatoawaitrepository.searchIngredient(番茄);constxihongshiawaitrepository.searchIngredient(西红柿);if(tomato.length0||xihongshi.length0){thrownewError(番茄/西红柿至少有一组没有命中);}consttomatoIdstomato.map(itemitem.recipeId).sort().join(,);constxihongshiIdsxihongshi.map(itemitem.recipeId).sort().join(,);if(tomatoIds!xihongshiIds){thrownewError(同义词返回结果不一致);}}这类验收比“页面能打开”更有用。页面能打开只能说明 UI 没崩番茄和西红柿返回一致才说明搜索链路真的收住了。工程验收验收点检查方式结论标准字段边界看表结构展示名和查询名分开查询边界看 Repository页面不直接拼 RDB 查询索引边界看建表脚本高频查询字段有索引低频字段不乱建状态边界看 ViewModelloading、empty、ready、error 分清用户路径真实操作搜索、清空、返回搜索词和结果不乱跳购物清单加入同义食材不重复显示相同食材常见问题和取舍问题我的处理要不要直接用模糊搜索可以作为补充但不能替代归一化字段要不要一开始接 AI 识别同义词先不要业务字典更可控也更容易验收别名字典放哪里早期可以放本地常量后期可以进 RDB 或配置表用户输入错别字怎么办那是下一层容错不要和同义词归一化混在一起为什么不把所有字段都建索引写入和迁移会变重先服务高频入口本章小结中式美食的搜索问题不只是“能不能搜到菜名”。真正影响用户的是他用自己的叫法输入食材应用能不能理解。番茄、西红柿、圣女果土豆、马铃薯、洋芋这些词如果不提前收口后面购物清单、推荐、搜索历史都会跟着乱。我的做法很直接展示名给用户看归一化名给查询和合并用ArkUI 页面只负责展示状态ViewModel 负责触发搜索Repository 负责把用户输入转成 RDB 查询。这样写看起来多了一层但后面加搜索历史、热门词、购物清单合并、详情页返回时不会每个页面都补一堆临时判断。如果你也在做菜谱、清单、内容库或本地工具类 HarmonyOS 应用可以先挑三组用户最可能输入的别名写进验收用例。只要这些词能稳定命中搜索体验就会比单纯做一个漂亮输入框更可靠。

相关新闻