HarmonyOS RDB 索引实战:中式美食搜索和购物清单查询怎么避免扫全表

📅 2026/7/11 21:26:46 👁️ 阅读次数 📝 编程学习
HarmonyOS RDB 索引实战:中式美食搜索和购物清单查询怎么避免扫全表

中式美食现在的搜索、分类、购物清单和收藏入口,页面上看起来只是几个筛选条件:输入关键词、点一个分类、勾几个食材、再从菜谱详情跳回来。但真正做本地数据时,问题不是“能不能筛出来”,而是“数据多了以后还敢不敢这么筛”。

我这次不把 RDB 当成一个概念来讲,而是拿中式美食里最容易变慢的几条查询链路拆开:搜索页要按标题和食材命中,列表页要按分类和排序切换,购物清单要按菜谱来源、分类和勾选状态过滤。用 HarmonyOS relationalStore 做这类数据时,索引不是锦上添花,而是让页面稳定的底座。

上面这张图对应的是一条很常见的产品链路:用户不是只输入一个词,他可能先看历史词,再点热门食材,再筛分类,最后进入详情或购物清单。页面动作很多,但数据层不能跟着乱。查询入口、模型字段、索引和验收要提前对齐。

这篇文章解决哪一类问题

位置这次重点
产品入口中式美食搜索页、分类列表页、购物清单页
当前代码证据SearchViewModelShoppingListViewModelRecipeRepositoryTablePlanRepository
HarmonyOS 目标能力relationalStore / RDB / RdbPredicates / index
核心问题关键词、分类、菜谱来源、勾选状态一起查询时,不能每次都扫完整列表
验收标准查询字段明确、索引字段明确、异常场景可复现、页面状态能回到稳定结果

中式美食现在有一部分状态已经能从模块上看出来边界:购物清单的勾选状态在ShoppingListState里,餐桌方案来自TablePlanRepository,菜谱详情由RecipeRepository抽象。现在的问题是,后续鸿蒙重构时,这些东西不能全部停留在页面状态里。

页面状态适合做当次交互,RDB 适合做可查询、可恢复、可关联的数据。索引要跟着“真实查询方式”走,而不是看到一个字段就顺手建一个。

当前验证环境和技术栈

项目说明
应用名称中式美食
当前工程证据Flutter 版本模块,重点看购物清单、餐桌方案、搜索和菜谱仓库
鸿蒙目标栈HarmonyOS NEXT / Stage 模型 / ArkTS / ArkUI
本地数据能力@ohos.data.relationalStore、RDB、RdbPredicates、SQL index
目标页面搜索页、分类列表页、购物清单页、收藏页、最近浏览页
验证方式用固定测试数据验证查询、勾选、刷新、历史恢复和同义词命中

我这里把“当前代码证据”和“鸿蒙目标实现”分开讲,是为了避免一个常见误区:当前工程里还没有把这些模块全部迁成 ArkTS RDB,所以不能假装已经有完整 RDB 实现。更准确的写法是,从当前中式美食已经存在的功能边界出发,推导后续 HarmonyOS relationalStore 应该怎么落。

本章导读

章节你能拿走什么
当前页面为什么会有查询压力看清楚购物清单和搜索页为什么不能一直靠内存列表
RDB 表从查询反推不先画大而全的数据图,先看真实入口怎么查
索引字段怎么选只给高频查询链路建索引,避免写入和迁移负担
搜索关键词归一化解决番茄/西红柿、鸡蛋/土鸡蛋这类命中问题
Repository 分层保持 ArkUI 页面干净,不让页面直接碰 RDB
工程验收用可复现步骤确认查询链路真的稳定

先看当前页面为什么会有查询压力

购物清单页面当前的状态很直观:每个食材有名字和勾选状态,页面负责切换勾选,然后计算剩余数量。这个写法适合 MVP,因为用户一进来就能看到列表,也能点选。

classShoppingListItem{constShoppingListItem({requiredthis.name,this.checked=false,});finalStringname;finalbool checked;}classShoppingListState{constShoppingListState({requiredthis.plan,requiredthis.items,});finalTablePlanplan;finalList<ShoppingListItem>items;intgetcheckedCount=>items.where((item)=>item.checked).length;List<ShoppingListItem>getremainingItems=>items.where((item)=>!item.checked).toList();}

