摘要

「味蕾探险家」是 AI40 智能应用工具箱中编号为 2 的品质生活类应用,核心功能是根据用户设定的用餐人数、口味偏好和忌口食材,智能生成三套完整的家常菜菜单方案,每套方案包含菜品列表、菜品描述和去重合并后的采购清单。本文从 6A 工作流(Align-Architect-Atomize-Approve-Automate-Assess)的视角,系统性地记录了该应用从 Prompt 模板到 ArkTS 代码实现的完整过程,深入分析了 ArkTS 严格模式下的多选标签交互模式、ForEach 回调纯度约束、Flex 布局在标签流式排列中的应用,以及 toggleAvoid() 和 isAvoidSelected() 两个关键辅助方法的实现细节,为 HarmonyOS 开发者提供了一套可复用的多选交互实现范式。


一、对齐阶段(Align):从 Prompt 到需求规范

1.1 应用背景与 Prompt 分析在这里插入图片描述

在这里插入图片描述

系统指令: 将 AI 角色定义为一个"家常菜单"数据生成器,要求只返回符合 JSON 格式的数据,核心约束包括三项:生成的菜品需避开用户指定的忌口食材,采购清单需对同类食材进行去重合并,temperature 参数设置为 0.5,在准确性和多样性之间取得平衡。

用户输入 Schema: 三个维度的参数设计——people(用餐人数,整数类型)、taste(口味偏好,枚举值"清淡|香辣|酸甜|重口")、avoid(忌口食材,字符串数组类型)。这三个维度覆盖了家庭烹饪决策的核心因素:人数决定了菜量和菜数,口味决定了烹饪方法和调味风格,忌口食材则是个性化饮食需求的关键约束。

输出格式: 一个 JSON 对象,包含 menus 数组(3 套菜单方案),每套菜单包含 mealName(餐次名称)、dishes(菜品数组,每道菜含菜名和描述)、shoppingList(采购清单,食材+数量,已去重合并)。此外还有一个 summary 字段,提供整体方案的概要说明。

兜底规则: 当忌口食材数组为空时,不过滤任何食材,正常生成菜单。这意味着忌口是可选的而非必填项,用户可以选择不设置任何忌口约束。

Temperature 参数分析: 0.5 的设置处于大模型参数调节的"中性区间"。与「衣品进化室」(App1)的 0.4 相比,0.5 略微提高了生成多样性。这是因为菜单生成需要适度的创意——同一组参数下,用户希望看到不同的菜品组合,而非每次都推荐相同的菜。但 0.5 又不至于过高(如 0.8),确保生成的菜品组合仍然实用、可操作,不会出现"巧克力炒鸡蛋"这样的荒谬搭配。

1.2 需求理解与功能边界确认

基于 Prompt 分析,我们确定了「味蕾探险家」的功能边界:

核心功能: 用户设定用餐人数(通过文本输入框输入数字)、选择口味偏好(从 4 种口味中选择一种)、选择忌口食材(从 10 种常见食材中多选),点击"AI 生成"按钮后,获得三套完整的菜单方案。每套方案展示:餐次名称(如"周一:清新蒸菜日")、菜品列表(菜名 + 描述)、采购清单(去重合并后的食材标签)。

不在范围内: 不提供详细的烹饪步骤(这是"冰箱魔法厨"App32 的职责),不计算营养热量分配(这是"营养方程式"App33 的职责),不支持用户自定义食材库存(这是"冰箱魔法厨"的输入特性),不提供食材购买链接或电商下单功能。

三维参数设计分析: 人数、口味、忌口三个维度的设计形成了一个完整的"约束-偏好"模型。人数是物理约束——决定了菜量的基准;口味是偏好驱动——决定了烹饪方法和调味方向;忌口是排除约束——从可选食材空间中移除特定食材。这三个维度在逻辑上形成"先排除、再选择、后量化"的决策流程:先用忌口过滤掉不可用食材,再根据口味偏好匹配烹饪方法,最后根据人数调整菜量和菜数。

交互流程: 人数输入(TextInput Number 类型)→ 口味选择(下拉展开/收起)→ 忌口选择(多选标签,Flex 流式布局)→ 点击"AI 生成"按钮 → 显示 Loading 动画(800ms 模拟延迟)→ 展示三套菜单结果。这是一个"输入 → 处理 → 输出"三段式交互,但输入阶段比 App1 的下拉选择器更复杂——引入了文本输入和数组多选两种新的交互模式。

离线策略: 内置 4 组 Mock 数据场景,分别覆盖了 4 种口味组合:清淡 + 2 人(精确匹配场景一)、香辣 + 4 人(精确匹配场景二)、酸甜口味(不限制人数,精确匹配场景三)、兜底通用场景(任意参数组合)。这 4 个场景覆盖了 4 种口味的全部枚举值,其中前两个场景额外匹配了特定人数,第三个场景匹配了酸甜口味但人数不限,第四个场景作为兜底,确保了所有参数组合都能获得可用的菜单结果。

1.3 技术约束确认

在 ArkTS 严格模式下,以下技术约束对「味蕾探险家」的开发有直接影响:

  1. 禁止 any 类型: 菜单结果的数据结构必须通过显式 interface 定义。我们需要定义 DishItem(菜品信息)、MenuSet(单套菜单)、MenuOutput(完整输出)三个接口,所有字段类型必须显式标注。

  2. 禁止解构赋值: 在 toggleAvoid() 和 generateMockData() 方法中,不能使用解构语法。例如,不能使用 const [first, ...rest] = this.avoidList 来移除元素,必须通过 for 循环手动构建新数组。

  3. 禁止 for…in 循环: 所有数组遍历必须使用常规 for 循环(for (let i: number = 0; i < arr.length; i++))或 ForEach 组件。

  4. ForEach 回调纯度约束: ForEach 的回调函数中只能包含 UI 组件及其属性链式调用,不能包含 let 声明、for 循环、if 逻辑判断。这意味着在渲染忌口标签时,不能在 ForEach 回调中直接使用 if (this.avoidList.includes(item)) 来判断选中状态——必须将判断逻辑抽取为独立的辅助方法 isAvoidSelected(),在 ForEach 回调中通过方法调用获取结果。

  5. 禁止使用 in 运算符: 不能使用 item in this.avoidList 来检查忌口选中状态,必须使用 for 循环遍历数组进行匹配。

  6. 禁止使用 includes 方法(如果运行时不可用): 在 ArkTS 中,数组的 includes 方法可能不被支持或产生运行时问题。因此我们使用显式的 for 循环遍历来实现元素查找。

  7. Flex 组件的 wrap 属性: ArkUI 中 Flex 组件的 wrap 属性实现了标签的流式排列,但由于不支持 Row 的 flexWrap 属性(Row 没有 wrap 能力),必须使用 Flex 组件而非 Row 组件来实现多选标签的自动换行。

  8. Scroll 组件单子组件约束: 所有可滚动内容必须包裹在一个 Column 中,作为 Scroll 的唯一直接子组件。

