当前位置: 首页 > news >正文

ArkUI 搜索空结果怎么办:中式美食搜不到菜时怎么给同义词、推荐词和清空入口

用户搜“番茄炒蛋”没有结果,页面只显示一句“暂无数据”,这事看起来很小,但实际会直接影响用户对搜索页的信任。中式美食做搜索页时,我不想把空状态当成一个文案问题处理,因为真正的问题在前面:用户输的词有没有被归一化,Repository 有没有把同义词查进去,ViewModel 有没有保存这次查询的原因,ArkUI 页面有没有给用户一个能继续点的入口。

项目说明
应用名称中式美食
这篇解决什么搜索页没有结果时,不只是显示空文案,而是给同义词、推荐词和清空入口
谁会搜这篇做 HarmonyOS 菜谱、食材库、购物清单、本地搜索的 ArkUI/ArkTS 开发者
点进来能拿到什么空结果状态模型、Repository 返回结构、RDB 查询边界、ArkUI 空态组件和验收用例
当前项目证据中式美食已经有搜索、分类、购物清单、收藏和最近浏览这些会互相影响的入口

本章导读

这篇不讲大而全的搜索系统,只拆一个很容易被忽略的点:搜不到结果时,页面到底该怎么收口。很多 App 的搜索页只做了“输入 -> 查询 -> 列表”,一旦没有结果,就把锅丢给空状态文案。我的处理方式是把空结果也当成一种业务结果:它要知道用户搜了什么、系统帮他试了哪些同义词、下一步能点什么。

章节重点
问题从哪里来为什么contains(keyword)做本地过滤很容易让用户搜不到
状态怎么拆querynormalizedQueryemptyReasonsuggestions要分开
Repository 怎么回不直接丢数组,返回一个带解释的SearchResult
ArkUI 怎么展示空态里放建议词、热门食材、清空入口,而不是只放一句话
怎么验收用番茄/西红柿、土豆/马铃薯、乱输词三组用例压一遍

验证环境和文件边界

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

我会把搜索页分成四层,页面不要直接判断数据库字段,Repository 也不要知道具体 UI 怎么摆。这样后面你把“同义词兜底”换成“历史搜索兜底”或者“热门菜兜底”,不用把页面拆一遍。

层级负责什么不应该做什么
ArkUI 页面展示输入框、列表、空态、建议词按钮不直接拼 RDB 查询条件
ViewModel保存查询状态、发起搜索、屏蔽旧结果回写不关心 SQL 表结构
Repository组织同义词、分类和菜谱查询不拼 UI 文案
RDB提供稳定字段和索引不承担页面兜底逻辑

先把空结果当成一种结果

很多代码会这样写:查到数组就展示列表,数组为空就展示空文案。这个写法能跑,但用户不知道自己该怎么继续。

typeSearchState={query:stringloading:booleanrecipes:RecipeCard[]error?:string}asyncfunctionsearch(keyword:string){state.query=keyword state.loading=trueconstrecipes=awaitrecipeRepository.searchByKeyword(keyword)state.recipes=recipes state.loading=false}

这段代码的问题不是短,而是信息太少。recipes.length === 0只能说明“当前查询没有结果”,不能说明为什么没有结果。用户到底是拼错了、搜了别名、当前分类太窄,还是网络/本地库异常,页面完全不知道。

空结果原因用户看到什么页面应该给什么
输入词是别名搜“番茄”,库里写“西红柿”提示“试试西红柿”并给一键搜索
分类条件太窄在“汤羹”里搜“红烧肉”给“清空分类”入口
本地库没有该菜搜很冷门的菜名给热门食材或最近浏览入口
查询失败RDB 异常或数据损坏给重试入口和错误说明

状态模型要多留几个字段

中式美食搜索页我会把状态拆得细一点。这里不是为了炫技,而是为了让空状态有依据。

exporttypeEmptyReason=|'none'|'aliasMatchedButNoRecipe'|'categoryTooNarrow'|'noLocalRecipe'|'queryFailed'exportinterfaceSearchState{query:stringnormalizedQuery:stringactiveCategoryId?:stringloading:booleanrecipes:RecipeCard[]suggestions:SearchSuggestion[]emptyReason:EmptyReason errorMessage?:string}

这个状态里最关键的是normalizedQueryemptyReason。用户输入“番茄”,归一化后可能是“西红柿”;用户输入“马铃薯”,归一化后可能是“土豆”。如果最后还是没有结果,页面至少可以告诉用户:“我已经帮你试了常见叫法,但当前分类下没有菜。”

