购物清单是菜谱应用中最容易被低估的功能。界面通常只有名称、用量、勾选和删除,看起来比菜谱详情简单得多;但一旦同一种食材来自多道菜、用户又能手动添加,系统就必须回答:鸡蛋和“土鸡蛋”是否相同、500 克与 2 斤如何合并、删除最后一项后是否保持空清单、勾选失败是否还能提示成功、从菜谱批量加入时如何保留来源。

本文基于“健康菜谱助手”1.0.5 的真实 HarmonyOS ArkTS 工程展开。ShoppingListPage.ets 内置 20 条默认采购项,支持按分类和按状态查看、勾选、删除、重置与手动添加;RecipeDetailPage.ets 还能把当前菜谱食材批量加入清单。但手动添加不查重,菜谱批量加入只按原始名称精确比较,命中后直接跳过,数量与来源都不会合并;当用户删除全部项目后再次进入页面,aboutToAppear() 又会重新写入 20 条默认项,导致“空清单”无法持久存在。

购物清单的真源不是页面里的一组字符串,而是由稳定 itemId、规范 ingredientKey、数量结构、来源集合和购买状态组成的可持久化记录。

HEALTHY-RECIPE-ONE7-SHOPPING-LIST-20260730

HarmonyOS ArkTS 购物清单实战封面

本文将完成以下内容:

  • 还原默认项、手动添加、菜谱批量加入与 Preferences 保存链路。
  • 解释为什么删除全部后会被默认数据重新填满。
  • 统一页面间重复定义的 ShoppingItem。
  • 用 ingredientKey 去重,保留 displayName 与别名。
  • 设计可合并与不可合并的数量策略。
  • 让勾选、删除、重置、清空和撤销具有明确语义。
  • 让保存结果可观察,避免 UI 提示假成功。
  • 给出空态、多设备、重复点击和数据迁移测试。

一、真实页面目前有哪些数据

页面内定义的模型是:

interface ShoppingItem {
  id: number
  name: string
  amount: string
  category: string
  checked: boolean
}

页面状态包括:

@StorageLink('shoppingItems') items: ShoppingItem[] = []
@State showAddPanel: boolean = false
@State newName: string = ''
@State newAmount: string = ''
@State newCategory: string = '蔬菜'
@State nextId: number = 21
@State viewMode: number = 0

默认清单有 20 项,覆盖肉类、蛋奶、豆制品、蔬菜和调味料。例如鸡胸肉 500 克、鸡蛋 10 个、牛奶 1 升、嫩豆腐 2 盒。页面还提供 8 个分类:肉类、蛋奶、豆制品、蔬菜、调味料、水果、主食和其他。

这套模型足以绘制列表,却缺少跨功能一致性所需的 ingredientKey、来源、创建时间和结构化数量。

二、删除全部后为什么又出现默认清单

进入页面时执行:

aboutToAppear(): void {
  if (this.items.length === 0) {
    this.items = ShoppingListPage.getDefaultItems()
    UserDataManager.persistShoppingItems(this.items)
  }
  // 计算 nextId
}

items.length === 0 同时表示两种完全不同的状态:

  1. 第一次安装,尚未初始化购物清单。
  2. 用户主动删除了全部项目,希望保留空清单。

页面无法区分二者,于是第二种也被当作第一次启动。解决方式不是取消默认数据,而是保存独立初始化标记:

interface ShoppingListStore {
  schemaVersion: number
  initialized: boolean
  items: ShoppingItem[]
  updatedAt: number
}

只有 initialized === false 才写默认项;initialized === true && items.length === 0 应渲染空态。

三、ShoppingItem 不应在两个页面重复定义

ShoppingListPage.ets 和 RecipeDetailPage.ets 都声明了各自的 ShoppingItem。字段暂时相同,但未来某一页增加 sourceRecipeIds 或 quantity 后,另一个页面很容易继续写旧结构。

应把模型移到共享文件:

export interface ShoppingItem {
  itemId: string
  ingredientKey: string
  displayName: string
  category: IngredientCategory
  quantity: IngredientQuantity
  checked: boolean
  sourceRecipeIds: number[]
  createdAt: number
  updatedAt: number
}