1.4 验收标准

维度 标准 验证方式
参数覆盖 4 种口味 + 10 种忌口多选 + 任意人数 至少 4 组 Mock 数据覆盖
结果完整性 每次生成 3 套菜单,每套含菜品列表 + 描述 + 采购清单 结果展示验证
忌口逻辑 选中的忌口食材不在生成的菜品中出现 Mock 数据设计验证
多选交互 标签点击切换选中/未选中状态,视觉反馈清晰 手动操作验证
离线可用 无网络环境下正常生成结果 飞行模式测试
编译合规 零 Error,通过 ArkTS 严格模式 DevEco Studio 编译

二、架构阶段(Architect):数据结构与组件设计

2.1 数据接口设计

「味蕾探险家」的数据模型采用三层嵌套结构,从最小的菜品单元到完整输出逐层构建:

// 菜品信息:每道菜的名称和描述
interface DishItem {
  name: string;
  description: string;
}

// 单套菜单:包含餐次名称、菜品列表和采购清单
interface MenuSet {
  mealName: string;
  dishes: DishItem[];
  shoppingList: string[];
}

// 完整输出:三套菜单 + 方案概要
interface MenuOutput {
  menus: MenuSet[];
  summary: string;
}

三层嵌套结构的设计意图: DishItem(菜品)→ MenuSet(单套菜单)→ MenuOutput(完整输出)的三层嵌套结构,精确对应了 Prompt 模板中的输出 JSON Schema。这种设计使得前端可以直接将 API 响应的 JSON 数据解析为类型安全的接口实例,无需任何中间转换。

shoppingList 的数组设计: 采购清单被设计为 string[] 类型,每个元素是"食材+数量"的完整描述(如"鲈鱼1条"、“番茄2个”)。这种设计比将食材和数量拆分为两个独立字段更简洁——在 UI 中,采购清单以标签(Tag)形式展示,每个标签就是一个完整的采购项,直接使用字符串即可渲染,无需额外的字段拼接逻辑。

summary 字段的用途: summary 是对整体方案的一句话概要说明(如"2人清淡饮食方案,每餐2菜1汤,营养均衡,适合日常家庭烹饪。")。在 UI 中,summary 被渲染在结果列表的最底部,用一个浅黄色背景的区块展示,作为对三套菜单的总结性说明,帮助用户快速理解方案的整体定位。

2.2 状态管理设计

本应用使用 6 个 @State 变量管理页面状态:

@State peopleCount: string = '2';           // 用餐人数(字符串类型,因为 TextInput 的 onChange 返回 string)
@State selectedTaste: string = '清淡';       // 选中的口味偏好
@State avoidList: string[] = [];             // 已选中的忌口食材列表
@State outputData: MenuOutput | null = null; // 生成结果(联合类型表达空值)
@State isLoading: boolean = false;           // 是否正在加载
@State hasResult: boolean = false;           // 是否有生成结果

状态变量的设计原则:

peopleCount 使用字符串类型: 虽然人数在逻辑上是整数,但在 ArkUI 中,TextInput 组件的 onChange 回调返回的是 string 类型。将 peopleCount 定义为 string 类型可以避免每次输入时都需要 parseInt 转换,减少了不必要的类型转换。在 Mock 数据生成时,通过字符串比较(如 people === '2')进行场景匹配,同样不需要转换为整数。

avoidList 使用数组类型: 忌口食材是多选的,因此使用 string[] 数组存储已选中的食材。数组的初始值为空数组 [],表示用户尚未选择任何忌口。在 aboutToAppear() 生命周期中重置为空数组,确保每次进入页面时状态干净。

outputData 使用联合类型: MenuOutput | null 的联合类型设计是该应用中最重要的类型安全措施。在 ArkTS 中,由于不支持可选链操作符(?.),访问可能为 null 的对象属性前必须进行显式的空值检查。如果我们使用 if (this.hasResult && this.outputData !== null) 进行双重检查,ArkTS 编译器能够识别出在 if 块内部,outputData 的类型已经被缩窄为 MenuOutput(非 null),从而允许安全地访问其 menus 和 summary 属性。

hasResult 与 outputData 的双重保障: 虽然可以通过 this.outputData !== null 来判断是否有结果,但单独使用 hasResult 布尔变量有额外好处:在用户重新点击生成按钮时,可以先设置 this.hasResult = false 来立即隐藏旧结果,等新数据准备好后再设置 this.hasResult = true。这避免了新旧数据交替时可能出现的 UI 闪烁。

静态数据使用 private 而非 @State: 选项列表(tasteOptions、avoidOptions)和展开状态变量(showTastePicker、showAvoidPicker)使用 private 修饰。这些数据不需要触发响应式更新——tasteOptions 和 avoidOptions 是常量数组,showTastePicker 和 showAvoidPicker 虽然会变化,但它们的值变化仅用于控制 UI 的条件渲染(if 语句),不需要通过 @State 机制触发整个组件的重渲染。

2.3 UI 组件树设计

App2 (@Entry, @Component)
├── Column (根容器)
│   ├── Row (TopBar 返回栏)
│   │   ├── Text("← 返回") → onClick → router.back()
│   │   ├── Text("味蕾探险家") → 居中标题
│   │   └── Text("") → 占位元素,保持标题居中
│   └── Scroll (可滚动内容区)
│       └── Column (唯一子组件)
│           ├── Text("用餐人数") → 标签
│           ├── TextInput (Number 类型) → onChange → 更新 peopleCount
│           ├── Text("口味偏好") → 标签
│           ├── Row → 当前选中口味 + "▼" 箭头 → onClick → 切换展开
│           ├── if (showTastePicker) → Column → 口味选项列表
│           │   └── ForEach(tasteOptions)
│           │       └── Row → 选项文字 + ✓ 选中标记
│           ├── Text("忌口选择(多选)") → 标签
│           ├── Flex(wrap: FlexWrap.Wrap) → 多选标签容器
│           │   └── ForEach(avoidOptions)
│           │       └── Text → isAvoidSelected() 判断颜色 → onClick → toggleAvoid()
│           ├── Row → Button("AI 生成") → onClick → onGenerate()
│           ├── if (isLoading) → LoadingProgress + 提示文字
│           └── if (hasResult && outputData !== null) → 结果展示区
│               ├── Text("菜单方案") → 区块标题
│               ├── ForEach(outputData.menus) → 每套菜单
│               │   └── Column → 菜单卡片
│               │       ├── Text(mealName) → 餐次名称
│               │       ├── ForEach(dishes) → 每道菜
│               │       │   └── Column
│               │       │       ├── Row → "🍽️" + dish.name
│               │       │       └── Text(dish.description)
│               │       ├── Text("购物清单") → 子标题
│               │       └── Flex(wrap: FlexWrap.Wrap) → 采购清单标签
│               │           └── ForEach(shoppingList) → Text 标签
│               └── Text(summary) → 方案概要(浅黄色背景)