字段给谁用为什么要留
query页面输入框保留用户原始输入,不能随便改掉
normalizedQueryRepository/RDB查询用稳定词,避免别名漏查
activeCategoryId分类筛选判断是不是分类条件挡住结果
suggestions空态和搜索联想给用户下一步可点的词
emptyReason空态组件决定展示哪种解释和按钮

Repository 不要只返回数组

如果 Repository 只返回RecipeCard[],ViewModel 就只能猜。更稳的做法是返回一个带上下文的结果对象。

exportinterfaceSearchResult{query:stringnormalizedQuery:stringrecipes:RecipeCard[]suggestions:SearchSuggestion[]emptyReason:EmptyReason}exportclassRecipeSearchRepository{constructor(privatereadonlyrecipeStore:RecipeStore,privatereadonlyaliasRepository:IngredientAliasRepository,){}asyncsearch(query:string,categoryId?:string):Promise<SearchResult>{constnormalizedQuery=awaitthis.aliasRepository.normalize(query)constrecipes=awaitthis.recipeStore.queryRecipes(normalizedQuery,categoryId)if(recipes.length>0){return{query,normalizedQuery,recipes,suggestions:[],emptyReason:'none'}}constsuggestions=awaitthis.aliasRepository.suggest(query,normalizedQuery)constemptyReason=categoryId?'categoryTooNarrow':this.resolveEmptyReason(suggestions)return{query,normalizedQuery,recipes,suggestions,emptyReason}}privateresolveEmptyReason(suggestions:SearchSuggestion[]):EmptyReason{returnsuggestions.length>0?'aliasMatchedButNoRecipe':'noLocalRecipe'}}

这段代码有一个边界很重要:Repository 可以告诉页面“为什么没有”,但不要替页面决定“怎么展示”。展示是 ArkUI 的事,Repository 只给足数据。

RDB 查询先做稳定字段

菜谱类应用不要只靠标题模糊匹配。中式美食里,食材、菜名、分类、推荐标签会同时影响搜索结果,最容易出问题的是同义词。

constCREATE_RECIPE_TABLE=`CREATE TABLE IF NOT EXISTS recipe ( id TEXT PRIMARY KEY, title TEXT NOT NULL, title_keyword TEXT NOT NULL, category_id TEXT, ingredient_keywords TEXT, updated_at INTEGER NOT NULL )`constCREATE_ALIAS_TABLE=`CREATE TABLE IF NOT EXISTS ingredient_alias ( alias TEXT PRIMARY KEY, normalized_name TEXT NOT NULL, hit_count INTEGER DEFAULT 0, updated_at INTEGER NOT NULL )`constCREATE_RECIPE_TITLE_INDEX='CREATE INDEX IF NOT EXISTS idx_recipe_title_keyword ON recipe(title_keyword)'constCREATE_ALIAS_INDEX='CREATE INDEX IF NOT EXISTS idx_alias_normalized ON ingredient_alias(normalized_name)'

这里的title_keywordingredient_keywords是为了查询稳定,不是给用户看的展示名。展示名该叫“西红柿炒鸡蛋”就叫“西红柿炒鸡蛋”,查询时可以让“番茄炒蛋”也能落到同一批候选上。

查询字段适合做什么不适合做什么
title展示给用户当唯一查询字段
title_keyword标题归一化查询展示原文
ingredient_keywords食材命中、推荐兜底直接给用户看
category_id分类收窄判断搜索意图
alias.normalized_name同义词归一替代完整搜索引擎

查询代码可以先保持朴素,但边界要清楚。

asyncfunctionqueryRecipes(store:relationalStore.RdbStore,normalizedQuery:string,categoryId?:string,):Promise<RecipeCard[]>{constpredicates=newrelationalStore.RdbPredicates('recipe')predicates.beginWrap().like('title_keyword',`%${normalizedQuery}%`).or().like('ingredient_keywords',`%${normalizedQuery}%`).endWrap()if(categoryId){predicates.equalTo('category_id',categoryId)}predicates.orderByDesc('updated_at').limitAs(20)constresultSet=awaitstore.query(predicates,['id','title','category_id'])returnmapRecipeCards(resultSet)}

ViewModel 要挡住旧结果回写

搜索框输入很快时,空结果也可能被旧请求覆盖。比如用户先输入“番”,马上改成“番茄炒蛋”,前一个请求慢一点回来,就把后一个结果覆盖成空态。这个问题我之前在搜索请求里已经遇到过,所以这里继续用版本号挡一下。

