用户搜“番茄炒蛋”没有结果,页面只显示一句“暂无数据”,这事看起来很小,但实际会直接影响用户对搜索页的信任。中式美食做搜索页时,我不想把空状态当成一个文案问题处理,因为真正的问题在前面:用户输的词有没有被归一化,Repository 有没有把同义词查进去,ViewModel 有没有保存这次查询的原因,ArkUI 页面有没有给用户一个能继续点的入口。
| 项目 | 说明 |
|---|---|
| 应用名称 | 中式美食 |
| 这篇解决什么 | 搜索页没有结果时,不只是显示空文案,而是给同义词、推荐词和清空入口 |
| 谁会搜这篇 | 做 HarmonyOS 菜谱、食材库、购物清单、本地搜索的 ArkUI/ArkTS 开发者 |
| 点进来能拿到什么 | 空结果状态模型、Repository 返回结构、RDB 查询边界、ArkUI 空态组件和验收用例 |
| 当前项目证据 | 中式美食已经有搜索、分类、购物清单、收藏和最近浏览这些会互相影响的入口 |
本章导读
这篇不讲大而全的搜索系统,只拆一个很容易被忽略的点:搜不到结果时,页面到底该怎么收口。很多 App 的搜索页只做了“输入 -> 查询 -> 列表”,一旦没有结果,就把锅丢给空状态文案。我的处理方式是把空结果也当成一种业务结果:它要知道用户搜了什么、系统帮他试了哪些同义词、下一步能点什么。
| 章节 | 重点 |
|---|---|
| 问题从哪里来 | 为什么contains(keyword)做本地过滤很容易让用户搜不到 |
| 状态怎么拆 | query、normalizedQuery、emptyReason、suggestions要分开 |
| Repository 怎么回 | 不直接丢数组,返回一个带解释的SearchResult |
| ArkUI 怎么展示 | 空态里放建议词、热门食材、清空入口,而不是只放一句话 |
| 怎么验收 | 用番茄/西红柿、土豆/马铃薯、乱输词三组用例压一遍 |
验证环境和文件边界
| 项目 | 说明 |
|---|---|
| DevEco Studio | 6.x 系列 |
| HarmonyOS SDK | API 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.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}这个状态里最关键的是normalizedQuery和emptyReason。用户输入“番茄”,归一化后可能是“西红柿”;用户输入“马铃薯”,归一化后可能是“土豆”。如果最后还是没有结果,页面至少可以告诉用户:“我已经帮你试了常见叫法,但当前分类下没有菜。”
| 字段 | 给谁用 | 为什么要留 |
|---|---|---|
query | 页面输入框 | 保留用户原始输入,不能随便改掉 |
normalizedQuery | Repository/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_keyword和ingredient_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 空态展示建议词和清空入口。这样做不复杂,但它能把“搜不到就退出”变成“搜不到也能继续找”。
最后留一个很实际的问题:你们做本地搜索页时,是先补同义词表,还是先做热门搜索兜底?我现在更偏向先补同义词,因为它能直接解决“我明明搜了常用叫法却没有结果”的尴尬。