这段代码本身没问题。问题出在功能继续往前走以后,页面会出现更多真实需求:

场景页面临时列表能不能撑住为什么后面要进 RDB
当前方案下的 7 个食材勾选数据量小,只是一次页面状态
按菜谱来源查看购物清单勉强能需要知道每个食材来自哪道菜
按分类看肉类、蔬菜、调料不稳食材要有分类字段,不能只靠名字
搜索“鸡蛋”同时命中菜谱和购物项不适合搜索入口跨表,页面遍历会越来越乱
查看历史购物清单不适合需要按时间、方案、完成状态查询

所以这篇不是说“马上把所有状态塞进数据库”。我的判断是:页面临时状态继续留在 ViewModel;需要跨页面恢复、按条件查询、和菜谱/食材发生关系的数据,进入 RDB。

RDB 表不是先画大图,而是从查询反推

很多本地库设计容易犯一个错:先把表画得很完整,字段塞一堆,然后再想页面怎么查。中式美食更适合反过来做,先把查询列出来。

查询入口用户动作需要的字段索引优先级
搜索页输入菜名、食材、口味词titleingredientNamekeyword
分类列表点“家常菜/下饭菜/汤羹”categoryIdsortScore
购物清单看某个餐桌方案的食材planIdrecipeId
购物清单只看未购买食材checkedupdatedAt
最近浏览按时间倒序看菜谱viewedAtrecipeId
收藏页看收藏菜谱favoriteAtrecipeId

这张表定下来以后,RDB 设计就不会跑偏。索引不是为了让表看起来专业,而是为了让“高频查询路径”少走弯路。

HarmonyOS 侧表结构可以这样拆

下面是我会给中式美食鸿蒙版先落地的几张表。字段不追求一次到位,但要保证后面能扩展。

constCREATE_RECIPE_TABLE=`CREATE TABLE IF NOT EXISTS recipe ( id TEXT PRIMARY KEY, title TEXT NOT NULL, category_id TEXT NOT NULL, cover_uri TEXT, difficulty TEXT, cook_minutes INTEGER DEFAULT 0, sort_score REAL DEFAULT 0, updated_at INTEGER NOT NULL )`;constCREATE_INGREDIENT_TABLE=`CREATE TABLE IF NOT EXISTS ingredient ( id TEXT PRIMARY KEY, recipe_id TEXT NOT NULL, name TEXT NOT NULL, normalized_name TEXT NOT NULL, amount TEXT, unit TEXT, category TEXT, FOREIGN KEY(recipe_id) REFERENCES recipe(id) )`;constCREATE_SHOPPING_ITEM_TABLE=`CREATE TABLE IF NOT EXISTS shopping_item ( id TEXT PRIMARY KEY, plan_id TEXT NOT NULL, recipe_id TEXT, ingredient_name TEXT NOT NULL, normalized_name TEXT NOT NULL, amount_text TEXT, category TEXT, checked INTEGER DEFAULT 0, updated_at INTEGER NOT NULL )`;

这里有几个取舍:

  1. recipe.title要保留,搜索菜名会用到。
  2. ingredient.normalized_name要保留,同名食材合并不能只靠原始展示名。
  3. shopping_item.checked不能只放在页面里,否则用户退出再回来就丢状态。
  4. cover_uri只存图片地址或资源标识,不把大图塞进数据库。
  5. sort_score给推荐、热度或人工排序留口子,不要让列表排序只能靠写死规则。

哪些字段值得建索引

索引不是越多越好。建太多索引会拖慢写入,也会让迁移变复杂。对中式美食这种本地数据场景,我会先建下面这些。

constCREATE_INDEXES=[`CREATE INDEX IF NOT EXISTS idx_recipe_category_score ON recipe(category_id, sort_score DESC)`,`CREATE INDEX IF NOT EXISTS idx_recipe_title ON recipe(title)`,`CREATE INDEX IF NOT EXISTS idx_ingredient_recipe ON ingredient(recipe_id)`,`CREATE INDEX IF NOT EXISTS idx_ingredient_name ON ingredient(normalized_name)`,`CREATE INDEX IF NOT EXISTS idx_shopping_plan_checked ON shopping_item(plan_id, checked, updated_at DESC)`,`CREATE INDEX IF NOT EXISTS idx_shopping_name ON shopping_item(normalized_name)`];

