【私房菜集 HarmonyOS ArkTS 实战系列 11】Preferences 个人化状态:NoteService、忌口检测与详情提醒
【私房菜集 HarmonyOS ArkTS 实战系列 11】Preferences 个人化状态:NoteService、忌口检测与详情提醒
第 10 篇拆完了烹饪计时器,菜谱已经从“能看”走到“能做”。继续往下,菜谱应用还需要承接两类更个人化的数据:一道菜做过之后留下的经验,以及浏览菜谱前就应该生效的忌口规则。本篇进入笔记与饮食偏好链路,重点看NoteService、PreferenceService、DietPreferencePage和RecipeDetailPage如何把个人状态重新接回菜谱详情页。

一、个人化数据不能只停在设置页
很多工具型页面容易把“个人偏好”做成一个孤立入口:用户可以打开设置、点几个选项,但这些选项不会影响核心业务页面。对菜谱应用来说,这样的设计不够完整。
“私房菜集”里有两类个人化数据:
- 菜谱笔记:针对某一道菜记录个人经验,比如火候、替换食材、下次改进。
- 饮食偏好:针对全局设置忌口食材,浏览菜谱时及时提醒。
它们的使用位置并不相同。笔记绑定具体 recipeId,属于单菜谱状态;忌口食材属于全局偏好,应该在任何菜谱详情页都能被检测到。因此这一篇的核心不是“做两个页面”,而是看项目如何区分菜谱级状态和应用级偏好。
二、源码对象总览
| 源码对象 | 作用 |
|---|---|
entry/src/main/ets/pages/NotePage.ets |
笔记编辑页,读取菜谱标题,限制笔记长度并保存。 |
entry/src/main/ets/services/NoteService.ets |
封装笔记读取和保存,复用用户菜谱状态仓。 |
entry/src/main/ets/pages/DietPreferencePage.ets |
饮食偏好页,负责忌口标签选择和提醒开关。 |
entry/src/main/ets/services/PreferenceService.ets |
封装偏好读取、忌口切换、提醒开关和详情页检测。 |
entry/src/main/ets/repositories/UserStateRepository.ets |
同时保存菜谱状态和全局偏好,是这一篇的本地状态基础。 |
entry/src/main/ets/pages/RecipeDetailPage.ets |
详情页读取 avoidedIngredients 并展示忌口提醒。 |
这条链路延续第 07 篇讲过的 Preferences 状态仓,但关注点更细:同一个仓储里既有 UserRecipeState,也有 UserPreference。
三、可复现工程上下文
这一篇可以直接围绕下面几组文件复现,不依赖远程服务,也不需要额外数据库:
| 检查目标 | 文件路径 |
|---|---|
| 笔记编辑页 | entry/src/main/ets/pages/NotePage.ets |
| 忌口设置页 | entry/src/main/ets/pages/DietPreferencePage.ets |
| 笔记服务 | entry/src/main/ets/services/NoteService.ets |
| 偏好服务 | entry/src/main/ets/services/PreferenceService.ets |
| 本地状态仓 | entry/src/main/ets/repositories/UserStateRepository.ets |
| 详情页提醒 | entry/src/main/ets/pages/RecipeDetailPage.ets |
本次实测环境如下:
| 项目 | 实测值 |
|---|---|
| 运行设备 | 本地 HarmonyOS 模拟器 |
| 应用包名 | com.lesson.myapplicationsfcj |
| 包配置版本 | AppScope/app.json5 -> versionName: 1.0.1, versionCode: 1000000 |
| SDK 版本 | targetSdkVersion: 6.1.0(23), compatibleSdkVersion: 6.0.2(22) |
| 工程模型 | modelVersion: 6.1.0, apiType: stageMode |
| 截图方式 | hdc shell snapshot_display |
| UI 树校验 | hdc shell uitest dumpLayout |
复现路径也很清晰:
我的 -> 饮食偏好 -> 选择忌口食材 -> 打开含对应食材的菜谱详情 -> 查看忌口提醒
笔记链路则是:
详情页携带 recipeId -> NotePage 读取菜谱标题 -> NoteService 保存 note -> UserStateRepository 写入 user_recipe_states
为了避免把这部分理解成普通设置页,先把数据流画出来:

四、用户状态模型:菜谱状态和全局偏好分开
先看模型定义:
export type ThemeMode = 'system' | 'light' | 'dark';
export interface UserRecipeState {
recipeId: string;
isFavorite: boolean;
isInTodoList: boolean;
todoOrder: number;
lastViewedAt: number;
note: string;
updatedAt: number;
}
export interface UserPreference {
avoidedIngredients: string[];
avoidReminderEnabled: boolean;
themeMode: ThemeMode;
unitPreference: string;
updatedAt: number;
}
UserRecipeState 以 recipeId 为主键,适合保存收藏、想做、最近浏览和笔记;UserPreference 不绑定菜谱,适合保存忌口、主题、单位偏好这类应用级设置。
这一步很关键。如果把忌口也塞进每道菜的状态里,就会出现重复写入、批量修改困难、详情页检测成本变高的问题。当前设计用两个模型划清边界,服务层就能按数据归属选择不同读写路径。
五、默认偏好:没有设置时也要有可用值
UserStateRepository.getPreference() 在本地没有记录时返回默认偏好:
getPreference(): UserPreference {
const raw = localStoreAdapter.loadString(USER_PREFERENCE_KEY);
if (raw) {
try {
return JSON.parse(raw) as UserPreference;
} catch (_) {
}
}
return {
avoidedIngredients: ['胡椒', '花生', '海鲜'],
avoidReminderEnabled: true,
themeMode: 'system',
unitPreference: 'metric',
updatedAt: Date.now()
};
}
这个默认值让饮食偏好页首次打开时不是空白状态,也让详情页可以直接验证提醒逻辑。默认包含 胡椒、花生、海鲜,对应截图里已经选中的三个标签。
默认值还带出一个工程细节:偏好读取不能因为 JSON 解析失败而让页面崩掉。这里捕获异常后回落到默认对象,牺牲的是损坏数据,保住的是页面可用性。
六、饮食偏好页:标签选择有上限
DietPreferencePage 的状态初始化很直接:
@State preference: UserPreference = {
avoidedIngredients: [],
avoidReminderEnabled: true,
themeMode: 'system',
unitPreference: 'metric',
updatedAt: 0
};
private ingredients: string[] = [
'胡椒', '花生', '海鲜', '牛肉', '羊肉', '香菜',
'蒜', '鸡蛋', '牛奶', '芒果', '香菇', '芝麻'
];
页面里可选项是固定候选列表,避免用户自由输入导致匹配不可控。比如“花生”“海鲜”这种词可以直接和菜谱用料名称做包含匹配。
切换逻辑里有一个明确上限:
private toggle(name: string): void {
if (!this.preference.avoidedIngredients.includes(name) &&
this.preference.avoidedIngredients.length >= 10) {
promptAction.showToast({ message: '最多选择10项' });
return;
}
this.preference = preferenceService.toggleAvoidedIngredient(name);
}
限制最多 10 项不是 UI 装饰,而是状态边界。忌口项太多会让详情提醒变成噪声,用户每打开一道菜都看到一长串提醒,反而降低提示价值。
七、PreferenceService:切换时不破坏其他偏好
服务层的切换实现如下:
toggleAvoidedIngredient(name: string): UserPreference {
const preference = userStateRepository.getPreference();
const exists = preference.avoidedIngredients.includes(name);
if (!exists && preference.avoidedIngredients.length >= 10) {
return preference;
}
const next = exists
? preference.avoidedIngredients.filter(item => item !== name)
: preference.avoidedIngredients.concat([name]);
return userStateRepository.savePreference({
avoidedIngredients: next,
avoidReminderEnabled: preference.avoidReminderEnabled,
themeMode: preference.themeMode,
unitPreference: preference.unitPreference,
updatedAt: preference.updatedAt
});
}
这里没有只保存 avoidedIngredients,而是把 avoidReminderEnabled、themeMode、unitPreference 一并带回去。这是 Preferences + JSON 模式下非常容易忽略的点:保存整块对象时,必须保留同一对象上的其他字段。
如果只写入忌口数组,主题模式和单位偏好会被重置。服务层的价值就在于把这种“局部修改、整体保存”的细节收住。
八、提醒开关:关闭后详情页不再检测
饮食偏好页底部有一个提醒开关:
Toggle({ type: ToggleType.Switch, isOn: this.preference.avoidReminderEnabled })
.selectedColor($r('app.color.success_green'))
.onChange((isOn: boolean) =>
this.preference = preferenceService.setAvoidReminderEnabled(isOn))
对应服务方法如下:
setAvoidReminderEnabled(enabled: boolean): UserPreference {
const preference = userStateRepository.getPreference();
return userStateRepository.savePreference({
avoidedIngredients: preference.avoidedIngredients,
avoidReminderEnabled: enabled,
themeMode: preference.themeMode,
unitPreference: preference.unitPreference,
updatedAt: preference.updatedAt
});
}
开关关闭后,不只是 UI 上不显示开关状态,详情页检测也会直接返回空数组。
九、详情页检测:偏好要回到菜谱决策
核心检测逻辑在 PreferenceService.detectAvoidedIngredients():
detectAvoidedIngredients(recipeId: string): string[] {
const preference = userStateRepository.getPreference();
if (!preference.avoidReminderEnabled) {
return [];
}
const recipe = recipeDataSource.getRecipe(recipeId);
if (!recipe) {
return [];
}
return preference.avoidedIngredients.filter(name =>
recipe.ingredients.some(item => item.name.includes(name)));
}
这个方法做了三层判断:
- 提醒开关关闭时直接返回空数组。
- 菜谱不存在时返回空数组,避免详情页异常。
- 用忌口名称匹配菜谱用料名称,命中后返回命中的忌口项。
在详情页渲染侧,只要 avoidedIngredients 不为空,就显示提醒:
if (this.data.avoidedIngredients.length > 0) {
Text(`忌口食材:${this.data.avoidedIngredients.join(' / ')}`)
.fontSize(13)
.fontColor($r('app.color.danger_red'))
.padding(10)
.backgroundColor($r('app.color.preference_warning_bg'))
.borderRadius(10)
}
这个提醒是非打断式的。它不会弹窗阻止用户看菜谱,也不会强行返回列表,而是在菜谱标题、描述和基础信息附近给出醒目的轻提示。
十、笔记页:最多 500 字,保存后回详情
笔记页通过路由参数接收 recipeId:
interface NoteParams {
recipeId?: string;
}
进入页面后读取菜谱详情和已有笔记:
async aboutToAppear() {
recipeService.init(getContext(this) as common.UIAbilityContext);
const params = router.getParams() as NoteParams;
this.recipeId = params.recipeId ?? '';
const detail = await recipeService.getRecipeDetail(this.recipeId);
if (detail === null) {
promptAction.showToast({ message: 'Recipe not found' });
router.back();
return;
}
const recipeDetail: RecipeDetailData = detail;
this.recipeTitle = recipeDetail.recipe.title;
this.note = await noteService.getNote(this.recipeId);
}
输入框限制 500 字:
TextArea({ text: this.note, placeholder: 'Write something here' })
.height(260)
.backgroundColor($r('app.color.card_bg'))
.borderRadius(14)
.onChange((value: string) => this.note = value.slice(0, 500))
Text(`${this.note.length}/500`)
保存动作同样回到详情页:
private async saveNote() {
await noteService.saveNote(this.recipeId, this.note);
promptAction.showToast({ message: 'Note saved' });
router.pushUrl({ url: AppRoutes.DETAIL, params: { recipeId: this.recipeId } });
}
从工程闭环看,笔记页的模型和服务都已经具备;当前详情页中尚未暴露进入 AppRoutes.NOTE 的按钮,这属于体验层可补强项。后续可以在详情页底部操作区加入“写笔记”入口,或者在菜谱信息区显示已有笔记摘要。
十一、NoteService:保存笔记不能丢收藏和想做状态
NoteService.saveNote() 没有直接构造一个只有 note 的对象:
async saveNote(recipeId: string, note: string): Promise<void> {
const state = userStateRepository.getState(recipeId);
userStateRepository.saveState({
recipeId: state.recipeId,
isFavorite: state.isFavorite,
isInTodoList: state.isInTodoList,
todoOrder: state.todoOrder,
lastViewedAt: state.lastViewedAt,
note: note.slice(0, 500).trim(),
updatedAt: state.updatedAt
});
}
这段代码和偏好服务一样,体现了局部更新时保留其他字段的原则。笔记只是 UserRecipeState 的一个字段,保存笔记时不能把收藏、想做、浏览时间覆盖掉。
另外,服务层再次执行 slice(0, 500).trim(),即使页面层已经限制过,也不会完全信任 UI 输入。这种双层限制能降低后续复用服务方法时的风险。
十二、可复现测试用例
这条链路适合用表格固定测试样例,把页面展示、状态读取、详情提醒和本地持久化一起验收。
| 用例 | 操作 | 预期结果 |
|---|---|---|
| 默认偏好读取 | 首次进入饮食偏好页 | 胡椒、花生、海鲜默认选中,提醒开关开启。 |
| 忌口上限 | 连续选择超过 10 个忌口项 | 第 11 个不写入,并提示“最多选择10项”。 |
| 关闭提醒 | 关闭提醒开关后打开详情页 | detectAvoidedIngredients() 返回空数组。 |
| 命中提醒 | 打开含花生的菜谱详情 | 详情页展示命中的忌口食材。 |
| 笔记截断 | 输入超过 500 字后保存 | 页面和服务层都截断到 500 字。 |
| 状态保护 | 保存笔记前后切换收藏/想做 | 收藏、想做、最近浏览字段不被覆盖。 |
如果要写自动化或手工验收记录,建议把这些用例固定到项目文档中。这样后续修改 UserStateRepository 或偏好模型时,不容易误伤已有状态。
本次模拟器 UI 树中能直接看到以下关键文本,说明页面状态来自真实运行界面:
| 页面 | UI 树关键文本 | 对应逻辑 |
|---|---|---|
| 我的页 | 饮食偏好、忌口食材和提醒 |
Index.ets 中 MineRow() 进入 AppRoutes.DIET_PREFERENCE。 |
| 饮食偏好页 | 胡椒 ×、花生 ×、海鲜 × |
UserStateRepository.getPreference() 的默认忌口项。 |
| 饮食偏好页 | 最多选择10项 |
DietPreferencePage.toggle() 的上限规则。 |
| 饮食偏好页 | 提醒方式 |
PreferenceService.setAvoidReminderEnabled() 的开关入口。 |
| 饮食偏好页 | 提醒偏好设置仅在本地生效,不会上传到服务器。 |
本地优先的数据边界提示。 |
对应的验证命令如下:
hdc shell uitest dumpLayout
hdc shell snapshot_display -f /data/local/tmp/11_diet_preference.jpeg
hdc file recv /data/local/tmp/11_diet_preference.jpeg ./SFCJ/screenshots/11_diet_preference_raw.jpeg
十三、运行验证
本次运行截图来自本地 HarmonyOS 模拟器,页面入口为“我的 -> 饮食偏好”。

