中式美食现在已经有菜谱、搜索、收藏、浏览历史、餐桌方案和买菜清单这些入口。页面能跑起来是一回事,数据以后能不能继续扩展是另一回事。如果菜谱一直只当成一个大对象缓存,后面做搜索筛选、购物清单、推荐排序、厨房步骤流时,很多逻辑会慢慢挤到页面和 ViewModel 里。
这篇就从真实项目里的 Recipe、FavoriteItem、HistoryItem、TablePlan 这些模型出发,拆一下中式美食后续如果迁到 HarmonyOS RDB,本地表应该怎么设计,Repository 边界应该怎么留,怎么验证这套设计不是纸上谈兵。

图 1|中式美食 RDB 菜谱表设计:菜谱、食材、步骤、用户行为和购物清单不要都挤在一个对象里,拆开以后搜索、清单和推荐才有继续扩展的空间。
本章位置
| 项目 | 内容 |
|---|---|
| 应用名称 | 中式美食 |
| 当前工程 | Flutter/HarmonyOS 上架项目 |
| 当前真实模型 | Recipe、FavoriteItem、HistoryItem、TablePlan |
| 当前真实文件 | lib/data/models/recipe.dart、lib/data/repositories/recipe_repository.dart、lib/data/datasources/recipe_local_datasource.dart |
| 本文重点 | 从当前缓存模型推导 HarmonyOS RDB 落库方案 |
| 读者收获 | 知道菜谱、食材、步骤、收藏浏览、购物清单应该怎么拆表 |
先看当前模型:Recipe 已经开始变重了
中式美食当前的菜谱模型已经不只是一个标题和图片:
dart
class Recipe {
const Recipe({
required this.id,
required this.name,
required this.coverImage,
required this.category,
required this.cuisine,
required this.difficulty,
required this.cookTimeMinutes,
required this.flavor,
required this.ingredients,
required this.steps,
required this.tips,
required this.tags,
required this.favoriteCount,
required this.viewCount,
required this.updatedAt,
});
final String id;
final String name;
final String coverImage;
final String category;
final String cuisine;
final String difficulty;
final int cookTimeMinutes;
final String flavor;
final List<String> ingredients;
final List<String> steps;
final List<String> tips;
final List<String> tags;
final int favoriteCount;
final int viewCount;
final DateTime updatedAt;
}
这个模型放在页面里读起来很舒服,但从数据层看,它已经有几个明显信号:
| 字段 | 当前作用 | 后续问题 |
|---|---|---|
ingredients |
展示食材 | 以后要生成买菜清单,不能一直当字符串数组 |
steps |
展示做法步骤 | 厨房执行页需要当前步骤、完成状态、预计耗时 |
tags |
搜索和推荐辅助 | 如果只塞在数组里,按标签统计和排序会变弱 |
favoriteCount |
展示热度 | 用户自己的收藏行为和全局热度要分开 |
viewCount |
展示浏览量 | 本机浏览历史、最近浏览和推荐排序要有独立行为记录 |
所以这篇不是说当前写法错了。当前阶段用模型和缓存先把产品跑通是对的。但下一阶段如果要写得更稳,就要把"展示模型"和"本地结构化数据"拆开。
为什么不能继续把菜谱当一个大 JSON
如果只是缓存首页推荐、详情页数据,一个大对象够用。中式美食现在已经不只是"看菜谱",它还有这些动作:
| 用户动作 | 数据层需要回答的问题 |
|---|---|
| 搜索"莲藕" | 哪些菜谱的食材或标签命中了莲藕 |
| 收藏一道菜 | 这道菜是否被当前用户收藏,什么时候收藏 |
| 最近浏览 | 哪些菜最近看过,重复浏览怎么计数 |
| 生成买菜清单 | 多道菜里的同名食材怎么合并 |
| 进入厨房步骤 | 当前执行到第几步,步骤是否完成 |
这些问题用一个 Recipe JSON 也能硬做,但后面会变成很多 map/filter/sort 堆在一起。短期快,长期不稳。RDB 的价值就在这里:把有关系的数据拆成表,让搜索、清单、推荐和验证都有明确入口。
我会先拆五张表,不一口气做复杂
中式美食的 RDB 切入点不应该一开始就做"大而全数据库"。先拆五张表就够:
| 表 | 解决什么问题 | 关键字段 |
|---|---|---|
recipe |
保存菜谱主体 | id、name、category、cuisine、difficulty、cook_time_minutes、flavor、cover_image、updated_at |
recipe_ingredient |
保存食材明细 | id、recipe_id、name、amount_text、sort_order |
recipe_step |
保存步骤 | id、recipe_id、content、tip、sort_order |
recipe_action |
保存用户行为 | id、recipe_id、action_type、count、last_at |
shopping_item |
保存买菜清单 | id、source_recipe_id、name、amount_text、checked、created_at |
这五张表的边界比较清楚:
- 菜谱主体负责"这道菜是什么"。
- 食材表负责"它需要什么"。
- 步骤表负责"怎么做"。
- 行为表负责"用户怎么用过它"。
- 购物清单负责"今天要买什么"。
Repository 边界要保留,页面不要直接碰 RDB
中式美食当前已经有 Repository 接口,比如:
dart
abstract interface class RecipeRepository {
Future<List<Recipe>> getRecommendedRecipes({bool forceRefresh = false});
Future<List<RecipeCategory>> getCategories({bool forceRefresh = false});
Future<Recipe?> getRecipeDetail(
String recipeId, {
bool forceRefresh = false,
});
}
本地数据源也已经有独立接口:
dart
abstract interface class RecipeLocalDatasource {
Future<CacheEntry<List<Recipe>>?> readRecommendedRecipes();
Future<void> cacheRecommendedRecipes(List<Recipe> recipes);
Future<CacheEntry<Recipe>?> readRecipeDetail(String recipeId);
Future<void> cacheRecipeDetail(Recipe recipe);
}
这个分层很好,后续迁到 HarmonyOS RDB 时,不要把页面改成直接查表。页面还是只找 ViewModel,ViewModel 还是只找 Repository。变化应该藏在 DataSource 里面。
可以把重构后的边界理解成这样:
| 层 | 负责什么 | 不应该做什么 |
|---|---|---|
| Page | 展示列表、详情、按钮状态 | 不写 SQL,不拼查询条件 |
| ViewModel | 管加载态、空态、错误态、用户动作 | 不知道数据来自 JSON、RDB 还是网络 |
| Repository | 聚合菜谱、收藏、历史、清单规则 | 不暴露表结构给页面 |
| RDB DataSource | 负责表读写、事务、迁移 | 不处理页面状态 |
这样改的好处是:如果先从缓存换到 RDB,页面可以少动。后面再加同步、推荐排序、视频拆菜草稿,也不会把页面越写越重。
食材为什么要独立成表
Recipe.ingredients 当前是 List<String>,展示没问题。但买菜清单会马上遇到三个问题:
- 同名食材来自多道菜,数量要合并。
- 用户勾选的是食材项,不是菜谱对象。
- 清单里要保留来源,用户才知道为什么要买这个。
所以食材表不能只是菜谱字段的一部分。至少要有:
text
recipe_ingredient
- id
- recipe_id
- name
- amount_text
- sort_order
后续从两道菜生成清单时,Repository 可以先按 name 分组,再决定是否合并 amount_text。如果数量暂时不能精确计算,也可以先保留文本,但数据来源一定要留住。
步骤为什么也要独立成表
厨房执行页关心的是"当前做到哪一步",这和详情页展示步骤不一样。
如果步骤只是 Recipe.steps 里的字符串数组,短期可以展示。但后面要支持这些能力时就会吃力:
- 当前步骤高亮。
- 下一步提示。
- 步骤完成状态。
- 每一步的小贴士。
- 视频拆菜后生成可编辑步骤草稿。
所以步骤表至少要有:
text
recipe_step
- id
- recipe_id
- content
- tip
- sort_order
这里的关键不是字段多复杂,而是 sort_order 必须稳定。厨房执行页最怕步骤顺序乱,用户照着做菜时会直接出错。
收藏和浏览不要混在 Recipe 里
当前项目里收藏和历史已经有独立模型:
dart
class FavoriteItem {
final String recipeId;
final String recipeName;
final String coverImage;
final DateTime favoritedAt;
final String category;
final int cookTimeMinutes;
final String difficulty;
}
dart
class HistoryItem {
final String recipeId;
final String recipeName;
final String coverImage;
final DateTime viewedAt;
final int viewCount;
}
这说明项目已经意识到:用户行为和菜谱主体不是一回事。
RDB 里可以把它们收敛到 recipe_action:
| 字段 | 说明 |
|---|---|
recipe_id |
行为属于哪道菜 |
action_type |
favorite、view、search_click、shopping_add |
count |
行为次数 |
last_at |
最近发生时间 |
后面做推荐排序时,这张表就能发挥作用。比如收藏加 5 分,最近浏览加 2 分,搜索点击加 3 分。用户看到推荐原因时,也能解释成"你最近常看这类菜""你收藏过相似做法",不会像黑盒推荐。
餐桌方案和购物清单怎么接上
当前项目里 TablePlan 已经有餐桌方案:
dart
class TablePlan {
const TablePlan({
required this.label,
required this.title,
required this.duration,
required this.dishes,
required this.route,
required this.tableware,
this.isLight = false,
});
final String label;
final String title;
final String duration;
final List<String> dishes;
final List<String> route;
final String tableware;
final bool isLight;
}
这块后续可以接购物清单。比如用户选了"家宴方案",系统把里面的菜转成食材项,再写入 shopping_item。
购物清单表不要只存一个字符串列表,至少要知道:
| 字段 | 说明 |
|---|---|
source_recipe_id |
食材来自哪道菜 |
name |
食材名 |
amount_text |
数量文本 |
checked |
是否已买 |
created_at |
加入时间 |
这样用户勾选"莲藕"以后,状态能恢复;删除某道菜来源时,也能只移除那道菜贡献的食材,不会把别的菜也需要的食材误删。
验证方式:不要只看页面能不能打开
RDB 设计最容易停在"表结构看起来对"。真正要验收,至少做下面几组动作:
| 操作 | 预期结果 |
|---|---|
| 首次启动应用 | 默认菜谱能写入或读取,首页不空白 |
| 搜索"莲藕" | 能查到食材或标签命中的菜谱 |
| 打开菜谱详情 | 能读取食材和步骤,顺序稳定 |
| 收藏一道菜 | recipe_action 里出现收藏行为 |
| 从餐桌方案生成清单 | shopping_item 出现对应食材 |
| 勾选一个食材 | 重进页面后仍保持勾选 |
| 清空本地数据 | 页面显示空状态,不崩溃 |
这里我建议把验证写成表,而不是只贴几张截图。截图只能证明"看起来有页面",验证表才能证明"数据链路是闭环的"。
这套设计的风险
| 风险 | 处理方式 |
|---|---|
| 一开始拆表太多,开发成本变高 | 先只做菜谱、食材、步骤、购物清单小切片 |
| 旧缓存数据迁移麻烦 | 保留 Repository 接口,DataSource 内部做迁移 |
| 食材数量文本不容易合并 | 第一版先按名称分组,数量保留文本,后续再做结构化数量 |
| 推荐排序过早复杂化 | 先记录行为,不急着上复杂模型 |
这也是为什么我不建议一上来就把中式美食改成很重的数据库工程。先把表边界立住,再用一个真实页面验证,比一次性铺开更稳。
本章小结
- 中式美食当前的
Recipe模型已经覆盖菜谱主体、食材、步骤、标签、热度等信息,适合展示,但不适合长期承担所有查询和行为记录。 - 下一阶段可以先用
recipe、recipe_ingredient、recipe_step、recipe_action、shopping_item五张表做 RDB 小切片。 - 页面不要直接碰 RDB。继续保留 Page、ViewModel、Repository、DataSource 的边界,后续重构成本会小很多。
如果你也在做菜谱、收藏、购物清单这类本地状态比较多的 App,可以先检查一个问题:你的页面是在展示数据,还是已经开始替数据层做查询、合并和排序了?如果是后者,就该考虑把数据结构往下沉了。