中式美食现在的搜索、分类、购物清单和收藏入口,页面上看起来只是几个筛选条件:输入关键词、点一个分类、勾几个食材、再从菜谱详情跳回来。但真正做本地数据时,问题不是"能不能筛出来",而是"数据多了以后还敢不敢这么筛"。
我这次不把 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,因为用户一进来就能看到列表,也能点选。
dart
class ShoppingListItem {
const ShoppingListItem({
required this.name,
this.checked = false,
});
final String name;
final bool checked;
}
class ShoppingListState {
const ShoppingListState({
required this.plan,
required this.items,
});
final TablePlan plan;
final List<ShoppingListItem> items;
int get checkedCount => items.where((item) => item.checked).length;
List<ShoppingListItem> get remainingItems =>
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 侧表结构可以这样拆
下面是我会给中式美食鸿蒙版先落地的几张表。字段不追求一次到位,但要保证后面能扩展。
ts
const CREATE_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
)`;
const CREATE_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)
)`;
const CREATE_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给推荐、热度或人工排序留口子,不要让列表排序只能靠写死规则。
哪些字段值得建索引
索引不是越多越好。建太多索引会拖慢写入,也会让迁移变复杂。对中式美食这种本地数据场景,我会先建下面这些。
ts
const CREATE_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 这些字段,除非后面真的有高频筛选,否则先不建。索引要服务入口,不要服务想象。
搜索页不要只查一个字段
中式美食的搜索不是普通文本搜索。用户搜"牛肉",可能想找菜谱,也可能想找购物清单里的食材,还可能是从历史词进来的。页面可以只展示一个输入框,但数据层要把来源拆清楚。
ts
type SearchScene = 'recipeTitle' | 'ingredient' | 'shoppingItem';
interface SearchHit {
scene: SearchScene;
targetId: string;
title: string;
subtitle: string;
score: number;
}
我会让 Repository 返回统一的 SearchHit,但内部查询分三路走:
| 来源 | 查询表 | 命中字段 | 展示意义 |
|---|---|---|---|
| 菜谱标题 | recipe |
title |
用户可以直接进详情 |
| 菜谱食材 | ingredient |
normalized_name |
用户知道哪道菜用到这个食材 |
| 购物项 | shopping_item |
normalized_name |
用户能快速找到待买食材 |
这样写的好处是,页面不用关心表结构,只关心结果怎么展示;数据层也不会为了一个搜索框,把所有数据揉成一坨大 JSON。
RdbPredicates 查询示例
下面这段是鸿蒙侧查询购物清单未勾选食材的写法。重点不是 API 调用本身,而是查询条件要和索引顺序对应。
ts
import relationalStore from '@ohos.data.relationalStore';
async function queryUncheckedShoppingItems(
store: relationalStore.RdbStore,
planId: string
): Promise<ShoppingItemEntity[]> {
const predicates = new relationalStore.RdbPredicates('shopping_item');
predicates.equalTo('plan_id', planId);
predicates.equalTo('checked', 0);
predicates.orderByDesc('updated_at');
const resultSet = await store.query(predicates, [
'id',
'recipe_id',
'ingredient_name',
'amount_text',
'category',
'checked',
'updated_at'
]);
const items: 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();
return items;
}
这个查询对应前面的索引:
sql
CREATE INDEX IF NOT EXISTS idx_shopping_plan_checked
ON shopping_item(plan_id, checked, updated_at DESC);
为什么索引顺序这么排?因为页面最先限定的是 plan_id,然后限定 checked,最后按 updated_at 排序。索引顺序如果反过来,查询不一定能吃满索引收益。
搜索关键词要先归一化
中式美食里食材名称很容易出现变体:番茄/西红柿、葱姜蒜/姜葱蒜、鸡蛋/土鸡蛋。RDB 索引只负责加速查询,不负责理解同义词。所以在写入前要做一层归一化。
ts
const ingredientAlias: Record<string, string> = {
'西红柿': '番茄',
'小葱': '葱',
'土鸡蛋': '鸡蛋',
'生抽酱油': '生抽'
};
function normalizeIngredientName(raw: string): string {
const trimmed = raw.trim();
if (!trimmed) {
return '';
}
return ingredientAlias[trimmed] ?? trimmed;
}
写入 ingredient 和 shopping_item 时保存两份:
| 字段 | 用途 |
|---|---|
ingredient_name |
原始展示名,给用户看 |
normalized_name |
查询、合并、索引用 |
这一步很小,但能避免后面很多麻烦。否则用户搜"番茄",页面却只存了"西红柿",你再怎么建索引都查不到。
从当前代码迁移时,不要一次性推倒
当前中式美食的代码已经有 Repository 和 ViewModel 分层,这一点要保留。迁移到 HarmonyOS RDB 时,不应该让 ArkUI 页面直接拿 RdbStore 查表。
更稳的分层是这样:
ts
class ShoppingListRepository {
constructor(private readonly store: relationalStore.RdbStore) {}
async getUncheckedItems(planId: string): Promise<ShoppingItemEntity[]> {
return queryUncheckedShoppingItems(this.store, planId);
}
async toggleChecked(itemId: string, checked: boolean): Promise<void> {
const values: relationalStore.ValuesBucket = {
checked: checked ? 1 : 0,
updated_at: Date.now()
};
const predicates = new relationalStore.RdbPredicates('shopping_item');
predicates.equalTo('id', itemId);
await this.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 查出对应清单 |
对应到自动化或手工验证,可以这样写:
ts
async function verifyShoppingListQuery(repository: ShoppingListRepository) {
const before = await repository.getUncheckedItems('plan-weekend-family');
const target = before.find(item => item.normalizedName === '番茄');
if (!target) {
throw new Error('测试数据缺少番茄购物项');
}
await repository.toggleChecked(target.id, true);
const after = await repository.getUncheckedItems('plan-weekend-family');
const stillExists = after.some(item => item.id === target.id);
if (stillExists) {
throw new Error('已勾选食材仍然出现在未购买列表中');
}
}
这类验证比单纯看页面靠谱。页面能刷新出来,只能说明 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 索引这一步做扎实,后面做推荐排序、离线可用、历史记录和家庭协作,才不会每次都回头改数据结构。
如果你也在做类似的菜谱、清单或本地工具类应用,可以先问自己一个问题:这个数据只是当前页面临时用一下,还是以后要被搜索、排序、恢复、统计和关联?前者放页面状态没问题,后者就应该认真进入本地数据模型。