组件树深度分析: 最深的嵌套路径为 Column > Scroll > Column > Column(菜单卡片)> Column(菜品)> Row > Text,共 7 层。虽然层数较多,但每一层都有明确的语义:根容器层 → 滚动容器层 → 内容分组层 → 卡片层 → 菜品层 → 行布局层 → 文本层。这种逐层嵌套的结构在 ArkUI 中是常见的,因为 ArkUI 缺乏 CSS 的 margin/padding 通配能力,需要通过嵌套容器来实现间距和边距的控制。

2.4 多选切换逻辑设计

「味蕾探险家」中最具技术挑战性的部分是多选忌口标签的交互逻辑。在 ArkTS 严格模式下,由于不能使用解构赋值、includes 方法、in 运算符等常见的数组操作,我们需要设计一套完全基于 for 循环的数组操作方法。

toggleAvoid() 方法——无解构的数组增删:

toggleAvoid(item: string): void {
  // 第一步:查找 item 在 avoidList 中的索引
  let index: number = -1;
  for (let i: number = 0; i < this.avoidList.length; i++) {
    if (this.avoidList[i] === item) {
      index = i;
      break;
    }
  }
  // 第二步:根据索引决定添加还是移除
  if (index >= 0) {
    // 已存在 → 移除(构建不含该元素的新数组)
    let newList: string[] = [];
    for (let i: number = 0; i < this.avoidList.length; i++) {
      if (i !== index) {
        newList.push(this.avoidList[i]);
      }
    }
    this.avoidList = newList;
  } else {
    // 不存在 → 添加(构建包含该元素的新数组)
    let newList: string[] = [];
    for (let i: number = 0; i < this.avoidList.length; i++) {
      newList.push(this.avoidList[i]);
    }
    newList.push(item);
    this.avoidList = newList;
  }
}

为什么不能使用 splice: 在标准 TypeScript 中,我们可以使用 this.avoidList.splice(index, 1) 来原地移除元素,或者使用 this.avoidList.push(item) 来原地添加元素。但在 ArkTS 中,@State 修饰的数组需要通过整体替换来触发响应式更新——原地修改数组(如 splice、push)可能不会被 ArkUI 的变更检测机制正确识别,导致 UI 不更新。因此,我们必须构建一个全新的数组(newList),然后整体赋值给 this.avoidList。

为什么不能使用 filter: 在标准 TypeScript 中,移除元素可以使用 this.avoidList = this.avoidList.filter(v => v !== item)。但 ArkTS 不支持函数表达式(function expression),这意味着 filter 回调必须使用箭头函数。然而,ArkTS 对箭头函数在数组方法中的使用也有严格限制——如果 filter 的回调中引用了外部的 item 变量,可能会因为闭包捕获问题导致编译错误。因此,使用显式的 for 循环是更安全的选择。

为什么逐元素复制而非使用扩展运算符: 在标准 TypeScript 中,添加元素可以使用 this.avoidList = [...this.avoidList, item]。但 ArkTS 中扩展运算符的使用受到严格限制——仅在"将数组展开到 rest 参数或数组字面量"的场景中支持。对于 @State 数组的赋值,扩展运算符可能不被编译器正确识别,导致编译错误。因此,我们使用显式的 for 循环逐元素复制,确保代码的兼容性。

isAvoidSelected() 方法——ForEach 回调的纯度保障:

isAvoidSelected(item: string): boolean {
  for (let i: number = 0; i < this.avoidList.length; i++) {
    if (this.avoidList[i] === item) {
      return true;
    }
  }
  return false;
}

这个方法的命名和设计看似简单,但背后有一个关键的设计决策:将判断逻辑从 ForEach 回调中提取出来。在 ArkTS 中,ForEach 的回调函数必须是"纯 UI 函数"——只能包含 UI 组件及其属性链式调用,不能包含 let 声明、for 循环、if 逻辑判断。如果我们直接在 ForEach 回调中编写判断逻辑:

// 错误写法:在 ForEach 回调中包含逻辑判断
ForEach(this.avoidOptions, (item: string): void => {
  let selected: boolean = false;  // ❌ ForEach 回调中不允许 let 声明
  for (let i: number = 0; i < this.avoidList.length; i++) {  // ❌ 不允许 for 循环
    if (this.avoidList[i] === item) {  // ❌ 不允许 if 逻辑
      selected = true;
	  break;
    }
  }
  // ...
})

这种写法在 ArkTS 中会导致编译错误。解决方案是将判断逻辑提取为独立的类方法 isAvoidSelected(),在 ForEach 回调中仅通过方法调用获取结果:

// 正确写法:ForEach 回调中仅包含 UI 组件和方法调用
ForEach(this.avoidOptions, (item: string): void => {
  Text(item)
    .fontColor(this.isAvoidSelected(item) ? '#FFFFFF' : '#666666')  // ✅ 方法调用返回布尔值
    .backgroundColor(this.isAvoidSelected(item) ? '#FF6B6B' : '#F0F0F5')
    .onClick((): void => { this.toggleAvoid(item); })  // ✅ 方法调用
})

该方法在模板中的复用: isAvoidSelected() 在 ForEach 回调中被调用了两次(fontColor 和 backgroundColor 各一次),每次调用都会执行一次完整的 for 循环遍历。对于 10 个忌口选项,每次渲染会执行 20 次 for 循环遍历(每个选项 2 次调用 × 10 个选项)。虽然这在性能上可以接受(avoidList 最多 10 个元素,遍历开销极小),但如果未来选项数量增加到 50 个以上,建议将 isAvoidSelected() 的结果缓存为局部变量。但在当前 ArkTS 约束下,ForEach 回调中不能声明变量,因此这个优化在当前架构下无法实现——这就是 ArkTS 严格模式下的一个典型的"性能-合规"权衡。


三、原子化阶段(Atomize):Mock 数据策略与核心方法

3.1 Mock 数据场景匹配逻辑

「味蕾探险家」的 Mock 数据生成采用了基于 if-else 条件链的场景匹配策略,共覆盖 4 个场景:

generateMockData(): void {
  const taste: string = this.selectedTaste;
  const people: string = this.peopleCount;

  // 场景1:清淡 + 2人(精确匹配)
  if (taste === '清淡' && people === '2') {
    this.outputData = { /* 清淡2人菜单 */ };
  }
  // 场景2:香辣 + 4人(精确匹配)
  else if (taste === '香辣' && people === '4') {
    this.outputData = { /* 香辣4人菜单 */ };
  }
  // 场景3:酸甜口味(不限人数)
  else if (taste === '酸甜') {
    this.outputData = { /* 酸甜菜单 */ };
  }
  // 场景4:兜底通用
  else {
    this.outputData = { /* 通用菜单 */ };
  }
}