我不会一开始就给每个字段建索引。比如difficultycook_minutesunit这些字段,除非后面真的有高频筛选,否则先不建。索引要服务入口,不要服务想象。

搜索页不要只查一个字段

中式美食的搜索不是普通文本搜索。用户搜“牛肉”,可能想找菜谱,也可能想找购物清单里的食材,还可能是从历史词进来的。页面可以只展示一个输入框,但数据层要把来源拆清楚。

typeSearchScene='recipeTitle'|'ingredient'|'shoppingItem';interfaceSearchHit{scene:SearchScene;targetId:string;title:string;subtitle:string;score:number;}

我会让 Repository 返回统一的SearchHit,但内部查询分三路走:

来源查询表命中字段展示意义
菜谱标题recipetitle用户可以直接进详情
菜谱食材ingredientnormalized_name用户知道哪道菜用到这个食材
购物项shopping_itemnormalized_name用户能快速找到待买食材

这样写的好处是,页面不用关心表结构,只关心结果怎么展示;数据层也不会为了一个搜索框,把所有数据揉成一坨大 JSON。

RdbPredicates 查询示例

下面这段是鸿蒙侧查询购物清单未勾选食材的写法。重点不是 API 调用本身,而是查询条件要和索引顺序对应。

importrelationalStorefrom'@ohos.data.relationalStore';asyncfunctionqueryUncheckedShoppingItems(store:relationalStore.RdbStore,planId:string):Promise<ShoppingItemEntity[]>{constpredicates=newrelationalStore.RdbPredicates('shopping_item');predicates.equalTo('plan_id',planId);predicates.equalTo('checked',0);predicates.orderByDesc('updated_at');constresultSet=awaitstore.query(predicates,['id','recipe_id','ingredient_name','amount_text','category','checked','updated_at']);constitems:ShoppingItemEntity[]=[];while(resultSet.goToNextRow()){items.push({id:resultSet.getString(resultSet.getColumnIndex('id')),recipeId:resultSet.getString(resultSet.getColumnIndex('recipe_id')),ingredientName:resultSet.getString(resultSet.getColumnIndex('ingredient_name')),amountText:resultSet.getString(resultSet.getColumnIndex('amount_text')),category:resultSet.getString(resultSet.getColumnIndex('category')),checked:resultSet.getLong(resultSet.getColumnIndex('checked'))===1,updatedAt:resultSet.getLong(resultSet.getColumnIndex('updated_at'))});}resultSet.close();returnitems;}

这个查询对应前面的索引:

CREATEINDEXIFNOTEXISTSidx_shopping_plan_checkedONshopping_item(plan_id,checked,updated_atDESC);

为什么索引顺序这么排?因为页面最先限定的是plan_id,然后限定checked,最后按updated_at排序。索引顺序如果反过来,查询不一定能吃满索引收益。

搜索关键词要先归一化

中式美食里食材名称很容易出现变体:番茄/西红柿、葱姜蒜/姜葱蒜、鸡蛋/土鸡蛋。RDB 索引只负责加速查询,不负责理解同义词。所以在写入前要做一层归一化。

constingredientAlias:Record<string,string>={'西红柿':'番茄','小葱':'葱','土鸡蛋':'鸡蛋','生抽酱油':'生抽'};functionnormalizeIngredientName(raw:string):string{consttrimmed=raw.trim();if(!trimmed){return'';}returningredientAlias[trimmed]??trimmed;}

写入ingredientshopping_item时保存两份:

字段用途
ingredient_name原始展示名,给用户看
normalized_name查询、合并、索引用

这一步很小,但能避免后面很多麻烦。否则用户搜“番茄”,页面却只存了“西红柿”,你再怎么建索引都查不到。

从当前代码迁移时,不要一次性推倒

当前中式美食的代码已经有 Repository 和 ViewModel 分层,这一点要保留。迁移到 HarmonyOS RDB 时,不应该让 ArkUI 页面直接拿RdbStore查表。

更稳的分层是这样:

classShoppingListRepository{constructor(privatereadonlystore:relationalStore.RdbStore){}asyncgetUncheckedItems(planId:string):Promise<ShoppingItemEntity[]>{returnqueryUncheckedShoppingItems(this.store,planId);}asynctoggleChecked(itemId:string,checked:boolean):Promise<void>{constvalues:relationalStore.ValuesBucket={checked:checked?1:0,updated_at:Date.now()};constpredicates=newrelationalStore.RdbPredicates('shopping_item');predicates.equalTo('id',itemId);awaitthis.store.update(values,predicates);}}

