
中式美食现在的搜索、分类、购物清单和收藏入口页面上看起来只是几个筛选条件输入关键词、点一个分类、勾几个食材、再从菜谱详情跳回来。但真正做本地数据时问题不是“能不能筛出来”而是“数据多了以后还敢不敢这么筛”。我这次不把 RDB 当成一个概念来讲而是拿中式美食里最容易变慢的几条查询链路拆开搜索页要按标题和食材命中列表页要按分类和排序切换购物清单要按菜谱来源、分类和勾选状态过滤。用 HarmonyOS relationalStore 做这类数据时索引不是锦上添花而是让页面稳定的底座。上面这张图对应的是一条很常见的产品链路用户不是只输入一个词他可能先看历史词再点热门食材再筛分类最后进入详情或购物清单。页面动作很多但数据层不能跟着乱。查询入口、模型字段、索引和验收要提前对齐。这篇文章解决哪一类问题位置这次重点产品入口中式美食搜索页、分类列表页、购物清单页当前代码证据SearchViewModel、ShoppingListViewModel、RecipeRepository、TablePlanRepositoryHarmonyOS 目标能力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.checkedfalse,});finalStringname;finalbool checked;}classShoppingListState{constShoppingListState({requiredthis.plan,requiredthis.items,});finalTablePlanplan;finalListShoppingListItemitems;intgetcheckedCountitems.where((item)item.checked).length;ListShoppingListItemgetremainingItemsitems.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_TABLECREATE 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_TABLECREATE 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_TABLECREATE 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这些字段除非后面真的有高频筛选否则先不建。索引要服务入口不要服务想象。搜索页不要只查一个字段中式美食的搜索不是普通文本搜索。用户搜“牛肉”可能想找菜谱也可能想找购物清单里的食材还可能是从历史词进来的。页面可以只展示一个输入框但数据层要把来源拆清楚。typeSearchScenerecipeTitle|ingredient|shoppingItem;interfaceSearchHit{scene:SearchScene;targetId:string;title:string;subtitle:string;score:number;}我会让 Repository 返回统一的SearchHit但内部查询分三路走来源查询表命中字段展示意义菜谱标题recipetitle用户可以直接进详情菜谱食材ingredientnormalized_name用户知道哪道菜用到这个食材购物项shopping_itemnormalized_name用户能快速找到待买食材这样写的好处是页面不用关心表结构只关心结果怎么展示数据层也不会为了一个搜索框把所有数据揉成一坨大 JSON。RdbPredicates 查询示例下面这段是鸿蒙侧查询购物清单未勾选食材的写法。重点不是 API 调用本身而是查询条件要和索引顺序对应。importrelationalStorefromohos.data.relationalStore;asyncfunctionqueryUncheckedShoppingItems(store:relationalStore.RdbStore,planId:string):PromiseShoppingItemEntity[]{constpredicatesnewrelationalStore.RdbPredicates(shopping_item);predicates.equalTo(plan_id,planId);predicates.equalTo(checked,0);predicates.orderByDesc(updated_at);constresultSetawaitstore.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:Recordstring,string{西红柿:番茄,小葱:葱,土鸡蛋:鸡蛋,生抽酱油:生抽};functionnormalizeIngredientName(raw:string):string{consttrimmedraw.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):PromiseShoppingItemEntity[]{returnqueryUncheckedShoppingItems(this.store,planId);}asynctoggleChecked(itemId:string,checked:boolean):Promisevoid{constvalues:relationalStore.ValuesBucket{checked:checked?1:0,updated_at:Date.now()};constpredicatesnewrelationalStore.RdbPredicates(shopping_item);predicates.equalTo(id,itemId);awaitthis.store.update(values,predicates);}}ArkUI 页面只接触 ViewModelViewModel 只接触 Repository。这样以后要从本地 RDB 增加云同步也不用改页面。索引设计最容易踩的坑坑表现处理方式所有字段都建索引写入慢、迁移麻烦只给高频查询字段建页面里拼复杂条件ViewModel 越写越厚查询条件沉到 Repository原始食材名直接查同义词查不到写入前做normalized_name勾选状态只放页面退出再回来丢状态checked进 RDB大图塞数据库表变大、读取慢只存 URI 或资源标识搜索结果混成一种类型点进去不知道去哪返回SearchHit.scene我现在更倾向于把这些坑提前写进验收标准而不是等页面慢了再补。因为本地库一旦上线后面迁移字段和重建索引都会比一开始规划麻烦。查询验收怎么做RDB 索引有没有起作用不要只看“页面能不能出来”。中式美食这种应用至少要按几个固定数据集跑一遍。验收项操作通过标准菜谱标题搜索搜“红烧”能返回标题命中的菜谱食材搜索搜“番茄”能命中原始名为“西红柿”的食材分类列表点“家常菜”只返回该分类并按sort_score排序购物清单筛选只看未购买checked0的食材排在列表里勾选后刷新勾选一个食材再返回状态仍然保留历史数据打开旧餐桌方案能按plan_id查出对应清单对应到自动化或手工验证可以这样写asyncfunctionverifyShoppingListQuery(repository:ShoppingListRepository){constbeforeawaitrepository.getUncheckedItems(plan-weekend-family);consttargetbefore.find(itemitem.normalizedName番茄);if(!target){thrownewError(测试数据缺少番茄购物项);}awaitrepository.toggleChecked(target.id,true);constafterawaitrepository.getUncheckedItems(plan-weekend-family);conststillExistsafter.some(itemitem.idtarget.id);if(stillExists){thrownewError(已勾选食材仍然出现在未购买列表中);}}这类验证比单纯看页面靠谱。页面能刷新出来只能说明 UI 没崩数据能按条件稳定变化才说明查询链路真的通了。和 Preferences 的边界中式美食后面会同时用 Preferences 和 RDB。我的边界会这样定数据建议存储原因深色模式Preferences单个开关读取频率高关系简单最近一次打开的 TabPreferences页面恢复用不需要复杂查询搜索历史短列表Preferences 或 RDB只存 10 条可用 Preferences要排序统计就进 RDB收藏菜谱RDB后面要按时间、分类、详情关联浏览历史RDB要按时间倒序和菜谱关联购物清单RDB有勾选、分类、来源、历史菜谱详情RDB有食材、步骤、分类等关系这里不做一刀切。轻状态继续轻关系数据进关系库。这样后面文章写 Preferences、RDB、Repository都不会互相打架。这篇能沉淀出的工程规则中式美食的本地数据不能只按页面拆要按查询链路拆。RDB 索引先服务搜索、分类、购物清单、收藏和浏览历史这些高频入口。食材搜索必须做归一化字段否则“番茄/西红柿”这类问题迟早会出现。ArkUI 页面不要直接查 RDBRepository 要挡在中间。验收不能只看 UI至少要验证写入、查询、勾选、刷新和历史恢复。最后说一句我判断中式美食后续鸿蒙开发最值得优先补的不是把每个页面都重写一遍而是先把本地数据底座拆稳。搜索、购物清单、收藏、浏览历史这些功能本质上都是“页面入口不同底层数据有关联”。RDB 索引这一步做扎实后面做推荐排序、离线可用、历史记录和家庭协作才不会每次都回头改数据结构。如果你也在做类似的菜谱、清单或本地工具类应用可以先问自己一个问题这个数据只是当前页面临时用一下还是以后要被搜索、排序、恢复、统计和关联前者放页面状态没问题后者就应该认真进入本地数据模型。