场景匹配策略的设计分析:

为什么场景1和场景2匹配了人数,而场景3不限人数: 这是一个经过深思熟虑的覆盖策略。4 种口味中,清淡和香辣是两种极端口味——清淡通常是 2-3 人的小家庭偏好,香辣通常是 4 人以上的聚会场景。通过在这两个场景中精确匹配人数,我们覆盖了最常见的"口味-人数"组合。酸甜口味相对小众,不限人数可以覆盖更多的用户组合。重口口味没有专门的精确匹配场景,会落入兜底分支,但兜底方案中的"家常小炒肉"、"红烧鸡块"等菜品本身就偏向重口味风格,因此兜底方案对重口用户来说也是合理的。

条件判断的顺序: 场景匹配的顺序是"清淡2人 → 香辣4人 → 酸甜 → 兜底"。这个顺序是经过优化的:前两个条件最具体(双重条件),第三个条件次之(单条件),第四个条件最宽泛(无条件)。如果我们将"酸甜"放在"清淡2人"之前,那么当用户选择"清淡 + 2人"时,因为 taste === '酸甜' 为 false,会继续向下匹配,最终正确命中"清淡2人"分支。但将最具体的条件放在最前面是一种良好的编程习惯,可以提高代码可读性。

为什么没有区分"重口"场景: 重口是 4 种口味中区分度最低的口味——它可能包括咸鲜、酱香、红烧等多种风格。Mock 数据的兜底方案中包含了"家常小炒肉"(酱香)、“红烧鸡块”(红烧)、“清蒸鱼”(原味)等菜品,已经覆盖了重口偏好的核心需求。如果为"重口"单独创建 Mock 场景,需要额外设计 3 套 × 3 道菜 = 9 道菜的数据,而这些菜品与兜底方案会有较大重叠,投入产出比不高。

3.2 Mock 数据内容设计分析

场景一:清淡 + 2人

周一:清新蒸菜日 → 清蒸鲈鱼 + 蒜蓉西兰花 + 番茄蛋花汤
周二:养胃粥面日 → 香菇鸡肉粥 + 凉拌黄瓜 + 蒸南瓜
周三:时蔬轻食日 → 虾仁豆腐 + 上汤娃娃菜 + 紫菜蛋花汤

这组菜单的设计体现了"清淡"口味的核心特征:烹饪方法以蒸、煮、拌为主,避免了煎、炸、烤等重油烹饪方式。菜品选择上,鲈鱼、豆腐、虾仁都是高蛋白低脂的食材,西兰花、娃娃菜、南瓜提供了丰富的膳食纤维。每餐的菜品数量为 2 菜 1 汤(或 2 菜 1 主食),符合 2 人用餐的菜量。

场景二:香辣 + 4人

周一:川味经典日 → 水煮肉片 + 麻婆豆腐 + 炝炒圆白菜
周二:湘味热炒日 → 辣椒炒肉 + 剁椒鱼头 + 酸辣土豆丝
周三:香锅烧烤日 → 麻辣香锅 + 蒜蓉烤茄子 + 酸梅汤

这组菜单以川菜和湘菜为主,烹饪方法以炒、煮、烤为主,调料以辣椒、花椒、豆瓣酱为核心。每餐的菜品数量为 2-3 道菜,加上一个解辣饮品(酸梅汤),符合 4 人聚会的菜量。特别值得注意的是,周三的菜单中酸梅汤作为饮品出现,这是对"香辣"主题的一个巧妙补充——在辣味盛宴中提供一个解辣消暑的饮品,体现了菜单设计的完整性和实用性。

场景三:酸甜口味

周一:经典糖醋日 → 糖醋里脊 + 番茄炒蛋 + 凉拌海带丝
周二:果味创意日 → 菠萝咕咾肉 + 柠檬鸡丝 + 橙汁冬瓜
周三:番茄盛宴日 → 番茄牛腩 + 茄汁大虾 + 话梅小番茄

这组菜单以酸甜口味为核心,分为三个子主题:糖醋类(传统酸甜)、果味类(水果酸甜)、番茄类(番茄酸甜)。这种细分展示了酸甜口味的多样性——酸甜不仅仅是糖醋,还可以通过水果(菠萝、柠檬、橙汁)和番茄来实现。每餐的菜品数量为 2 菜 1 小菜,结构均衡。

场景四:兜底通用

今日推荐 → 家常小炒肉 + 清炒时蔬 + 紫菜蛋花汤
明日推荐 → 红烧鸡块 + 蒜蓉空心菜 + 番茄蛋汤
后日推荐 → 清蒸鱼 + 蚝油生菜 + 冬瓜排骨汤

兜底方案的设计原则是"普适性"——菜品种类涵盖炒、红烧、清蒸、炖汤等多种烹饪方法,口味从中性到微重都有覆盖,适合大多数中国家庭的日常饮食。每餐的菜品数量为 2 菜 1 汤,是最经典的家常菜配置。

3.3 采购清单的去重合并设计

Mock 数据中的采购清单已经过人工去重合并。以场景一(清淡2人)为例:

周一采购清单:鲈鱼1条, 西兰花1颗, 番茄2个, 鸡蛋3个, 姜, 葱, 蒜
周二采购清单:鸡胸肉200g, 香菇5朵, 大米适量, 黄瓜2根, 老南瓜1块
周三采购清单:虾仁150g, 嫩豆腐1盒, 娃娃菜2颗, 紫菜, 鸡蛋2个, 高汤块

注意周一的鸡蛋3个和周三的鸡蛋2个是分开列出的——这是按餐次进行采购清单管理,而不是跨餐次合并。在实际的 Prompt 模板中,要求 AI 对采购清单进行去重合并,即同一个食材在不同餐次中出现时,应该合并数量。但在 Mock 数据中,由于三套菜单是独立设计的,手工跨餐次合并会增加数据维护的复杂度。因此我们采用了"每餐独立采购清单"的策略,在真实 API 调用时,AI 会自动完成跨餐次合并。

3.4 tsAvoidSelected() 方法的深度解析

isAvoidSelected() 是「味蕾探险家」中最具教学价值的方法。它体现了 ArkTS 严格模式下 ForEach 回调纯度约束的应对策略。

问题背景: 在标准 TypeScript + React/Vue 中,处理多选标签的选中状态通常直接在渲染循环中编写判断逻辑:

// React 写法(在 JSX 中直接使用 includes 判断)
{avoidOptions.map(item => (
  <Tag
    selected={avoidList.includes(item)}
    onPress={() => toggleAvoid(item)}
  >
    {item}
  </Tag>
))}

但在 ArkTS 中,ForEach 回调必须是纯 UI 函数,不允许包含逻辑判断。因此,我们需要将判断逻辑提取为独立方法。

