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,因为用户一进来就能看到列表,也能点选。

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 表不是先画大图,而是从查询反推

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

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

这张表定下来以后,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
)`;

这里有几个取舍:

  1. recipe.title 要保留,搜索菜名会用到。
  2. ingredient.normalized_name 要保留,同名食材合并不能只靠原始展示名。
  3. shopping_item.checked 不能只放在页面里,否则用户退出再回来就丢状态。
  4. cover_uri 只存图片地址或资源标识,不把大图塞进数据库。
  5. 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)`
];

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

搜索页不要只查一个字段

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

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;
}

写入 ingredientshopping_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,都不会互相打架。

这篇能沉淀出的工程规则

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

最后说一句

我判断中式美食后续鸿蒙开发最值得优先补的,不是把每个页面都重写一遍,而是先把本地数据底座拆稳。搜索、购物清单、收藏、浏览历史这些功能,本质上都是"页面入口不同,底层数据有关联"。RDB 索引这一步做扎实,后面做推荐排序、离线可用、历史记录和家庭协作,才不会每次都回头改数据结构。
如果你也在做类似的菜谱、清单或本地工具类应用,可以先问自己一个问题:这个数据只是当前页面临时用一下,还是以后要被搜索、排序、恢复、统计和关联?前者放页面状态没问题,后者就应该认真进入本地数据模型。

相关推荐
星释1 小时前
鸿蒙智能体开发实战:26.Skill 与插件协同开发
microsoft·华为·ai·harmonyos·鸿蒙·智能体
大师兄66682 小时前
HarmonyOS7 module.json5 配置全解:Ability 注册与权限
arkui·实战案例·harmonyos7
星释2 小时前
鸿蒙智能体开发实战:32.鸿蒙壁纸大师 - API认证与会话管理
华为·ai·harmonyos·智能体
花开彼岸天~2 小时前
鸿蒙实战:导航栏与顶部栏设计规范 HmTitleBar
华为·harmonyos·鸿蒙系统·设计规范
星释2 小时前
鸿蒙智能体开发实战:33.鸿蒙壁纸大师 - 多轮交互工作流设计
华为·交互·harmonyos·鸿蒙
●VON2 小时前
HarmonyKit | 鸿蒙新特性:router 导航 API 从 pushUrl 到 UIContext 的演进
算法·华为·交互·harmonyos
星释2 小时前
鸿蒙智能体开发实战:31.鸿蒙壁纸大师 - 环境搭建与基础配置
算法·华为·ai·harmonyos·鸿蒙
Helen_cai2 小时前
OpenHarmony 相册图片选择封装全场景开发(API Version23 + 适配版)
华为·harmonyos
特立独行的猫a3 小时前
鸿蒙PC开源软件迁移与多语言三方库移植实战课程讲稿(三、命令行工具移植实战——以OpenSSH为主线)
华为·harmonyos·三方库移植·鸿蒙pc