验证结果如下:
- 饮食偏好页能正确读取默认忌口:胡椒、花生、海鲜。
- 忌口标签点击后会改变选中状态。
- 已选忌口达到 10 项时会阻止继续添加,并提示“最多选择10项”。
- 提醒开关开启时,
detectAvoidedIngredients()会继续执行食材匹配。 - 提醒开关关闭时,检测直接返回空数组。
- 笔记保存时会截断到 500 字并去除首尾空白。
- 保存笔记不会破坏收藏、想做、最近浏览等同一菜谱状态字段。
十四、问题复盘
这一篇的实现已经有清晰的数据边界,但也暴露出几个值得继续优化的点。
第一,NotePage 已经注册并实现,详情页操作区可以加入“笔记”按钮,并在已有笔记时显示摘要。这样可以把已经完成的路由、页面和服务能力全部显性化。
第二,忌口匹配使用 includes(),实现简单,但对同义词不敏感。例如“虾”“海鲜”之间不会自动关联。后续可以在偏好模型中增加 alias,或者在数据源侧维护食材标签。
第三,忌口提醒的样式可以继续资源化,把提醒背景、危险色和边框色放入 resources/base 与 resources/dark 两套颜色中,和第 12 篇的主题资源统一管理。
十五、可落地的详情页入口改造
当前 NotePage 和 NoteService 已经具备完整能力,详情页还可以继续补一个更直接的入口。一个低风险做法是在详情页底部操作区增加“笔记”按钮:
Button(this.data.note.length > 0 ? '查看笔记' : '写笔记')
.layoutWeight(1)
.height(44)
.fontColor($r('app.color.primary_orange'))
.backgroundColor($r('app.color.primary_orange_light'))
.onClick(() => router.pushUrl({
url: AppRoutes.NOTE,
params: { recipeId: this.data.recipe.id }
}))
如果担心底部按钮过多,也可以在详情页说明区域下方显示笔记摘要:
if (this.data.note.length > 0) {
Text(`我的笔记:${this.data.note}`)
.fontSize(13)
.fontColor($r('app.color.text_secondary'))
.maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
}
这类改造不会改变状态模型,只是把已经存在的路由和服务能力暴露出来。对于发布前质量检查,它属于“体验闭环补齐”,风险低、收益明确。
十六、小结
笔记和饮食偏好看起来都是“小功能”,但它们真正考验的是本地状态设计。
这一篇可以提炼出三条经验:
- 绑定菜谱的数据放进
UserRecipeState,例如笔记、收藏、想做、浏览时间。 - 面向全局的数据放进
UserPreference,例如忌口、提醒开关、主题和单位偏好。 - 局部更新整块 JSON 对象时,服务层必须保留其他字段,避免把用户状态覆盖掉。
下一篇继续进入设置与主题,看看 themeMode 如何从设置页落到系统 setColorMode(),并在应用重新启动后继续生效。
更多推荐



所有评论(0)