HarmonyOS RDB 模糊搜索变慢怎么办:中式美食菜名、别名和食材关键词怎么分层查询
本地菜谱越来越多以后,搜索响应慢,结果排序还经常把弱相关菜排前面。 中式美食里同一个菜谱会从搜索、分类、收藏、推荐和购物清单多个入口出现,状态一旦没拆清楚,用户看到的就是点了没反应、返回位置丢了、统计不准或者卡片状态串到别的菜上。
| 项目 | 说明 |
|---|---|
| 应用名称 | 中式美食 |
| 这篇解决什么 | 本地菜谱越来越多以后,搜索响应慢,结果排序还经常把弱相关菜排前面。 |
| 谁会搜这篇 | 做 HarmonyOS 本地搜索、菜谱库、食材库的开发者会搜这篇 |
| 读完能解决什么 | 明确状态归属、Repository 边界、ArkUI/ArkTS 代码拆法和回归验收 |
| 当前项目证据 | 中式美食已经有搜索、分类、详情、收藏、购物清单和最近浏览,多入口状态会互相影响 |
先把问题说清楚
这个问题要拆到页面、状态和数据层一起看。只改页面文案解决不了根因,因为页面看到的是结果,真正决定结果的是 ViewModel 怎么保存状态、Repository 怎么查数据、路由或存储层怎么留下稳定事实。
| 章节 | 重点 |
|---|---|
| 问题从哪里来 | 先把用户能看到的异常讲明白 |
| 状态怎么拆 | 页面状态、查询状态、路由状态不要混用 |
| Repository 怎么接 | 数据层只负责事实,不负责 UI 文案 |
| ArkUI 怎么写 | 组件只做展示和事件出口,不偷改全局状态 |
| 怎么验收 | 用连续点击、刷新、返回、筛选这些动作压一遍 |
验证环境和文件边界
| 项目 | 说明 |
|---|---|
| DevEco Studio | 6.0.0 Release 系列 |
| HarmonyOS SDK | API 12 |
| 应用模型 | Stage 模型 |
| 语言和 UI | ArkTS、ArkUI 声明式页面 |
| 数据 API | @ohos.data.relationalStore、RdbStore、RdbPredicates、executeSql、query |
| 相关模块 | RecipeRepository、IngredientAliasRepository、SearchViewModel、RDBSchema |
| 层级 | 负责什么 | 不应该做什么 |
|---|---|---|
| ArkUI 页面 | 展示状态、收集点击、给用户反馈 | 不直接拼查询、不偷偷改仓储 |
| ViewModel | 保存当前页面状态,调用 Repository,屏蔽旧结果 | 不关心 UI 细节 |
| Repository | 查数据、写数据、做归一化和事务边界 | 不决定页面怎么排版 |
| 路由或存储 | 提供稳定事实 | 不承担临时交互判断 |
RDB 表和索引先定下来
模糊搜索变慢,很多时候不是因为 RDB 不能用,而是表和索引一开始就没给搜索留位置。中式美食这里把菜谱主表、别名表和食材索引表拆开,别把所有词都塞进一个description字段里。
importrelationalStorefrom'@ohos.data.relationalStore'constCREATE_RECIPE_INDEX=`CREATE TABLE IF NOT EXISTS recipe_index ( id TEXT PRIMARY KEY, recipe_id TEXT NOT NULL, normalized_name TEXT NOT NULL, category_key TEXT NOT NULL, rating REAL DEFAULT 0, updated_at INTEGER NOT NULL )`constCREATE_ALIAS_INDEX=`CREATE TABLE IF NOT EXISTS ingredient_alias ( alias_key TEXT PRIMARY KEY, target_key TEXT NOT NULL, weight INTEGER DEFAULT 10 )`constCREATE_INGREDIENT_INDEX=`CREATE TABLE IF NOT EXISTS recipe_ingredient_index ( id TEXT PRIMARY KEY, recipe_id TEXT NOT NULL, ingredient_key TEXT NOT NULL, amount_text TEXT )`建表之后再补索引。这里的目标不是让所有LIKE '%xx%'都变快,而是让更常见的前缀、别名、食材 key 查询先走窄集合,最后才做宽模糊。
asyncfunctionprepareSearchSchema(rdb:relationalStore.RdbStore):Promise<void>{awaitrdb.executeSql(CREATE_RECIPE_INDEX)awaitrdb.executeSql(CREATE_ALIAS_INDEX)awaitrdb.executeSql(CREATE_INGREDIENT_INDEX)awaitrdb.executeSql('CREATE INDEX IF NOT EXISTS idx_recipe_name ON recipe_index(normalized_name)')awaitrdb.executeSql('CREATE INDEX IF NOT EXISTS idx_recipe_category ON recipe_index(category_key)')awaitrdb.executeSql('CREATE INDEX IF NOT EXISTS idx_ingredient_key ON recipe_ingredient_index(ingredient_key)')awaitrdb.executeSql('CREATE INDEX IF NOT EXISTS idx_alias_target ON ingredient_alias(target_key)')}| 表 | 查什么 | 为什么单独拆 |
|---|---|---|
recipe_index | 菜名、分类、评分 | 先命中最准确的菜谱 |
ingredient_alias | 番茄/西红柿、土豆/马铃薯 | 解决用户叫法和数据叫法不一致 |
recipe_ingredient_index | 食材命中 | 菜名没命中时还能按食材兜底 |
先把状态模型写出来
如果页面上来就用几个零散变量凑逻辑,后面一定会乱。中式美食这类菜谱应用的麻烦点在于:一个操作会影响列表、详情、收藏、购物清单和最近浏览。先把状态模型写出来,后面才知道该更新谁。
exporttypeLoadStatus='idle'|'loading'|'ready'|'empty'|'error'exportinterfacePageState{keyword:stringstatus:LoadStatus selectedIds:string[]source:'search'|'favorite'|'recommend'|'shopping'anchorId?:stringmessage?:string}这段模型不追求复杂,关键是把“用户正在看什么”和“页面为什么这样显示”分开。比如空结果不是数组长度为 0 这么简单,它还可能带着关键词、来源和下一步建议。
| 字段 | 作用 | 出错表现 |
|---|---|---|
keyword | 当前搜索词或筛选词 | 返回后输入框空了 |
status | 页面当前状态 | 加载、空态、错误态互相覆盖 |
selectedIds | 当前勾选或命中项 | 刷新后统计不准 |
source | 从哪个入口进入 | 详情返回错页 |
anchorId | 列表恢复位置 | 返回后回到顶部 |
ViewModel 不要把动作写散
页面里每个按钮都直接改状态,短期看很快,后期调试很痛。我的习惯是让页面只调用动作,动作里统一处理旧状态、请求版本和刷新边界。
exportclassFeatureViewModel{state:PageState={keyword:'',status:'idle',selectedIds:[],source:'search'}privaterequestVersion=0asyncreload(keyword:string):Promise<void>{constversion=++this.requestVersionthis.state.keyword=keyword.trim()this.state.status='loading'constresult=awaitthis.repository.query(this.state.keyword)if(version!==this.requestVersion)returnthis.state.status=result.length>0?'ready':'empty'}}这里的requestVersion很土,但很管用。用户连续输入或连续切换 tab 时,旧请求回来不能覆盖新结果。这个坑在搜索页、购物清单页和推荐页都会遇到。
Repository 分三层查,不要一把 LIKE 打到底
我会把查询拆成三段:菜名前缀和完全命中、别名命中、食材兜底。这样做的好处是结果排序可以解释,调试时也能知道慢在哪里。
exportinterfaceSearchHit{recipeId:stringsource:'name'|'alias'|'ingredient'score:numberexplain:string}functionnormalizeKeyword(input:string):string{returninput.trim().toLowerCase().replace(/\s+/g,'')}第一层查菜名。精确命中和前缀命中要比包含命中更靠前。
asyncfunctionqueryByRecipeName(rdb:relationalStore.RdbStore,keyword:string):Promise<SearchHit[]>{constkey=normalizeKeyword(keyword)constpredicates=newrelationalStore.RdbPredicates('recipe_index').beginsWith('normalized_name',key)constresultSet=awaitrdb.query(predicates,['recipe_id','normalized_name','rating'])consthits:SearchHit[]=[]while(resultSet.goToNextRow()){constrecipeId=resultSet.getString(resultSet.getColumnIndex('recipe_id'))constname=resultSet.getString(resultSet.getColumnIndex('normalized_name'))constrating=resultSet.getDouble(resultSet.getColumnIndex('rating'))hits.push({recipeId,source:'name',score:name===key?100+rating:80+rating,explain:`菜名命中:${name}`})}resultSet.close()returnhits}第二层查别名。用户搜“番茄”,数据里可能是“西红柿”;用户搜“马铃薯”,菜谱里可能写“土豆”。这一步不要在页面里硬编码,放到别名表里更稳。
asyncfunctionqueryAliasTarget(rdb:relationalStore.RdbStore,keyword:string):Promise<string[]>{constp=newrelationalStore.RdbPredicates('ingredient_alias').equalTo('alias_key',normalizeKeyword(keyword))constresultSet=awaitrdb.query(p,['target_key'])constkeys:string[]=[]while(resultSet.goToNextRow()){keys.push(resultSet.getString(resultSet.getColumnIndex('target_key')))}resultSet.close()returnkeys}第三层用食材兜底。只有菜名和别名都不够时,才扩大到食材索引。
asyncfunctionqueryByIngredient(rdb:relationalStore.RdbStore,ingredientKey:string):Promise<SearchHit[]>{constp=newrelationalStore.RdbPredicates('recipe_ingredient_index').equalTo('ingredient_key',ingredientKey)constresultSet=awaitrdb.query(p,['recipe_id','ingredient_key'])consthits:SearchHit[]=[]while(resultSet.goToNextRow()){hits.push({recipeId:resultSet.getString(resultSet.getColumnIndex('recipe_id')),source:'ingredient',score:50,explain:`食材命中:${ingredientKey}`})}resultSet.close()returnhits}最后把三层结果合并去重。同一个菜谱可能被菜名和食材同时命中,保留分数最高的那条。
functionmergeSearchHits(groups:SearchHit[][]):SearchHit[]{constmap=newMap<string,SearchHit>()groups.flat().forEach((hit:SearchHit)=>{constold=map.get(hit.recipeId)if(!old||hit.score>old.score){map.set(hit.recipeId,hit)}})returnArray.from(map.values()).sort((a,b)=>b.score-a.score)}ArkUI 页面只做展示和事件出口
页面代码越像“业务大杂烩”,越难定位问题。中式美食里我会把点击动作放到 ViewModel 或 Router,页面只负责把当前状态画出来。
@Componentstruct FeaturePanel{vm:FeatureViewModelbuild(){Column(){if(this.vm.state.status==='loading'){Text('正在加载')}elseif(this.vm.state.status==='empty'){Text('没有找到合适结果,换个关键词试试')}else{List(){ForEach(this.vm.items,(item:RecipeCardModel)=>{ListItem(){Text(item.name)}},(item:RecipeCardModel)=>item.id)}}}}}ForEach的 key 一定要用业务 id。不要用 index。index 在搜索、筛选、排序、插入、删除之后都会变,卡片复用就容易把上一条的状态带到下一条。
容易忽略的边界
这几个边界如果不提前写清楚,明天换一个页面还会踩同样的坑。
| 边界 | 错误写法 | 更稳的写法 |
|---|---|---|
| 点击动作 | 子组件里直接改 Repository | 子组件抛回调,父级 ViewModel 处理 |
| 返回来源 | 只传dishId | 同时传source和上下文快照 |
| 搜索结果 | 页面里做字符串匹配 | Repository 统一归一化和排序 |
| 勾选统计 | 底部栏自己算一遍 | 从 ViewModel 派生同一份统计 |
| 列表 key | 用 index | 用稳定业务 id |
回归验收
| 验收动作 | 预期结果 |
|---|---|
| 连续快速点击两次入口 | 不重复写入,不出现旧状态覆盖新状态 |
| 筛选后进入详情再返回 | 关键词、分类、排序、滚动位置保持一致 |
| 收藏或勾选后刷新页面 | 页面状态和本地数据一致 |
| 切换分类再清空条件 | 列表回到默认态,不残留旧筛选 |
| 删除或新增一条数据 | ForEach 不串状态,卡片展示不乱 |
functionverifyState(current:PageState):boolean{if(current.status==='ready'&¤t.keyword.length===0)returntrueif(current.status==='empty'&¤t.message===undefined)returnfalsereturncurrent.selectedIds.every((id:string)=>id.length>0)}我的验收习惯是先用手点出问题,再用表格固定动作。只看代码很容易觉得没问题,但一旦连续点击、返回、刷新、清空一起压,状态边界是否清楚马上就能看出来。
最后小结
RDB 搜索 这类问题不要只看页面表象。真正要处理的是:谁保存状态,谁查数据,谁负责跳转,谁只展示结果。中式美食后面继续扩菜谱、购物清单和推荐入口时,只要这些边界不乱,新功能就不会把旧页面拖出问题。
