ArkUI 搜索空结果怎么办:中式美食搜不到菜时怎么给同义词、推荐词和清空入口
用户搜“番茄炒蛋”没有结果页面只显示一句“暂无数据”这事看起来很小但实际会直接影响用户对搜索页的信任。中式美食做搜索页时我不想把空状态当成一个文案问题处理因为真正的问题在前面用户输的词有没有被归一化Repository 有没有把同义词查进去ViewModel 有没有保存这次查询的原因ArkUI 页面有没有给用户一个能继续点的入口。项目说明应用名称中式美食这篇解决什么搜索页没有结果时不只是显示空文案而是给同义词、推荐词和清空入口谁会搜这篇做 HarmonyOS 菜谱、食材库、购物清单、本地搜索的 ArkUI/ArkTS 开发者点进来能拿到什么空结果状态模型、Repository 返回结构、RDB 查询边界、ArkUI 空态组件和验收用例当前项目证据中式美食已经有搜索、分类、购物清单、收藏和最近浏览这些会互相影响的入口本章导读这篇不讲大而全的搜索系统只拆一个很容易被忽略的点搜不到结果时页面到底该怎么收口。很多 App 的搜索页只做了“输入 - 查询 - 列表”一旦没有结果就把锅丢给空状态文案。我的处理方式是把空结果也当成一种业务结果它要知道用户搜了什么、系统帮他试了哪些同义词、下一步能点什么。章节重点问题从哪里来为什么contains(keyword)做本地过滤很容易让用户搜不到状态怎么拆query、normalizedQuery、emptyReason、suggestions要分开Repository 怎么回不直接丢数组返回一个带解释的SearchResultArkUI 怎么展示空态里放建议词、热门食材、清空入口而不是只放一句话怎么验收用番茄/西红柿、土豆/马铃薯、乱输词三组用例压一遍验证环境和文件边界项目说明DevEco Studio6.x 系列HarmonyOS SDKAPI 12 目标工程页面层ArkUI 声明式页面Stage 模型语言ArkTS本地数据ohos.data.relationalStore、RDB、RdbPredicates相关模块SearchViewModel、RecipeRepository、IngredientAliasRepository、SearchEmptyView我会把搜索页分成四层页面不要直接判断数据库字段Repository 也不要知道具体 UI 怎么摆。这样后面你把“同义词兜底”换成“历史搜索兜底”或者“热门菜兜底”不用把页面拆一遍。层级负责什么不应该做什么ArkUI 页面展示输入框、列表、空态、建议词按钮不直接拼 RDB 查询条件ViewModel保存查询状态、发起搜索、屏蔽旧结果回写不关心 SQL 表结构Repository组织同义词、分类和菜谱查询不拼 UI 文案RDB提供稳定字段和索引不承担页面兜底逻辑先把空结果当成一种结果很多代码会这样写查到数组就展示列表数组为空就展示空文案。这个写法能跑但用户不知道自己该怎么继续。typeSearchState{query:stringloading:booleanrecipes:RecipeCard[]error?:string}asyncfunctionsearch(keyword:string){state.querykeyword state.loadingtrueconstrecipesawaitrecipeRepository.searchByKeyword(keyword)state.recipesrecipes state.loadingfalse}这段代码的问题不是短而是信息太少。recipes.length 0只能说明“当前查询没有结果”不能说明为什么没有结果。用户到底是拼错了、搜了别名、当前分类太窄还是网络/本地库异常页面完全不知道。空结果原因用户看到什么页面应该给什么输入词是别名搜“番茄”库里写“西红柿”提示“试试西红柿”并给一键搜索分类条件太窄在“汤羹”里搜“红烧肉”给“清空分类”入口本地库没有该菜搜很冷门的菜名给热门食材或最近浏览入口查询失败RDB 异常或数据损坏给重试入口和错误说明状态模型要多留几个字段中式美食搜索页我会把状态拆得细一点。这里不是为了炫技而是为了让空状态有依据。exporttypeEmptyReason|none|aliasMatchedButNoRecipe|categoryTooNarrow|noLocalRecipe|queryFailedexportinterfaceSearchState{query:stringnormalizedQuery:stringactiveCategoryId?:stringloading:booleanrecipes:RecipeCard[]suggestions:SearchSuggestion[]emptyReason:EmptyReason errorMessage?:string}这个状态里最关键的是normalizedQuery和emptyReason。用户输入“番茄”归一化后可能是“西红柿”用户输入“马铃薯”归一化后可能是“土豆”。如果最后还是没有结果页面至少可以告诉用户“我已经帮你试了常见叫法但当前分类下没有菜。”字段给谁用为什么要留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):PromiseSearchResult{constnormalizedQueryawaitthis.aliasRepository.normalize(query)constrecipesawaitthis.recipeStore.queryRecipes(normalizedQuery,categoryId)if(recipes.length0){return{query,normalizedQuery,recipes,suggestions:[],emptyReason:none}}constsuggestionsawaitthis.aliasRepository.suggest(query,normalizedQuery)constemptyReasoncategoryId?categoryTooNarrow:this.resolveEmptyReason(suggestions)return{query,normalizedQuery,recipes,suggestions,emptyReason}}privateresolveEmptyReason(suggestions:SearchSuggestion[]):EmptyReason{returnsuggestions.length0?aliasMatchedButNoRecipe:noLocalRecipe}}这段代码有一个边界很重要Repository 可以告诉页面“为什么没有”但不要替页面决定“怎么展示”。展示是 ArkUI 的事Repository 只给足数据。RDB 查询先做稳定字段菜谱类应用不要只靠标题模糊匹配。中式美食里食材、菜名、分类、推荐标签会同时影响搜索结果最容易出问题的是同义词。constCREATE_RECIPE_TABLECREATE 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_TABLECREATE 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_INDEXCREATE INDEX IF NOT EXISTS idx_recipe_title_keyword ON recipe(title_keyword)constCREATE_ALIAS_INDEXCREATE INDEX IF NOT EXISTS idx_alias_normalized ON ingredient_alias(normalized_name)这里的title_keyword和ingredient_keywords是为了查询稳定不是给用户看的展示名。展示名该叫“西红柿炒鸡蛋”就叫“西红柿炒鸡蛋”查询时可以让“番茄炒蛋”也能落到同一批候选上。查询字段适合做什么不适合做什么title展示给用户当唯一查询字段title_keyword标题归一化查询展示原文ingredient_keywords食材命中、推荐兜底直接给用户看category_id分类收窄判断搜索意图alias.normalized_name同义词归一替代完整搜索引擎查询代码可以先保持朴素但边界要清楚。asyncfunctionqueryRecipes(store:relationalStore.RdbStore,normalizedQuery:string,categoryId?:string,):PromiseRecipeCard[]{constpredicatesnewrelationalStore.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)constresultSetawaitstore.query(predicates,[id,title,category_id])returnmapRecipeCards(resultSet)}ViewModel 要挡住旧结果回写搜索框输入很快时空结果也可能被旧请求覆盖。比如用户先输入“番”马上改成“番茄炒蛋”前一个请求慢一点回来就把后一个结果覆盖成空态。这个问题我之前在搜索请求里已经遇到过所以这里继续用版本号挡一下。ObservedexportclassSearchViewModel{state:SearchState{query:,normalizedQuery:,loading:false,recipes:[],suggestions:[],emptyReason:none,}privaterequestVersion0constructor(privatereadonlyrepository:RecipeSearchRepository){}asyncsearch(query:string,categoryId?:string):Promisevoid{constversionthis.requestVersionthis.state{...this.state,query,loading:true,errorMessage:undefined}try{constresultawaitthis.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.requestVersionthis.state{...this.state,query:,normalizedQuery:,recipes:[],suggestions:[],emptyReason:none}}}这段代码不是为了把 ViewModel 写复杂而是为了避免一个很难解释的体验明明输入框里是新词列表却显示上一次的空结果。ArkUI 空态别只写一句话页面层要做的事很简单有结果展示列表加载中展示骨架没结果时根据emptyReason给不同入口。Componentstruct SearchEmptyView{Propquery:stringPropnormalizedQuery:stringPropreason:EmptyReasonPropsuggestions: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.length0){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.reasoncategoryTooNarrow?当前分类里没找到:还没找到这道菜}privatebuildDescription():string{returnthis.normalizedQuerythis.normalizedQuery!this.query?已尝试按“${this.normalizedQuery}”查找可以换个推荐词再试。:可以换个食材名或者先回到默认推荐列表。}}这里有两个细节第一ForEach的 key 用keyword不要用 index第二清空按钮必须真的清掉 query、分类和分页状态否则用户点了也回不到默认列表。页面组合时把三种状态摆清楚搜索页主体不要把所有判断塞在一个if里。加载、成功、空态、错误最好分开写后面改样式和埋点都方便。Componentstruct RecipeSearchPage{Statekeyword:stringprivateviewModel:SearchViewModelnewSearchViewModel(recipeSearchRepository)build(){Column(){SearchBar({value:this.keyword,onChange:(value:string){this.keywordvaluethis.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.length0){List(){ForEach(this.viewModel.state.recipes,(recipe:RecipeCard){RecipeListItem({recipe})},(recipe:RecipeCard)recipe.id)}}elseif(this.viewModel.state.query.length0){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.keywordthis.viewModel.clearSearch()},})}else{DefaultRecommendList()}}}}这就是我觉得空结果要认真做的原因它不是一个低优先级的边角料而是搜索链路的一部分。用户搜不到时如果还能看到“你可以试试西红柿、鸡蛋、家常菜”他会继续点如果只有一句“暂无数据”他大概率就退出了。错误和边界怎么收口空态最容易被写成“什么都兜底”最后反而让用户更糊涂。中式美食这里我会把边界写死。边界处理方式原因输入为空直接回默认推荐空输入不是搜索失败同义词过多只展示 3 到 5 个建议不把空态做成另一个首页分类挡住结果优先给清空分类用户很可能忘了自己在筛选里RDB 异常展示重试不展示推荐数据异常时不能装作有结果旧请求回写用requestVersion丢弃防止输入框和列表状态对不上再补一个小工具函数避免空字符串、空格和大小写问题在不同层各写一遍。exportfunctionnormalizeSearchInput(value:string):string{returnvalue.trim().replace(/\s/g,).toLowerCase()}exportfunctionshouldShowDefaultList(state:SearchState):boolean{return!state.loadingstate.query.length0state.emptyReasonnone}这类函数看着不起眼但它能减少很多“页面 A 是这么判断页面 B 是另一个判断”的问题。工程验收记录我会用几组很土但有效的用例验收不靠感觉判断页面好不好。用例操作预期同义词命中输入“番茄”库里只有“西红柿炒鸡蛋”页面能查到或给“西红柿”建议分类过窄在“汤羹”分类里搜“红烧肉”空态提示当前分类可能过窄给清空分类按钮完全无结果输入一个不存在的菜名给热门食材/最近浏览不显示假结果快速输入连续输入“番”“番茄”“番茄炒蛋”最终页面只显示最后一次查询结果清空恢复点击清空条件输入框、分类、分页、列表都回到默认推荐如果这几组都能稳定过搜索空态才算真正收口。否则只是做了一个看起来还可以的空页面实际一用就会露问题。本章小结搜索空结果不是一句“暂无数据”能解决的。对中式美食这种菜谱和购物清单类应用来说用户搜不到菜时页面要告诉他为什么没找到还要给他下一步能点的东西。我的处理顺序是先归一化输入再让 Repository 返回带解释的结果ViewModel 保存空原因ArkUI 空态展示建议词和清空入口。这样做不复杂但它能把“搜不到就退出”变成“搜不到也能继续找”。最后留一个很实际的问题你们做本地搜索页时是先补同义词表还是先做热门搜索兜底我现在更偏向先补同义词因为它能直接解决“我明明搜了常用叫法却没有结果”的尴尬。

相关新闻