页面只导入模型,写入逻辑统一由 ShoppingListService 处理。

四、itemId 和 ingredientKey 解决的是不同问题

当前 id 既用于列表 key,也用于查找项目。手动新增使用递增整数,菜谱批量加入使用 Date.now() + addedCount,两个入口的 ID 策略不同。

建议明确区分:

  • itemId:标识一条清单记录,稳定且唯一。
  • ingredientKey:标识“这是哪一种食材”,用于去重和合并。
function createItemId(now: number, sequence: number): string {
  return `shopping_${now}_${sequence}`
}

如果产品规定同一种食材只能有一条记录,ingredientKey 也可成为业务唯一键;itemId 仍适合 UI key 和迁移。

五、名称必须先规范化再去重

当前菜谱加入逻辑是:

const existed = nextItems.find(
  (item: ShoppingItem) => item.name === ingredient.name
)
if (existed) {
  continue
}

手动添加则完全不检查重复。因此“西红柿”和“番茄”、“鸡蛋”和“ 鸡蛋 ”可能分别存在。

可以使用本地别名表生成 ingredientKey:

const INGREDIENT_KEY_MAP: Record<string, string> = {
  '西红柿': 'tomato',
  '番茄': 'tomato',
  '小番茄': 'tomato',
  '马铃薯': 'potato',
  '土豆': 'potato',
  '嫩豆腐': 'tofu_tender',
  '老豆腐': 'tofu_firm'
}

function normalizeIngredientName(input: string): string {
  return input.trim().replace(/\s+/g, '')
}

function resolveIngredientKey(input: string): string {
  const name = normalizeIngredientName(input)
  return INGREDIENT_KEY_MAP[name] ?? `custom:${name}`
}

注意嫩豆腐与老豆腐并不等价,不能为了去重把所有“豆腐”合并。

购物项目从输入到持久化的合并流程

六、数量字符串为什么难以合并

当前 amount 是自由文本,例如:

500克
10个
1升
2盒
适量

菜谱详情发现同名项目时直接跳过,因此已有“鸡蛋 10 个”,再加入需要 2 个鸡蛋的菜谱,清单仍显示 10 个;系统既没有合并,也没有记录第二道菜的来源。

数量应至少拆成数值、单位和原始文本:

export interface IngredientQuantity {
  value?: number
  unit?: 'g' | 'kg' | 'ml' | 'l' | 'piece' | 'box' | 'bag'
  rawText: string
}

可解析的同单位数量才能直接相加;适量、少许 等保持原始文本,不做伪精确计算。

七、建立安全的数量解析器

const UNIT_ALIASES: Record<string, IngredientQuantity['unit']> = {
  '克': 'g',
  '千克': 'kg',
  '公斤': 'kg',
  '毫升': 'ml',
  '升': 'l',
  '个': 'piece',
  '盒': 'box',
  '袋': 'bag'
}

function parseQuantity(input: string): IngredientQuantity {
  const rawText = input.trim() || '适量'
  const match = rawText.match(/^(\d+(?:\.\d+)?)\s*(克|千克|公斤|毫升|升|个|盒|袋)$/)
  if (!match) return { rawText }
  return {
    value: Number(match[1]),
    unit: UNIT_ALIASES[match[2]],
    rawText
  }
}

解析失败不等于输入错误。页面可以保存 rawText,但合并时显示“存在多个用量说明”。

八、统一单位后再合并

function toBaseQuantity(quantity: IngredientQuantity): IngredientQuantity {
  if (quantity.value === undefined || quantity.unit === undefined) return quantity
  if (quantity.unit === 'kg') {
    return { value: quantity.value * 1000, unit: 'g', rawText: quantity.rawText }
  }
  if (quantity.unit === 'l') {
    return { value: quantity.value * 1000, unit: 'ml', rawText: quantity.rawText }
  }
  return quantity
}

只有 base unit 相同才相加:

function mergeQuantity(
  current: IngredientQuantity,
  incoming: IngredientQuantity
): IngredientQuantity {
  const left = toBaseQuantity(current)
  const right = toBaseQuantity(incoming)
  if (left.value !== undefined && right.value !== undefined && left.unit === right.unit) {
    const value = left.value + right.value
    return { value, unit: left.unit, rawText: `${value}${formatUnit(left.unit)}` }
  }
  return {
    rawText: [current.rawText, incoming.rawText]
      .filter((value, index, all) => all.indexOf(value) === index)
      .join(' + ')
  }
}

“2 个 + 500 克”不能强行相加,应保留两个说明并提示用户确认。

九、来源信息决定了能否正确撤销

从一道菜加入食材后,用户可能又从另一道菜加入同一种食材。只保存合并后的总量,后续移除第一道菜时无法知道应该扣掉多少。

更完整的做法是保存来源明细:

export interface ShoppingSource {
  sourceId: string
  recipeId?: number
  quantity: IngredientQuantity
  addedAt: number
}

export interface ShoppingItem {
  itemId: string
  ingredientKey: string
  displayName: string
  category: IngredientCategory
  checked: boolean
  sources: ShoppingSource[]
}

页面展示数量由 sources 汇总。删除某个来源不会误伤其他菜谱需求,手动添加也可使用 sourceId = manual_xxx。

十、添加操作应该返回结构化结果

当前页面添加后直接 Toast。服务层可以返回:

export interface AddShoppingResult {
  item: ShoppingItem
  action: 'created' | 'merged' | 'unchanged'
  mergedSourceCount: number
}

对应提示更准确:

  • created:已添加鸡蛋。
  • merged:已合并鸡蛋用量,共来自 2 道菜。
  • unchanged:这项需求已在清单中。

十一、勾选更新应保持稳定 key

当前勾选时复制数组和目标对象,这符合 ArkUI 状态更新习惯:

this.items = this.items.map((item) =>
  item.id === id ? { ...item, checked: !item.checked } : item
)

但列表 key 使用 ${item.id}_${item.checked},勾选后 key 变化,会让 ArkUI 把它视作新节点。更稳定的 key 是 itemId:

ForEach(this.items, (item: ShoppingItem) => {
  this.ShoppingRow(item)
}, (item: ShoppingItem) => item.itemId)

状态变化不应改变身份,尤其当行内未来增加编辑输入框或动画时。

十二、删除按钮与整行点击要隔离

当前每一行都可点击切换 checked,行内又有“删除”文字。必须在真机上验证子控件点击是否会同时触发行点击;即使当前 ArkUI 事件行为未冒泡,交互区域也容易误触。

更清晰的结构是:

  • 左侧独立勾选按钮只负责 toggle。
  • 中间内容区域打开编辑。
  • 右侧删除图标只负责删除。
  • 不让整行同时承担三个命令。

删除应使用熟悉的垃圾桶图标并提供可访问标签,而不是把“删除”文字挤在用量旁边。

十三、删除和清空要支持撤销

当前删除立即持久化,已购买视图中删除甚至没有 Toast。对购物清单这种高频误触场景,短时撤销比每次确认对话框更顺畅:

interface DeleteSnapshot {
  item: ShoppingItem
  index: number
  deletedAt: number
}

删除后显示“已删除大葱 · 撤销”。撤销时按原 index 插回并重新保存。清空全部属于更大范围动作,应二次确认或提供更长撤销窗口。

十四、“重置”和“清空”必须是两个命令

顶部当前文案是“重置”,实际行为只是把全部 checked 改成 false,并不删除项目。建议改名为“全部标为待购买”。

产品可提供三个明确命令:

命令结果是否保留项目
全部标为待购买checked=false是
清除已购买删除 checked=true部分保留
清空清单删除全部否

命令名称必须与结果一致。

十五、默认数据初始化与用户清空分离

初始化逻辑可以放进仓库:

async function loadShoppingStore(): Promise<ShoppingListStore> {
  const stored = await repository.read()
  if (stored) return migrateStore(stored)
  const initial: ShoppingListStore = {
    schemaVersion: 2,
    initialized: true,
    items: createDefaultItems(),
    updatedAt: Date.now()
  }
  await repository.save(initial)
  return initial
}