@ObservedexportclassSearchViewModel{state:SearchState={query:'',normalizedQuery:'',loading:false,recipes:[],suggestions:[],emptyReason:'none',}privaterequestVersion=0constructor(privatereadonlyrepository:RecipeSearchRepository){}asyncsearch(query:string,categoryId?:string):Promise<void>{constversion=++this.requestVersionthis.state={...this.state,query,loading:true,errorMessage:undefined}try{constresult=awaitthis.repository.search(query,categoryId)if(version!==this.requestVersion){return}this.state={...this.state,normalizedQuery:result.normalizedQuery,recipes:result.recipes,suggestions:result.suggestions,emptyReason:result.emptyReason,loading:false,}}catch(error){if(version!==this.requestVersion){return}this.state={...this.state,loading:false,emptyReason:'queryFailed',errorMessage:String(error)}}}clearSearch():void{this.requestVersion++this.state={...this.state,query:'',normalizedQuery:'',recipes:[],suggestions:[],emptyReason:'none'}}}

这段代码不是为了把 ViewModel 写复杂,而是为了避免一个很难解释的体验:明明输入框里是新词,列表却显示上一次的空结果。

ArkUI 空态别只写一句话

页面层要做的事很简单:有结果展示列表,加载中展示骨架,没结果时根据emptyReason给不同入口。

@Componentstruct SearchEmptyView{@Propquery:string@PropnormalizedQuery:string@Propreason:EmptyReason@Propsuggestions:SearchSuggestion[]onSearchSuggestion:(keyword:string)=>void=()=>{}onClear:()=>void=()=>{}build(){Column({space:14}){Text(this.buildTitle()).fontSize(18).fontWeight(FontWeight.Bold)Text(this.buildDescription()).fontSize(14).fontColor('#666666')if(this.suggestions.length>0){Row({space:8}){ForEach(this.suggestions,(item:SearchSuggestion)=>{Button(item.label).height(34).onClick(()=>this.onSearchSuggestion(item.keyword))},(item:SearchSuggestion)=>item.keyword)}}Button('清空条件,回到默认推荐').type(ButtonType.Capsule).onClick(()=>this.onClear())}.padding(20).width('100%')}privatebuildTitle():string{returnthis.reason==='categoryTooNarrow'?'当前分类里没找到':'还没找到这道菜'}privatebuildDescription():string{returnthis.normalizedQuery&&this.normalizedQuery!==this.query?`已尝试按“${this.normalizedQuery}”查找,可以换个推荐词再试。`:'可以换个食材名,或者先回到默认推荐列表。'}}

这里有两个细节:第一,ForEach的 key 用keyword,不要用 index;第二,清空按钮必须真的清掉 query、分类和分页状态,否则用户点了也回不到默认列表。

页面组合时把三种状态摆清楚

搜索页主体不要把所有判断塞在一个if里。加载、成功、空态、错误最好分开写,后面改样式和埋点都方便。

@Componentstruct RecipeSearchPage{@Statekeyword:string=''privateviewModel:SearchViewModel=newSearchViewModel(recipeSearchRepository)build(){Column(){SearchBar({value:this.keyword,onChange:(value:string)=>{this.keyword=valuethis.viewModel.search(value)},})if(this.viewModel.state.loading){SearchSkeleton()}elseif(this.viewModel.state.errorMessage){SearchErrorView({message:this.viewModel.state.errorMessage})}elseif(this.viewModel.state.recipes.length>0){List(){ForEach(this.viewModel.state.recipes,(recipe:RecipeCard)=>{RecipeListItem({recipe})},(recipe:RecipeCard)=>recipe.id)}}elseif(this.viewModel.state.query.length>0){SearchEmptyView({query:this.viewModel.state.query,normalizedQuery:this.viewModel.state.normalizedQuery,reason:this.viewModel.state.emptyReason,suggestions:this.viewModel.state.suggestions,onSearchSuggestion:(keyword:string)=>this.viewModel.search(keyword),onClear:()=>{this.keyword=''this.viewModel.clearSearch()},})}else{DefaultRecommendList()}}}}

这就是我觉得空结果要认真做的原因:它不是一个低优先级的边角料,而是搜索链路的一部分。用户搜不到时,如果还能看到“你可以试试西红柿、鸡蛋、家常菜”,他会继续点;如果只有一句“暂无数据”,他大概率就退出了。

错误和边界怎么收口

空态最容易被写成“什么都兜底”,最后反而让用户更糊涂。中式美食这里我会把边界写死。

边界处理方式原因
输入为空直接回默认推荐空输入不是搜索失败
同义词过多只展示 3 到 5 个建议不把空态做成另一个首页
分类挡住结果优先给清空分类用户很可能忘了自己在筛选里
RDB 异常展示重试,不展示推荐数据异常时不能装作有结果
旧请求回写requestVersion丢弃防止输入框和列表状态对不上