方法签名设计: isAvoidSelected(item: string): boolean——接收一个食材名称,返回一个布尔值,表示该食材是否在忌口列表中。返回类型为 boolean,可以直接用于三元运算符 ? : 中的条件判断。

实现细节: 使用常规 for 循环遍历 avoidList 数组,逐个比较元素。如果找到匹配项,立即返回 true(短路求值);如果遍历完整个数组都没有找到匹配项,返回 false。这种实现方式的时间复杂度为 O(n),其中 n 是 avoidList 的长度(最多 10),性能完全可接受。

替代方案对比:

方案 代码 ArkTS 兼容性 性能
includes this.avoidList.includes(item) 可能不支持 O(n)
indexOf this.avoidList.indexOf(item) >= 0 可能不支持 O(n)
in 运算符 item in this.avoidList 不支持 -
for 循环 isAvoidSelected(item) 完全支持 O(n)
Set.has avoidSet.has(item) 需要额外转换 O(1)

Set 方案的可行性分析: 理论上,我们可以维护一个 private avoidSet: Set<string> 来存储忌口食材,使用 O(1) 的查找。但在 ArkTS 中,Set 的支持情况不确定,且需要同时维护 avoidList(用于 @State 绑定)和 avoidSet(用于快速查找),增加了状态同步的复杂度。在 avoidList 长度最多为 10 的前提下,O(n) 的 for 循环遍历与 O(1) 的 Set 查找在实际性能上几乎没有差别,因此选择最简单的 for 循环方案。

3.5 toggleAvoid() 方法的深度解析

toggleAvoid() 是另一个核心方法,它实现了"无解构、无 splice、无 filter、无扩展运算符"的数组增删操作。

方法流程:

  1. 查找阶段: 遍历 avoidList 数组,查找 target item 的索引位置。如果找到,记录索引;如果未找到,索引保持为 -1。
  2. 删除阶段(index >= 0): 构建一个新数组 newList,使用 for 循环遍历原 avoidList,跳过索引为 index 的元素(即不将其添加到 newList),其余元素逐个添加到 newList。最后将 newList 赋值给 this.avoidList。
  3. 添加阶段(index === -1): 构建一个新数组 newList,先使用 for 循环将原 avoidList 的所有元素复制到 newList,然后将 target item 添加到 newList 末尾。最后将 newList 赋值给 this.avoidList。

为什么需要构建新数组而非原地修改: ArkTS 中 @State 修饰的数组需要通过整体替换来触发响应式更新。原地修改数组(如 push、splice)不会改变数组的引用,ArkUI 的变更检测机制可能无法检测到这种变化,导致 UI 不更新。通过构建新数组并整体赋值,我们确保了每次 avoidList 变化时,@State 机制都能检测到引用变化并触发 UI 重新渲染。

性能分析: 每次 toggle 操作的时间复杂度为 O(n)(查找 + 复制),空间复杂度为 O(n)(创建新数组)。对于最多 10 个元素的 avoidList,这个开销可以忽略不计。即使在极端情况下(用户快速连续点击 10 个标签),总操作次数也不超过 10 × 10 = 100 次循环,在现代设备上执行时间远小于 1ms。


四、审批阶段(Approve):ArkTS 合规检查与关键问题修复

4.1 ArkTS 类型安全检查清单

在代码审查中,我们对「味蕾探险家」进行了全面的 ArkTS 合规检查:

检查项 1:无 any/unknown 类型。 所有变量和参数都显式标注了类型。ForEach 回调中的 item 参数标注为 string,onClick 回调的返回类型标注为 void。outputData 的类型为 MenuOutput | null,是一个明确的联合类型。三角色的接口定义(DishItem、MenuSet、MenuOutput)的所有字段都标注了具体类型。

检查项 2:无解构赋值。 generateMockData() 方法中使用 const taste: string = this.selectedTaste 逐字段赋值,toggleAvoid() 中使用 for 循环逐元素构建新数组,没有任何解构语法。

检查项 3:无 for…in 循环。 所有数组遍历使用常规 for 循环(for (let i: number = 0; i < arr.length; i++)),符合 ArkTS 规范。

检查项 4:ForEach 回调纯 UI 语法。 所有 ForEach 回调中只包含 UI 组件(Text、Row、Column、Flex)和属性链式调用(.fontSize()、.fontColor()、.onClick()),没有 let 声明、for 循环、if 逻辑判断。判断逻辑通过 isAvoidSelected() 方法调用实现。

检查项 5:无 Function 表达式。 所有回调使用箭头函数语法((): void => { ... }),符合 ArkTS 规范。

检查项 6:无 in 运算符。 isAvoidSelected() 和 toggleAvoid() 中使用 for 循环遍历而非 in 运算符,符合 ArkTS 规范。

检查项 7:接口定义完整性。 三个接口(DishItem、MenuSet、MenuOutput)的所有字段都标注了具体类型,没有使用 any 或 object 类型。嵌套数组类型(DishItem[]string[])标注正确。

检查项 8:Scroll 单子组件约束。 Scroll 组件内部只有一个 Column 子组件,符合 ArkUI 的 Scroll 组件约束。

4.2 Flex 组件在标签流式排列中的应用

为什么使用 Flex 而非 Row: 在标准 CSS 中,多选标签的流式排列通常使用 flex-wrap: wrap 实现。在 ArkUI 中,Row 组件不支持 wrap 属性(Row 只能单行排列),而 Flex 组件提供了 wrap: FlexWrap.Wrap 属性来实现自动换行。这是 ArkUI 与标准 CSS 的一个重要差异点。

Flex({ wrap: FlexWrap.Wrap }) {
  ForEach(this.avoidOptions, (item: string): void => {
    Text(item)
      .fontSize(13)
      .fontColor(this.isAvoidSelected(item) ? '#FFFFFF' : '#666666')
      .backgroundColor(this.isAvoidSelected(item) ? '#FF6B6B' : '#F0F0F5')
      .borderRadius(16)
      .padding({ left: 14, right: 14, top: 6, bottom: 6 })
      .margin({ right: 8, bottom: 8 })
      .onClick((): void => { this.toggleAvoid(item); })
  })
}
.width('100%')

标签间距控制: 标签之间的间距通过 .margin({ right: 8, bottom: 8 }) 实现,而非使用 Flex 的 gap 属性。这是因为 ArkUI 中 Flex 组件的 gap 属性支持可能与版本有关,使用 margin 是更保守且兼容的选择。right: 8 控制标签之间的水平间距,bottom: 8 控制换行后的垂直间距。

标签圆角: .borderRadius(16) 使标签呈现胶囊形状(pill shape),这是多选标签最常见的视觉风格。16px 的圆角值在 HarmonyOS 设计规范中是一个标准的圆角半径,适用于中等大小的标签组件。