只要存储中存在 initialized: true,空数组就是合法用户结果。

十六、nextId 不应由页面维护

页面进入时扫描 maxId,再设置 nextId = maxId + 1。这对单页面递增 ID 有效,但菜谱详情页同时使用时间戳 ID,ID 策略分裂。

ID 生成应放在服务层;页面只提交名称、数量和分类:

export interface AddManualItemCommand {
  name: string
  amountText: string
  category: IngredientCategory
}

服务层创建 itemId、ingredientKey、sourceId 和时间戳。

十七、分类应由领域常量约束

当前 category 是任意 string,页面用 switch 返回 emoji 与颜色。旧数据或拼写错误会落到“其他”,但没有明确迁移。

export type IngredientCategory =
  | '肉类' | '蛋奶' | '豆制品' | '蔬菜'
  | '调味料' | '水果' | '主食' | '其他'

分类配置集中定义:

interface CategoryMeta {
  value: IngredientCategory
  emoji: string
  color: string
  order: number
}

页面按 order 排序,不再依赖项目第一次出现的顺序。

十八、按分类和按状态都是派生视图

当前 build() 中多次执行 filter() 和 getItemsByCategory()。数据量 20 时没有性能压力,但逻辑重复使空态和计数容易不一致。

可以一次生成视图模型:

interface ShoppingViewState {
  total: number
  checked: number
  pending: ShoppingItem[]
  completed: ShoppingItem[]
  groups: CategoryGroup[]
}

function buildShoppingView(items: ShoppingItem[]): ShoppingViewState {
  const pending = items.filter((item) => !item.checked)
  const completed = items.filter((item) => item.checked)
  return {
    total: items.length,
    checked: completed.length,
    pending,
    completed,
    groups: groupByCategory(items)
  }
}

进度条、百分比、状态列表和分类标题都消费同一份派生结果。

十九、进度计算要处理空清单

当前代码已在 items 为空时返回 0%,这是正确的边界:

const percent = items.length > 0
  ? Math.round(checkedCount / items.length * 100)
  : 0

需要补充产品语义:空清单不应显示“采购进度 0 / 0”后留下一大片空白,而应显示“清单是空的,从菜谱加入食材或手动添加”。

二十、保存结果不能被吞掉

UserDataManager.persistShoppingItems() 最终调用返回 void 的 persist();prefs 未初始化或写入异常时直接结束,页面仍提示“已添加”“已删除”或“已重置”。

仓库应返回:

export interface SaveShoppingResult {
  ok: boolean
  revision?: number
  reason?: 'not_initialized' | 'serialize_failed' | 'write_failed'
}

页面更新后保存失败,可以回滚旧数组,或保留本次内存状态并显示“未保存,点击重试”。不能只用成功 Toast。

二十一、避免连续操作造成旧写覆盖新写

用户可能快速勾选三项。若 flush() 异步完成顺序不稳定,较旧快照可能覆盖较新快照。可使用串行保存队列或 revision:

interface ShoppingListStore {
  schemaVersion: number
  revision: number
  initialized: boolean
  items: ShoppingItem[]
  updatedAt: number
}

每次变更 revision + 1,仓库只接受不小于当前 revision 的写入;简单版本也可让所有保存进入同一 Promise 链。

二十二、添加面板需要输入状态与错误状态

当前名称为空时 Toast,数量为空使用“适量”。可以进一步明确:

interface AddItemDraft {
  name: string
  amountText: string
  category: IngredientCategory
  nameError: string
  amountWarning: string
  isSaving: boolean
}

重复名称不应无声新增,可展示“清单已有番茄,是否合并用量”;保存期间禁用确定按钮,避免重复提交。

二十三、页面、服务、仓库如何分工

购物清单的模型、服务、仓库与 ArkUI 分层

23.1 ShoppingListPage

负责模式切换、弹层、输入草稿、展示列表和操作反馈。

23.2 ShoppingListService

负责名称归一、去重、数量合并、来源管理、toggle、delete、reset 和 clear。

23.3 ShoppingListRepository