再补一个小工具函数,避免空字符串、空格和大小写问题在不同层各写一遍。

exportfunctionnormalizeSearchInput(value:string):string{returnvalue.trim().replace(/\s+/g,'').toLowerCase()}exportfunctionshouldShowDefaultList(state:SearchState):boolean{return!state.loading&&state.query.length===0&&state.emptyReason==='none'}

这类函数看着不起眼,但它能减少很多“页面 A 是这么判断,页面 B 是另一个判断”的问题。

工程验收记录

我会用几组很土但有效的用例验收,不靠感觉判断页面好不好。

用例操作预期
同义词命中输入“番茄”,库里只有“西红柿炒鸡蛋”页面能查到或给“西红柿”建议
分类过窄在“汤羹”分类里搜“红烧肉”空态提示当前分类可能过窄,给清空分类按钮
完全无结果输入一个不存在的菜名给热门食材/最近浏览,不显示假结果
快速输入连续输入“番”“番茄”“番茄炒蛋”最终页面只显示最后一次查询结果
清空恢复点击清空条件输入框、分类、分页、列表都回到默认推荐

如果这几组都能稳定过,搜索空态才算真正收口。否则只是做了一个看起来还可以的空页面,实际一用就会露问题。

本章小结

搜索空结果不是一句“暂无数据”能解决的。对中式美食这种菜谱和购物清单类应用来说,用户搜不到菜时,页面要告诉他为什么没找到,还要给他下一步能点的东西。我的处理顺序是:先归一化输入,再让 Repository 返回带解释的结果,ViewModel 保存空原因,ArkUI 空态展示建议词和清空入口。这样做不复杂,但它能把“搜不到就退出”变成“搜不到也能继续找”。

最后留一个很实际的问题:你们做本地搜索页时,是先补同义词表,还是先做热门搜索兜底?我现在更偏向先补同义词,因为它能直接解决“我明明搜了常用叫法却没有结果”的尴尬。

http://www.cnnetsun.cn/news/3379042.html

相关文章:

  • 一键转换视频为WebM:FFmpeg全攻略
  • Hermes Agent实战指南:23分钟掌握智能代理部署与自进化记忆系统
  • C#核心(持续更新中)
  • Vibe Coding的移动端革命:在手机上写代码是一种怎样的体验?
  • OpenCvSharp实战:巧用凸包缺陷分割粘连目标与精确定位
  • AI图像生成提示词工程:从“AI美女 车上好热”看多概念融合技术
  • ACCESS从零到一:构建你的第一个关系型数据库
  • 基于FPGA的以太网ARP协议实现
  • openeuler/compliance:开源合规管理的终极指南与工具集
  • 程序员正在悄悄淘汰的手动注释方式,而顶尖团队已用ChatGPT实现注释覆盖率100%(内部白皮书节选)
  • 多店铺业绩目标完成率自动统计,老板喜欢的日报来了 —— 跨系统数据集成与自动化日报技术测评
  • 物联网云平台是什么?有什么推荐?
  • 免费开源的PDF差异检测工具:5分钟快速上手diff-pdf
  • 深度技术解析:如何高效修复损坏MP4/MOV视频文件的专业指南
  • 多模态AI健康助手:架构、优化与应用实践
  • 智能单词记忆系统:提升小升初英语效率的解决方案
  • 2000-2025年各省、市气候政策不确定性指标CPU月度年度
  • GitHub Copilot SDK与混合AI实现README转PPT自动化
  • 【Springboot毕设全套源码+文档】基于springboot拍卖网站的设计与实现(丰富项目+远程调试+讲解+定制)
  • 开源OCR引擎Tesseract实战:从环境搭建到多语言识别
  • 4K修复技术与系统调试思维:从《黑暗边缘》解析影视工程实践
  • AI大模型赋能11大行业:从金融到医疗的全方位应用指南
  • Python通达信数据获取终极指南:5分钟掌握股票数据分析利器
  • AI Code终端系统:从指令驱动到任务自治的演进
  • 5分钟快速上手:鸣潮自动化工具全面指南
  • 综合实力最强的国际EMBA怎么选?2026避坑选购推荐攻略
  • Wand-Enhancer终极指南:三步免费解锁WeMod Pro高级功能完全教程
  • ChatGPT商业提案生成器失效真相:3大认知盲区+2个数据锚点校准法(附审计级提示词清单)
  • 【GIS入门】ArcGIS软件安装、界面操作与空间数据浏览查询
  • 本地视频预览别只自己看:把Remotion动效项目发给客户远程验收