【私房菜集 HarmonyOS ArkTS 实战系列 11】Preferences 个人化状态:NoteService、忌口检测与详情提醒

第 10 篇拆完了烹饪计时器,菜谱已经从“能看”走到“能做”。继续往下,菜谱应用还需要承接两类更个人化的数据:一道菜做过之后留下的经验,以及浏览菜谱前就应该生效的忌口规则。本篇进入笔记与饮食偏好链路,重点看 NoteServicePreferenceServiceDietPreferencePageRecipeDetailPage 如何把个人状态重新接回菜谱详情页。

私房菜集饮食偏好页真实截图

一、个人化数据不能只停在设置页

很多工具型页面容易把“个人偏好”做成一个孤立入口:用户可以打开设置、点几个选项,但这些选项不会影响核心业务页面。对菜谱应用来说,这样的设计不够完整。

“私房菜集”里有两类个人化数据:

  • 菜谱笔记:针对某一道菜记录个人经验,比如火候、替换食材、下次改进。
  • 饮食偏好:针对全局设置忌口食材,浏览菜谱时及时提醒。

它们的使用位置并不相同。笔记绑定具体 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;
}

UserRecipeStaterecipeId 为主键,适合保存收藏、想做、最近浏览和笔记;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,而是把 avoidReminderEnabledthemeModeunitPreference 一并带回去。这是 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.etsMineRow() 进入 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/baseresources/dark 两套颜色中,和第 12 篇的主题资源统一管理。

十五、可落地的详情页入口改造

当前 NotePageNoteService 已经具备完整能力,详情页还可以继续补一个更直接的入口。一个低风险做法是在详情页底部操作区增加“笔记”按钮:


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(),并在应用重新启动后继续生效。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