负责 Preferences 的读取、迁移、保存、revision 和错误映射。

23.4 Shared Model

统一 ShoppingListPage 与 RecipeDetailPage 的数据合同,禁止页面复制接口。

二十四、旧数据迁移

现有数据只有 id/name/amount/category/checked。迁移时:

function migrateLegacyItem(item: LegacyShoppingItem, index: number): ShoppingItem {
  const now = Date.now()
  return {
    itemId: `legacy_${item.id}_${index}`,
    ingredientKey: resolveIngredientKey(item.name),
    displayName: normalizeIngredientName(item.name),
    category: normalizeCategory(item.category),
    quantity: parseQuantity(item.amount),
    checked: item.checked,
    sources: [{
      sourceId: `legacy_source_${item.id}_${index}`,
      quantity: parseQuantity(item.amount),
      addedAt: now
    }]
  }
}

迁移后若多个旧项目得到同一 ingredientKey,应谨慎合并:同单位相加,不同单位保留来源明细;checked 可采用“全部来源都已购买才为 true”的规则,或迁移时保留为项目级状态。

二十五、多设备与安全区

手机适合单列分类卡片和底部添加面板。浮动添加按钮当前位于 bottom 24,可能贴近系统手势区;应使用系统导航避让区与 28vp 中较大值。

平板和 2in1 可左侧显示分类,右侧显示待购买/已购买列表;添加面板使用限宽对话框而非拉满全屏。鼠标环境下要提供 hover、焦点和键盘提交;长食材名称与长数量文本必须 maxLines + ellipsis,不得挤掉删除按钮。

二十六、回归测试矩阵

编号场景预期
L01首次安装初始化 20 项并写入 initialized=true
L02删除全部后重进保持空清单,不恢复默认项
L03手动添加鸡蛋两次第二次提示合并,不新增重复记录
L04输入“ 西红柿 ”和番茄得到同一 ingredientKey
L05嫩豆腐与老豆腐保持不同 ingredientKey
L06500克 + 1千克合并为 1500克
L072个 + 500克保留两个来源,不做错误相加
L08菜谱批量加入新项目带 recipeId 来源
L09同食材来自两道菜合并项目并保留两个 source
L10勾选项目itemId 不变,checked 更新并保存
L11快速勾选三项最终 revision 最大,不被旧快照覆盖
L12点击删除不同时触发勾选,提供撤销
L13全部标为待购买只修改 checked,不删除项目
L14清除已购买只删除 checked=true 的项目
L15清空清单明确确认或撤销,重进仍为空
L16保存失败不显示成功提示,可重试或回滚
L17空清单显示引导空态和添加入口
L18切换分类/状态总数、已买数和分组计数一致

二十七、可复核验收步骤

27.1 初始化与空态

清除应用数据后首次打开,确认 20 条默认项只初始化一次。删除全部、退出并重启,确认 items 仍为 0 且 initialized 为 true。

27.2 去重与数量

分别添加“西红柿 500克”“番茄 1千克”,确认只有一条 tomato 项,总量为 1500克,sources 为 2。再加入“番茄 2个”,确认不做克与个的换算。

27.3 菜谱联动

从两道都需要大葱的菜谱加入清单,确认项目只出现一次、来源包含两个 recipeId。移除一个来源后,另一个来源与数量仍保留。

27.4 持久化

勾选、添加、删除各执行一次,重启后确认 itemId、ingredientKey、quantity、sources 和 checked 恢复。模拟 Preferences 失败,检查 UI 没有假成功。

27.5 多设备

在手机竖屏、横屏、平板和 2in1 下检查分类卡片、状态模式、长文本、添加面板、键盘弹出和底部安全区;深色模式下检查次要文字、删除图标、选中态与分类色对比度。

二十八、常见问题与定位

28.1 清单删空后又出现 20 项

原因: items.length === 0 被当作未初始化。

处理: 保存 initialized 标记,空数组作为合法用户状态。

28.2 同一种食材出现多条

原因: 手动添加不查重,菜谱入口只按原始 name 精确比较。

处理: 统一 resolveIngredientKey,所有入口都调用服务层。