ArkUI 页面只接触 ViewModel,ViewModel 只接触 Repository。这样以后要从本地 RDB 增加云同步,也不用改页面。

索引设计最容易踩的坑

表现处理方式
所有字段都建索引写入慢、迁移麻烦只给高频查询字段建
页面里拼复杂条件ViewModel 越写越厚查询条件沉到 Repository
原始食材名直接查同义词查不到写入前做normalized_name
勾选状态只放页面退出再回来丢状态checked进 RDB
大图塞数据库表变大、读取慢只存 URI 或资源标识
搜索结果混成一种类型点进去不知道去哪返回SearchHit.scene

我现在更倾向于把这些坑提前写进验收标准,而不是等页面慢了再补。因为本地库一旦上线,后面迁移字段和重建索引都会比一开始规划麻烦。

查询验收怎么做

RDB 索引有没有起作用,不要只看“页面能不能出来”。中式美食这种应用,至少要按几个固定数据集跑一遍。

验收项操作通过标准
菜谱标题搜索搜“红烧”能返回标题命中的菜谱
食材搜索搜“番茄”能命中原始名为“西红柿”的食材
分类列表点“家常菜”只返回该分类,并按sort_score排序
购物清单筛选只看未购买checked=0的食材排在列表里
勾选后刷新勾选一个食材再返回状态仍然保留
历史数据打开旧餐桌方案能按plan_id查出对应清单

对应到自动化或手工验证,可以这样写:

asyncfunctionverifyShoppingListQuery(repository:ShoppingListRepository){constbefore=awaitrepository.getUncheckedItems('plan-weekend-family');consttarget=before.find(item=>item.normalizedName==='番茄');if(!target){thrownewError('测试数据缺少番茄购物项');}awaitrepository.toggleChecked(target.id,true);constafter=awaitrepository.getUncheckedItems('plan-weekend-family');conststillExists=after.some(item=>item.id===target.id);if(stillExists){thrownewError('已勾选食材仍然出现在未购买列表中');}}

这类验证比单纯看页面靠谱。页面能刷新出来,只能说明 UI 没崩;数据能按条件稳定变化,才说明查询链路真的通了。

和 Preferences 的边界

中式美食后面会同时用 Preferences 和 RDB。我的边界会这样定:

数据建议存储原因
深色模式Preferences单个开关,读取频率高,关系简单
最近一次打开的 TabPreferences页面恢复用,不需要复杂查询
搜索历史短列表Preferences 或 RDB只存 10 条可用 Preferences,要排序统计就进 RDB
收藏菜谱RDB后面要按时间、分类、详情关联
浏览历史RDB要按时间倒序和菜谱关联
购物清单RDB有勾选、分类、来源、历史
菜谱详情RDB有食材、步骤、分类等关系

这里不做一刀切。轻状态继续轻,关系数据进关系库。这样后面文章写 Preferences、RDB、Repository,都不会互相打架。

这篇能沉淀出的工程规则

  1. 中式美食的本地数据不能只按页面拆,要按查询链路拆。
  2. RDB 索引先服务搜索、分类、购物清单、收藏和浏览历史这些高频入口。
  3. 食材搜索必须做归一化字段,否则“番茄/西红柿”这类问题迟早会出现。
  4. ArkUI 页面不要直接查 RDB,Repository 要挡在中间。
  5. 验收不能只看 UI,至少要验证写入、查询、勾选、刷新和历史恢复。

最后说一句

我判断中式美食后续鸿蒙开发最值得优先补的,不是把每个页面都重写一遍,而是先把本地数据底座拆稳。搜索、购物清单、收藏、浏览历史这些功能,本质上都是“页面入口不同,底层数据有关联”。RDB 索引这一步做扎实,后面做推荐排序、离线可用、历史记录和家庭协作,才不会每次都回头改数据结构。

如果你也在做类似的菜谱、清单或本地工具类应用,可以先问自己一个问题:这个数据只是当前页面临时用一下,还是以后要被搜索、排序、恢复、统计和关联?前者放页面状态没问题,后者就应该认真进入本地数据模型。