选中状态视觉反馈: 选中标签使用白色文字(#FFFFFF)+ 红色背景(#FF6B6B),未选中标签使用灰色文字(#666666)+ 浅灰背景(#F0F0F5)。两个状态的对比度差异明显,用户可以在瞬间识别哪些标签已被选中。红色(#FF6B6B)与应用的"AI 生成"按钮颜色一致,形成了统一的视觉主题。

4.3 采购清单的 Flex 标签复用

在结果展示区,采购清单同样使用了 Flex + Wrap 的标签布局:

Flex({ wrap: FlexWrap.Wrap }) {
  ForEach(menu.shoppingList, (item: string): void => {
    Text(item)
      .fontSize(12)
      .fontColor('#333333')
      .backgroundColor('#F0F8FF')
      .borderRadius(12)
      .padding({ left: 10, right: 10, top: 4, bottom: 4 })
      .margin({ right: 6, bottom: 6 })
  })
}
.width('100%')

采购清单标签与忌口标签有两个关键区别:首先,采购清单标签不需要交互(不可点击),因此没有 onClick 回调,也没有选中/未选中状态切换;其次,采购清单标签使用浅蓝色背景(#F0F8FF)而非灰色,这是为了在视觉上区分"可选标签"(忌口,灰/红色)和"信息标签"(采购清单,蓝色)。这种颜色编码策略帮助用户快速区分交互元素和展示元素。

4.4 条件渲染的边界处理

在结果展示区域,我们使用了两层条件检查:

if (this.hasResult && this.outputData !== null) {
  // 展示结果
  Column() {
    Text('菜单方案')...
    ForEach(this.outputData.menus, ...) // 此时 outputData 已确认为非 null
    Text(this.outputData.summary)...
  }
}

在 ArkTS 中,if (this.hasResult && this.outputData !== null) 的双重检查使得编译器能够推断出在 if 块内部,outputData 的类型已经缩窄为 MenuOutput(非 null)。因此,我们可以安全地访问 this.outputData.menusthis.outputData.summary,无需额外的类型断言。

如果只使用 if (this.hasResult) 而不检查 outputData !== null,编译器会报告类型错误——因为 outputData 的类型是 MenuOutput | null,不能直接访问其属性。如果只使用 if (this.outputData !== null) 而不检查 hasResult,在用户首次进入页面时(outputData 初始值为 null),空状态提示文字不会显示,因为 else 分支的条件是 !this.isLoading,可能导致 UI 显示空白。


五、自动化执行阶段(Automate):代码生成与组装

5.1 代码生成流程

「味蕾探险家」的代码生成遵循了 8 部分标准模板:

  1. 导入依赖: import { router } from '@kit.ArkUI'——唯一的外部依赖是路由模块,用于实现返回按钮的页面跳转。

  2. 接口定义: DishItem、MenuSet、MenuOutput 三个接口,共 7 个字段(DishItem 2 个、MenuSet 3 个、MenuOutput 2 个),形成三层嵌套结构。

  3. 入口组件: @Entry @Component struct App2——使用 @Entry 标记为页面入口,@Component 标记为可复用的 UI 组件。

  4. @State 状态变量: 6 个状态变量,分为用户输入组(peopleCount、selectedTaste、avoidList)和结果管理组(outputData、isLoading、hasResult)。

  5. 静态数据: tasteOptions(4 种口味)、avoidOptions(10 种忌口食材),使用 private 修饰。showTastePicker 和 showAvoidPicker 使用 private 修饰,用于控制下拉选择器的展开/收起状态。

  6. 核心方法: toggleAvoid()(多选切换)、isAvoidSelected()(选中状态判断)、generateMockData()(Mock 数据生成)、onGenerate()(生成触发)。

  7. AI API 预留: callAIAPI() 方法(注释形式),对接 fetch POST 请求,请求体包含 people、taste、avoid 三个字段。

  8. UI 构建: build() 方法,包含返回栏、人数输入、口味选择器、忌口多选标签、生成按钮、Loading 动画、结果展示区、空状态提示。

5.2 颜色主题设计

「味蕾探险家」采用暖色系作为主色调,与"美食"主题的情感调性一致:

  • 主色调: #FF6B6B(温暖珊瑚红),用于"AI 生成"按钮、Loading 动画、餐次名称、选中标签背景。这个颜色在视觉上传达了"食欲"和"温暖"的感觉,与家常菜的主题高度契合。
  • 结果卡片背景: #FFFFFF(白色),在灰色页面背景上形成高对比度,便于阅读菜品信息。
  • 方案概要背景: #FFF9E6(浅黄色),黄色在美食应用中常用于传达"温馨"和"提示"的信息,与珊瑚红主色调形成协调的暖色搭配。
  • 采购清单标签背景: #F0F8FF(浅蓝色),蓝色传达"信息"和"清单"的语义,与暖色主色调形成适度的冷暖对比,帮助用户区分菜谱内容(暖色区域)和采购信息(蓝色标签)。
  • 未选中标签背景: #F0F0F5(浅灰色),中性灰色传达"非活跃"状态,与选中状态的珊瑚红形成明确的视觉对比。
  • 返回按钮: #007AFF(系统蓝),遵循 HarmonyOS/iOS 的交互规范,蓝色表示可点击的导航元素。

5.3 页面路由集成

返回按钮使用 router.back() 返回上一页,不传递任何参数。标题居中采用"左-中-右"三段式布局:

Row() {
  Text('← 返回')
    .fontSize(16)
    .fontColor('#007AFF')
    .onClick((): void => { router.back(); })
  Text('味蕾探险家')
    .fontSize(18)
    .fontWeight(FontWeight.Bold)
    .layoutWeight(1)
    .textAlign(TextAlign.Center)
  Text('').width(60)  // 占位元素,保持标题居中
}
.width('100%')
.padding({ left: 16, right: 16, top: 12, bottom: 12 })
.backgroundColor('#FFFFFF')

左侧返回按钮宽度为 60(‘← 返回’ 四个字符的宽度),中间标题使用 layoutWeight(1) 自动填充剩余空间,右侧占位 Text 宽度为 60。这种对称布局确保了标题在任何屏幕宽度下都能精确居中,不会因为左侧返回按钮的存在而偏移。

5.4 异步加载模拟

onGenerate() 方法使用 setTimeout 模拟 800ms 的异步延迟,与 App1 保持一致。在实际使用中,这 800ms 的延迟起到了两个作用:一是模拟真实 AI API 调用的感知延迟,让用户感受到"AI 在工作";二是为状态切换提供足够的视觉过渡时间——isLoading 变为 true 时显示 Loading 动画,800ms 后 isLoading 变为 false 且 hasResult 变为 true 时显示结果。如果延迟过短(如 200ms),用户可能看不到 Loading 动画就直接看到结果,体验上会感觉"太假"。

5.5 AI API 预留接口

callAIAPI() 方法以注释形式预留了完整的 API 调用结构:

// async callAIAPI(): Promise<void> {
//   const response = await fetch('https://api.example.com/menu', {
//     method: 'POST',
//     header: { 'Content-Type': 'application/json' },
//     extraData: JSON.stringify({
//       people: parseInt(this.peopleCount),
//       taste: this.selectedTaste,
//       avoid: this.avoidList
//     })
//   });
//   const result: MenuOutput = await response.json();
//   this.outputData = result;
// }

这个预留接口的关键设计点:

  • 人数类型转换: 在请求体中使用 parseInt(this.peopleCount) 将字符串类型的人数转换为整数。这是因为 Prompt 模板中 people 字段定义为整数类型,而前端存储为字符串类型(TextInput 的 onChange 返回 string)。
  • 请求体结构: 三个字段(people、taste、avoid)与 Prompt 模板中的用户输入 Schema 完全一致,确保前端参数可以直接映射到 API 请求体。
  • 响应类型安全: const result: MenuOutput = await response.json() 将 API 响应解析为 MenuOutput 接口类型,ArkTS 编译器会在编译时检查响应 JSON 的结构是否与 MenuOutput 接口匹配。
  • async/await 语法: 使用 async/await 而非 Promise.then() 链式调用。ArkTS 支持 async/await 语法,且代码可读性更好。

六、评估阶段(Assess):总结与展望

6.1 技术成果

「味蕾探险家」的开发完成了一个完整的智能菜单生成应用,具有以下技术特点:

  • 使用 ArkTS 严格模式,零 any 类型,三层嵌套接口定义完整
  • 实现了多选标签交互模式,包含选中/未选中状态切换和视觉反馈
  • 设计并实现了 toggleAvoid() 和 isAvoidSelected() 两个核心方法,在 ArkTS 严格约束下完成数组操作
  • 使用 Flex 组件的 Wrap 属性实现标签流式排列,解决了 Row 不支持 wrap 的问题
  • 4 组 Mock 数据覆盖了 4 种口味偏好和 2 种人数场景
  • 预留了 callAIAPI() 接口,支持未来对接真实大模型 API
  • 完整的 UI 状态管理:默认态、加载态、结果态、空状态

6.2 代码质量指标

维度 指标 说明
总行数 478 行 单文件完整实现
接口数量 3 个 DishItem、MenuSet、MenuOutput
@State 变量 6 个 输入 3 个 + 结果 3 个
Mock 场景 4 个 2 精确匹配 + 1 口味匹配 + 1 兜底
组件嵌套层级 最深 7 层 Column > Scroll > Column > Column > Column > Row > Text
ForEach 使用 4 处 口味选择器、忌口标签、菜单列表、菜品列表、采购清单
条件渲染 4 处 口味选择器展开、Loading、结果、空状态
核心方法 4 个 toggleAvoid、isAvoidSelected、generateMockData、onGenerate

6.3 与同类应用的对比分析

「味蕾探险家」在 40 个应用中属于"多选型"应用的代表,与「衣品进化室」(App1,纯选择型)和「冰箱魔法厨」(App32,文本输入型)形成对比:

维度 味蕾探险家(App2) 衣品进化室(App1) 冰箱魔法厨(App32)
输入类型 TextInput + 单选 + 多选 三个单选 文本输入 + 单选
参数维度 3 个(人数、口味、忌口) 3 个(气温、场合、风格) 3 个(食材、菜系、人数)
输出复杂度 3 套菜单 × 3 道菜 1 套穿搭 2 道菜谱
最复杂交互 多选标签切换 下拉选择器 食材列表
ArkTS 核心挑战 ForEach 回调纯度 条件渲染 文本输入处理
标志性方法 isAvoidSelected() 无特定 无特定

「味蕾探险家」最独特的技术贡献是 isAvoidSelected() 辅助方法模式——这是 ArkTS 严格模式下解决 ForEach 回调纯度约束的一个通用模式。在后续的 App 开发中,任何需要"在渲染循环中判断数组元素存在性"的场景,都可以复用这个模式。

6.4 性能分析

渲染性能: 组件嵌套层级最深为 7 层,但每一层的子组件数量有限(最多 10 个忌口标签),不会导致明显的性能瓶颈。Flex 组件的 Wrap 布局计算在标签数量较少(10 个以内)时性能开销可忽略。

ForEach 渲染效率: 4 处 ForEach 使用场景中,忌口标签(10 项)和采购清单标签(3-7 项)的数据量都很小,ForEach 的 diff 计算开销极小。菜单列表(3 项)和菜品列表(3 项)的数据量同样很小。总体而言,渲染性能不是本应用的瓶颈。

isAvoidSelected() 的调用频率: 在渲染忌口标签时,每个标签调用 isAvoidSelected() 两次(fontColor 和 backgroundColor 各一次),10 个标签共 20 次调用。每次调用遍历 avoidList(最多 10 个元素),总计 20 × 10 = 200 次比较操作。在用户点击标签时(toggleAvoid 触发),整个 ForEach 会重新渲染,再次执行 20 次 isAvoidSelected() 调用。这个开销在总操作次数上可以忽略不计。

优化建议: 如果未来忌口选项数量增加到 50 个以上,建议将 isAvoidSelected 的结果缓存。在 ArkTS 约束下,可以通过以下方式实现:在 toggleAvoid() 中同时更新一个 private selectedMap: Record<string, boolean> 对象,在 ForEach 回调中使用 this.selectedMap[item] 替代 this.isAvoidSelected(item)。但 Record 类型在 ArkTS 中的支持情况需要确认,且索引访问 rec[index] 的返回类型为 V | undefined,需要额外的空值处理。

6.5 可扩展方向

扩展方向一:过敏原预设。 当前版本中,用户需要手动选择忌口食材。未来可以增加"过敏原预设"功能——提供常见的过敏原组合(如"海鲜过敏"自动选中海鲜、虾、蟹等,"乳糖不耐"自动选中牛奶、奶酪等),减少用户的操作步骤。技术实现上,只需要在 avoidOptions 之外增加一个 private allergenPresets: Record<string, string[]> 映射表,用户选择一个预设后,自动将对应的食材添加到 avoidList 中。

扩展方向二:热量追踪。 当前版本只提供菜品名称和描述。未来可以在每道菜中增加热量估算(如"约 350 千卡"),并在菜单概要中提供每日总热量。这需要修改 DishItem 接口,增加 calories: number 字段,并在 Prompt 模板中要求 AI 估算每道菜的热量。技术挑战在于确保 AI 估算的热量值相对准确,可以通过在 Prompt 中提供常见食材的热量参考表来提升准确性。

扩展方向三:备餐计划。 当前版本生成三套菜单后,采购清单是按餐次独立的。未来可以增加"备餐计划"功能——将三套菜单的采购清单合并去重,按食材分类(蔬菜、肉类、调料等),生成一份完整的超市采购清单。技术实现上,需要在 generateMockData() 或 callAIAPI() 的响应处理中增加一个采购清单合并步骤,将 menus[0].shoppingList、menus[1].shoppingList、menus[2].shoppingList 合并去重。

扩展方向四:季节时令推荐。 当前版本的 Mock 数据没有考虑季节因素。未来可以增加季节参数,让 AI 推荐当季时令食材制作的菜品。例如,春季推荐春笋、韭菜,夏季推荐冬瓜、苦瓜,秋季推荐南瓜、莲藕,冬季推荐萝卜、白菜。这需要增加一个 season 参数(春/夏/秋/冬),并在 Prompt 模板中增加对应的时令食材推荐逻辑。

扩展方向五:烹饪步骤展示。 当前版本只展示菜品名称和描述,不包含烹饪步骤。未来可以与"冰箱魔法厨"(App32)的能力进行整合——点击一道菜品后,展开显示详细的烹饪步骤,包括食材用量、烹饪时间、步骤说明。这需要修改 DishItem 接口,增加 steps: string[] 字段,或通过路由跳转到"冰箱魔法厨"页面,传递菜品名称作为参数。

6.6 开发经验总结

多选标签在 ArkTS 中的最佳实践: 使用 Flex + Wrap 实现标签流式排列,使用 isAvoidSelected() 辅助方法实现选中状态判断,使用 toggleAvoid() 方法实现无解构的数组增删。这三个组件(Flex 布局、辅助方法、数组操作方法)构成了 ArkTS 多选交互的完整实现模式,可以在任何需要标签多选的场景中复用。

ForEach 回调纯度约束的应对策略: 当需要在渲染循环中执行逻辑判断时,将判断逻辑提取为独立的类方法,在 ForEach 回调中仅通过方法调用获取结果。这个策略适用于任何需要条件渲染的 ForEach 场景——不仅是多选状态判断,还包括数组过滤、排序、分组等操作。

ArkTS 数组操作的"复制-替换"模式: 由于 @State 数组需要通过整体替换来触发响应式更新,我们形成了"构建新数组 → 整体赋值"的操作模式。这个模式虽然比原地修改(push、splice)多了一些代码行数,但确保了状态更新的可靠性。在 ArkTS 中,"可靠性优先于简洁性"是一个重要的设计原则。

三层嵌套接口的设计优势: DishItem → MenuSet → MenuOutput 的三层嵌套结构精确对应了 Prompt 模板的输出格式,使得前端可以直接将 API 响应解析为类型安全的接口实例。这种"接口定义与 Prompt Schema 对齐"的设计策略,是 AI 应用开发中保证前后端类型一致性的关键。

Mock 数据场景覆盖的"80/20"原则: 4 个 Mock 场景覆盖了 4 种口味偏好,但没有覆盖所有人数组合。这是"80/20 原则"在 Mock 数据设计中的应用——用 20% 的场景覆盖 80% 的用户需求。对于剩余的 20% 需求(如重口 + 1 人、清淡 + 6 人),兜底方案提供了可用的通用结果,虽然不如精确匹配场景那样精准,但足以满足用户的基本需求。


七、Prompt 工程深度分析

7.1 三维参数设计的约束模型

「味蕾探险家」的参数设计可以抽象为一个"约束满足问题"(Constraint Satisfaction Problem, CSP):

  • 变量: 人数(整数)、口味(枚举)、忌口(集合)
  • 域: 人数 [1, 20]、口味 {清淡, 香辣, 酸甜, 重口}、忌口 {猪肉, 牛肉, 羊肉, 海鲜, 鸡蛋, 牛奶, 辣椒, 蒜, 葱, 香菜}
  • 约束: 生成的菜品不包含忌口集合中的任何食材;菜品数量与人数正相关;菜品的烹饪风格与口味偏好一致

从这个角度理解,AI 的角色是"约束求解器"——在满足所有约束条件的前提下,从菜谱空间中选择一组最优的菜品组合。这个模型可以帮助我们理解 Prompt 设计的核心逻辑:系统指令定义了约束的类型和强度,用户输入提供了约束的具体值,输出格式定义了约束求解结果的结构。

三层约束的优先级: 忌口约束(硬约束)> 口味偏好(软约束)> 人数调整(参考约束)。忌口是硬约束——生成的菜品绝对不能包含忌口食材,这是不可妥协的底线。口味偏好是软约束——菜品应该尽量符合口味偏好,但如果有合理理由(如季节性调整),可以适当偏离。人数调整是参考约束——菜量应该与人数匹配,但"适量"本身就是模糊的,不需要精确计算。

7.2 Temperature 参数的选择逻辑

0.5 的 Temperature 设置是一个经过深思熟虑的选择。在 AI 菜单生成场景中:

  • 需要适度多样性: 同一组参数下,用户希望看到不同的菜品组合。如果 Temperature 过低(如 0.2),AI 可能每次都推荐相同的菜(如"番茄炒蛋"作为最安全的家常菜),导致用户体验单调。
  • 不能过于随机: 菜品组合必须实用且可操作。如果 Temperature 过高(如 0.8),AI 可能生成"巧克力炒鸡蛋"或"西瓜炖排骨"这样的荒谬组合,虽然有趣但不可操作,违背了家常菜应用的实用定位。
  • 需要逻辑一致性: 同一套菜单中的菜品应该相互协调(如口味一致、烹饪方法互补),不能出现"清淡的蒸鱼"和"麻辣火锅"出现在同一套菜单中的情况。

0.5 是一个在"多样性"和"实用性"之间取得平衡的参数值。与「营养方程式」(App33)的 0.3(需要精确热量计算)和「AI 起名大师」(App31)的 0.7(需要高度创意)相比,0.5 处于"实用创意"区间——有一定的创意空间,但以实用为底线。

7.3 采购清单去重合并的 Prompt 设计

采购清单去重合并是 Prompt 模板中的一个关键约束。在没有这个约束的情况下,AI 可能为每道菜单独列出采购清单,导致"鸡蛋"在番茄炒蛋和紫菜蛋花汤中分别出现,用户需要自己合并。通过在 Prompt 中明确要求"采购清单去重合并",AI 会自动将同一食材的多次出现合并为一个条目(如"鸡蛋 5 个"而非"鸡蛋 2 个"+“鸡蛋 3 个”)。

这是一个典型的"Prompt 工程中的输出格式优化"案例——通过在 Prompt 中增加一个简单的约束(“去重合并”),将原本需要前端处理的数据清洗工作转移到了 AI 端,降低了前端代码的复杂度。在 AI 应用开发中,"让 AI 做更多"是一个值得推广的设计原则——AI 擅长处理这类数据清洗和格式规范化任务,而前端应该专注于 UI 渲染和交互逻辑。


本文档基于「味蕾探险家」应用的完整开发实践撰写,所有代码示例均来自实际项目代码。

Logo

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

更多推荐