【健康菜谱助手|05】HarmonyOS ArkTS 购物清单实战:去重食材并同步勾选、数量与清空动作

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

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

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

HEALTHY-RECIPE-ONE7-SHOPPING-LIST-20260730

本文将完成以下内容:

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

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

页面内定义的模型是:

ts 复制代码
interface ShoppingItem {
  id: number
  name: string
  amount: string
  category: string
  checked: boolean
}

页面状态包括:

ts 复制代码
@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、来源、创建时间和结构化数量。

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

进入页面时执行:

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

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

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

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

ts 复制代码
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 后,另一个页面很容易继续写旧结构。

应把模型移到共享文件:

ts 复制代码
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:标识"这是哪一种食材",用于去重和合并。
ts 复制代码
function createItemId(now: number, sequence: number): string {
  return `shopping_${now}_${sequence}`
}

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

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

当前菜谱加入逻辑是:

ts 复制代码
const existed = nextItems.find(
  (item: ShoppingItem) => item.name === ingredient.name
)
if (existed) {
  continue
}

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

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

ts 复制代码
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 是自由文本,例如:

text 复制代码
500克
10个
1升
2盒
适量

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

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

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

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

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

ts 复制代码
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,但合并时显示"存在多个用量说明"。

八、统一单位后再合并

ts 复制代码
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 相同才相加:

ts 复制代码
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 克"不能强行相加,应保留两个说明并提示用户确认。

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

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

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

ts 复制代码
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。服务层可以返回:

ts 复制代码
export interface AddShoppingResult {
  item: ShoppingItem
  action: 'created' | 'merged' | 'unchanged'
  mergedSourceCount: number
}

对应提示更准确:

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

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

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

ts 复制代码
this.items = this.items.map((item) =>
  item.id === id ? { ...item, checked: !item.checked } : item
)

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

ts 复制代码
ForEach(this.items, (item: ShoppingItem) => {
  this.ShoppingRow(item)
}, (item: ShoppingItem) => item.itemId)

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

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

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

更清晰的结构是:

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

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

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

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

ts 复制代码
interface DeleteSnapshot {
  item: ShoppingItem
  index: number
  deletedAt: number
}

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

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

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

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

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

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

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

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

ts 复制代码
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 生成应放在服务层;页面只提交名称、数量和分类:

ts 复制代码
export interface AddManualItemCommand {
  name: string
  amountText: string
  category: IngredientCategory
}

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

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

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

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

分类配置集中定义:

ts 复制代码
interface CategoryMeta {
  value: IngredientCategory
  emoji: string
  color: string
  order: number
}

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

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

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

可以一次生成视图模型:

ts 复制代码
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%,这是正确的边界:

ts 复制代码
const percent = items.length > 0
  ? Math.round(checkedCount / items.length * 100)
  : 0

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

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

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

仓库应返回:

ts 复制代码
export interface SaveShoppingResult {
  ok: boolean
  revision?: number
  reason?: 'not_initialized' | 'serialize_failed' | 'write_failed'
}

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

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

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

ts 复制代码
interface ShoppingListStore {
  schemaVersion: number
  revision: number
  initialized: boolean
  items: ShoppingItem[]
  updatedAt: number
}

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

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

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

ts 复制代码
interface AddItemDraft {
  name: string
  amountText: string
  category: IngredientCategory
  nameError: string
  amountWarning: string
  isSaving: boolean
}

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

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

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。迁移时:

ts 复制代码
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
L06 500克 + 1千克 合并为 1500克
L07 2个 + 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 反馈以真实保存结果为准,失败可重试或回滚。

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

参考资料

AI 辅助声明:本文由 AI 辅助完成源码统计、结构校对与配图设计,当前能力结论均以文中列出的本地 ArkTS 源码为依据,并由作者复核后发布;建议模型、迁移方案和测试矩阵不代表工程已经构建、安装或上线。

相关推荐
特立独行的猫a3 小时前
仓颉语言cj-tauri 跨端开发框架实战:给鸿蒙侧加一个「读设备电量」的原生能力
ui·harmonyos·tauri·1024程序员节·仓颉·鸿蒙pc
贾伟康13 小时前
【健康菜谱助手|01】HarmonyOS ArkTS 菜谱详情页实战:组织食材、营养、步骤和收藏状态
软件测试·移动开发·harmonyos·arkts·arkui
一目Leizi20 小时前
折叠屏适配踩坑实录:从“一展开就变形“到一套代码适配多种形态【鸿蒙心迹】
华为·harmonyos
传奇开心果编程20 小时前
【Jetpack Compose娓娓道来】 第25课:ViewModel就是UI界面的大脑
android·kotlin·移动开发·jetpack compose·编程语言理论·编程框架·声明式ui
马剑威(威哥爱编程)21 小时前
【HarmonyOS 7 小艺智能体实战】03:第一次建智能体——在小艺开放平台把骨架立起来
人工智能·华为·harmonyos
KevinChaos1 天前
HarmonyOS 7.0 端侧3DGS实验⑤:空洞、漂浮伪影与视角稳定
harmonyos
fellow991 天前
DSH Desktop - HarmonyOS Edition
harmonyos·openharmony·deepseek·deepseekharness
鸿蒙开发1 天前
鸿蒙表单异步校验实战:@hmkit/validator 1.3 的取消、超时与任务清理
harmonyos