28.3 同名食材数量没有增加

原因: 菜谱入口发现 existed 后直接 continue。

处理: 保存来源数量,同单位合并,不同单位保留明细。

28.4 删除时项目被勾选

原因: 删除控件位于整行点击区域内,事件目标不够独立。

处理: 拆分勾选、内容和删除点击区,并做真机回归。

28.5 页面提示已保存,重启却丢失

原因: persist 返回 void 并吞掉异常。

处理: 仓库返回 SaveShoppingResult,Toast 根据真实结果显示。

28.6 勾选后行闪动或输入状态丢失

原因: ForEach key 包含 checked,状态变化导致节点身份改变。

处理: key 只使用稳定 itemId。

二十九、落地顺序

  1. 先增加 initialized,修复空清单无法保存的问题。
  2. 抽取共享 ShoppingItem 与 ingredientKey。
  3. 让手动添加和菜谱加入共用 ShoppingListService。
  4. 引入 quantity 与 sources,完成安全合并。
  5. 让仓库返回保存结果并串行化写入。
  6. 拆分重置、清除已购买和清空语义。
  7. 最后补撤销、旧数据迁移和多设备布局。

每一步都可用 L01–L18 的相关用例独立验证。

三十、源码事实与建议实现的边界

为避免把设计方案误写成已经落地的功能,下面将当前工程可以直接复核的事实与本文建议严格分开:

维度当前源码事实本文建议,不代表已经实现
默认数据getDefaultItems() 返回 20 项,覆盖肉类、蛋奶、豆制品、蔬菜、调味料 5 类增加独立 initialized 标记,允许空清单持久存在
数据模型每项只有 id/name/amount/category/checked 5 个字段增加 itemId、ingredientKey、结构化数量和来源明细
添加入口手动添加只校验名称非空;数量为空时写入“适量”所有入口统一经过服务层,返回 created/merged/unchanged
菜谱联动RecipeDetailPage 仅按原始名称精确比较,重名时直接 continue名称归一后去重,并按来源安全合并数量
ID 策略清单页扫描最大整数并加一;菜谱页使用 Date.now() + addedCount统一由服务层生成稳定 ID
勾选与列表 key勾选时复制数组;分类视图 key 包含 checked,状态变化会改变 key列表 key 只使用稳定 itemId
重置与删除顶部“重置”只把全部项目改为未购买;删除会立即持久化将重置、清除已购买、清空清单拆成不同命令并支持撤销
本地保存persistShoppingItems() 委托 persist();prefs 为空时直接返回,异常被捕获,flush() 结果未被页面观察仓库返回结构化保存结果,并串行处理连续写入
运行验证本文只进行了源码级复核与文章质量检查构建、安装、重启恢复和多设备测试仍需在真实工程环境执行

因此,本文中的 ShoppingListService、ShoppingListRepository、revision、迁移函数、撤销快照和测试矩阵都属于可落地的改造设计,而不是对当前 1.0.5 工程能力的虚构描述。读者复核时,应以 ShoppingListPage.ets、RecipeDetailPage.ets 与 UserDataManager.ets 的实际代码为准。

三十一、总结

健康菜谱助手的购物清单页已经具备 20 项默认数据、分类视图、状态视图、进度、手动添加、勾选、删除和菜谱批量加入,功能入口并不少。真正影响可靠性的,是空数组被误判为未初始化、页面间模型重复、名称去重不统一、数量只有字符串,以及持久化失败仍提示成功。

把购物清单做稳,需要守住五条原则:

  • initialized 与 items.length 分开,允许用户拥有真正空清单。
  • itemId 表示记录身份,ingredientKey 表示食材身份。
  • 数量按结构化单位安全合并,不可换算时保留来源明细。
  • 所有入口共用服务层,勾选、删除和清空语义明确。
  • UI 反馈以真实保存结果为准,失败可重试或回滚。

当用户从两道菜加入同一种食材、在超市勾选后重启应用,仍能看到正确数量和状态时,这个清单才真正从展示页面变成可依赖的采购工具。

参考资料

Logo

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

更多推荐