news 2026/7/14 18:47:14

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
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 空态展示建议词和清空入口。这样做不复杂,但它能把“搜不到就退出”变成“搜不到也能继续找”。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/7/14 18:44:59

一键转换视频为WebM:FFmpeg全攻略

文章目录需求分析1. 安装 FFmpeg2. 字段说明3. 场景一&#xff1a;单条快速命令4. 场景二&#xff1a;致画质&#xff1a;VP9 二遍编码&#xff08;2-pass&#xff0c;画质最优&#xff0c;速度慢&#xff09;5. 场景三&#xff1a;批量转换脚本&#xff08;文件夹内所有视频转…

作者头像 李华
网站建设 2026/7/14 18:44:03

Hermes Agent实战指南:23分钟掌握智能代理部署与自进化记忆系统

在企业级自动化场景中&#xff0c;团队经常面临重复性任务繁重、多平台集成复杂、AI助手缺乏记忆能力等痛点。传统Agent框架要么过于臃肿难以落地&#xff0c;要么无法保留上下文记忆&#xff0c;导致每次交互都像"初次见面"。本文基于实际项目经验&#xff0c;完整拆…

作者头像 李华
网站建设 2026/7/14 18:42:48

C#核心(持续更新中)

一、类和对象1、什么是类基本概念&#xff1a;具有相同特征具有相同行为一类事物的抽象类是对象的模板可以通过类创建出对象类的关键词 class 2、类申明在哪里类一般申明在namespace语句块中 3、类申明语法访问修饰符 class 类名 {//特征——成员变量//行为——成员方法//保护特…

作者头像 李华
网站建设 2026/7/14 18:42:06

Vibe Coding的移动端革命:在手机上写代码是一种怎样的体验?

2026年&#xff0c;我在地铁上用手机完成了一个Web应用的全部开发。这不是噱头&#xff0c;也不是"凑合能用"的玩具。而是一个真正完整的开发流程——需求描述、代码生成、调试、部署&#xff0c;全程在一台手机上完成。Vibe Coding正在改变移动端编程的可能性。当AI…

作者头像 李华
网站建设 2026/7/14 18:35:21

OpenCvSharp实战:巧用凸包缺陷分割粘连目标与精确定位

1. 工业视觉中的粘连目标分割难题在自动化分拣流水线上&#xff0c;我们经常会遇到这样的场景&#xff1a;传送带上的零件因为摆放过于紧密而相互接触&#xff0c;或者产品在包装过程中发生重叠。这时候传统的轮廓检测方法就会失效——计算机会把这些粘连的物体识别成一个整体。…

作者头像 李华
网站建设 2026/7/14 18:33:50

AI图像生成提示词工程:从“AI美女 车上好热”看多概念融合技术

最近在AI图像生成领域&#xff0c;一个看似简单的提示词"AI美女 车上好热"引发了广泛讨论。这个提示词背后反映的不仅是技术问题&#xff0c;更是对AI图像生成模型理解能力和创意表达边界的深度考验。1. 为什么这个简单的提示词值得深入分析"AI美女 车上好热&qu…

作者头像 李华