HarmonyOS RDB 索引实战:中式美食搜索和购物清单查询怎么避免扫全表
中式美食现在的搜索、分类、购物清单和收藏入口,页面上看起来只是几个筛选条件:输入关键词、点一个分类、勾几个食材、再从菜谱详情跳回来。但真正做本地数据时,问题不是“能不能筛出来”,而是“数据多了以后还敢不敢这么筛”。
我这次不把 RDB 当成一个概念来讲,而是拿中式美食里最容易变慢的几条查询链路拆开:搜索页要按标题和食材命中,列表页要按分类和排序切换,购物清单要按菜谱来源、分类和勾选状态过滤。用 HarmonyOS relationalStore 做这类数据时,索引不是锦上添花,而是让页面稳定的底座。
上面这张图对应的是一条很常见的产品链路:用户不是只输入一个词,他可能先看历史词,再点热门食材,再筛分类,最后进入详情或购物清单。页面动作很多,但数据层不能跟着乱。查询入口、模型字段、索引和验收要提前对齐。
这篇文章解决哪一类问题
| 位置 | 这次重点 |
|---|---|
| 产品入口 | 中式美食搜索页、分类列表页、购物清单页 |
| 当前代码证据 | SearchViewModel、ShoppingListViewModel、RecipeRepository、TablePlanRepository |
| 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 表不是先画大图,而是从查询反推
很多本地库设计容易犯一个错:先把表画得很完整,字段塞一堆,然后再想页面怎么查。中式美食更适合反过来做,先把查询列出来。
| 查询入口 | 用户动作 | 需要的字段 | 索引优先级 |
|---|---|---|---|
| 搜索页 | 输入菜名、食材、口味词 | title、ingredientName、keyword | 高 |
| 分类列表 | 点“家常菜/下饭菜/汤羹” | categoryId、sortScore | 高 |
| 购物清单 | 看某个餐桌方案的食材 | planId、recipeId | 高 |
| 购物清单 | 只看未购买食材 | checked、updatedAt | 中 |
| 最近浏览 | 按时间倒序看菜谱 | viewedAt、recipeId | 中 |
| 收藏页 | 看收藏菜谱 | favoriteAt、recipeId | 中 |
这张表定下来以后,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 )`;这里有几个取舍:
recipe.title要保留,搜索菜名会用到。ingredient.normalized_name要保留,同名食材合并不能只靠原始展示名。shopping_item.checked不能只放在页面里,否则用户退出再回来就丢状态。cover_uri只存图片地址或资源标识,不把大图塞进数据库。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)`];我不会一开始就给每个字段建索引。比如difficulty、cook_minutes、unit这些字段,除非后面真的有高频筛选,否则先不建。索引要服务入口,不要服务想象。
搜索页不要只查一个字段
中式美食的搜索不是普通文本搜索。用户搜“牛肉”,可能想找菜谱,也可能想找购物清单里的食材,还可能是从历史词进来的。页面可以只展示一个输入框,但数据层要把来源拆清楚。
typeSearchScene='recipeTitle'|'ingredient'|'shoppingItem';interfaceSearchHit{scene:SearchScene;targetId:string;title:string;subtitle:string;score:number;}我会让 Repository 返回统一的SearchHit,但内部查询分三路走:
| 来源 | 查询表 | 命中字段 | 展示意义 |
|---|---|---|---|
| 菜谱标题 | recipe | title | 用户可以直接进详情 |
| 菜谱食材 | ingredient | normalized_name | 用户知道哪道菜用到这个食材 |
| 购物项 | shopping_item | normalized_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;}写入ingredient和shopping_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 | 单个开关,读取频率高,关系简单 |
| 最近一次打开的 Tab | Preferences | 页面恢复用,不需要复杂查询 |
| 搜索历史短列表 | Preferences 或 RDB | 只存 10 条可用 Preferences,要排序统计就进 RDB |
| 收藏菜谱 | RDB | 后面要按时间、分类、详情关联 |
| 浏览历史 | RDB | 要按时间倒序和菜谱关联 |
| 购物清单 | RDB | 有勾选、分类、来源、历史 |
| 菜谱详情 | RDB | 有食材、步骤、分类等关系 |
这里不做一刀切。轻状态继续轻,关系数据进关系库。这样后面文章写 Preferences、RDB、Repository,都不会互相打架。
这篇能沉淀出的工程规则
- 中式美食的本地数据不能只按页面拆,要按查询链路拆。
- RDB 索引先服务搜索、分类、购物清单、收藏和浏览历史这些高频入口。
- 食材搜索必须做归一化字段,否则“番茄/西红柿”这类问题迟早会出现。
- ArkUI 页面不要直接查 RDB,Repository 要挡在中间。
- 验收不能只看 UI,至少要验证写入、查询、勾选、刷新和历史恢复。
最后说一句
我判断中式美食后续鸿蒙开发最值得优先补的,不是把每个页面都重写一遍,而是先把本地数据底座拆稳。搜索、购物清单、收藏、浏览历史这些功能,本质上都是“页面入口不同,底层数据有关联”。RDB 索引这一步做扎实,后面做推荐排序、离线可用、历史记录和家庭协作,才不会每次都回头改数据结构。
如果你也在做类似的菜谱、清单或本地工具类应用,可以先问自己一个问题:这个数据只是当前页面临时用一下,还是以后要被搜索、排序、恢复、统计和关联?前者放页面状态没问题,后者就应该认真进入本地数据模型。