HarmonyOS 6.1 开发者盛宴|《灵犀厨房》实战(五):首页“今日吃什么”智能推荐(下)——让推荐算法活起来

摘要:上篇我们给“今日吃什么”搭好了响应式骨架,但推荐列表还是写死的——像个没灵魂的菜单架。今天,我们要给它注入真正的“智能”:用规则引擎+权重算法模拟一个轻量推荐系统,让它能根据你的偏好、食材、甚至时间季节,动态生成专属于你的菜谱列表。我们会从算法模型开始,一路写到UI刷新,全文代码严格基于 API 23。读完这篇,你不仅能掌握 ArkTS 中推荐逻辑的落地方式,更能深刻理解声明式UI与数据驱动的协奏之美。


一、引言与系列定位

第4篇中,我们用 ListGrid 搭建了一套断点自适应的推荐卡片流,但数据是写死在 MockData.ts 里的。“每天打开都是这4道菜”——这完全背离了《灵犀厨房》“消除吃饭焦虑”的初心。

本篇是“下”篇,核心使命只有一个:让推荐“活”起来。我们会设计一个可扩展的推荐引擎,基于用户偏好、关键词匹配和时间因子,每次打开都能给出不同且合理的推荐。同时,代码严格承接第4篇的组件结构,RecommendCard 零改动,仅替换数据源。


二、核心原理:推荐引擎——给菜谱排个“聪明序”

如果把第4篇的静态列表比作“把所有菜印在一张纸上”,那么推荐引擎就是“一个懂你口味的服务员”。

我们实现的不是一个黑盒AI,而是一个透明的规则+权重推荐引擎,核心思路如下:

  1. 偏好匹配:用户设置的口味标签(如“高蛋白”“低脂”)与菜谱标签做交集,匹配越多权重越高。
  2. 时间/季节加权:夏天偏向推荐清淡低卡,冬天偏向推荐高热量汤煲。
  3. 随机扰动因子:在权重上叠加小范围随机值,保证每次刷新结果不完全相同。
  4. 去重机制:记录最近几次推荐,避免重复出现同一道菜。

这套引擎用纯 TypeScript 类实现,不依赖任何后端,但它为第7篇的AI大模型推荐预留了替换接口。

状态管理侧的原理:引擎计算出的新推荐列表,通过父组件的 @State recipes 驱动变化。由于 ArkUI 的声明式渲染管线会自动计算变化部分并更新 Grid/List,我们无需手动调 notifyDataChange


三、关键知识点详解

3.1 动态数据获取方式对比

方式 适用场景 优点 缺点 本篇选型
本地模拟数据 开发期布局验证 无网络依赖,即时可用 无动态性 第4篇使用
本地规则引擎 轻量智能推荐 有动态性,逻辑可控,无需后端 算法简单,无法学习优化 ✅ 本篇使用
远端API 生产级AI推荐 模型强大,持续优化 依赖网络和后端 第7篇预留
本地数据库 历史数据查询 数据持久化,离线可用 首次无数据 第28篇使用

我们选“本地规则引擎”的理由:这一篇要让推荐“看起来智能”,但又不引入后端复杂度。引擎类独立封装,第7篇可以无缝替换为调用云端AI。

3.2 @Watch 监听 vs 显式回调

方式 触发时机 适用场景
@Watch 装饰器 被监听状态变化时自动触发 数据变化后需要执行副作用(如保存日志)
显式方法调用 由具体事件触发 用户主动点击“刷新”按钮或下拉刷新

本篇选择:使用显式方法 refreshRecommendations(),配合 Refresh 组件的下拉手势触发,逻辑清晰可追踪。


四、架构设计 / 核心逻辑图解

推荐引擎数据流图

偏好标签数组

季节权重

全量 Recipe 数组

计算加权分数

Top N 推荐

Prop 传递

点击事件

用户偏好设置

RecommendEngine 推荐引擎

时间/季节检测

菜谱全量库

排序结果

父组件 @State recipes

RecommendCard 组件

预留详情跳转

推荐引擎数据流图展示了《灵犀厨房》推荐系统从“用户偏好输入”到“UI 卡片展示”的完整数据流转链路,共涉及 7 个关键节点。

  1. 用户偏好设置:用户在个人中心(后续章节实现)设置的口味标签(如“高蛋白”“快手菜”)、忌口、目标卡路里等,以 UserPreference 对象的形式传入推荐引擎。这是整个推荐逻辑的个性化起点。

  2. 时间/季节检测:引擎内部会根据当前月份注入季节权重,例如冬季优先推荐高热量汤煲,夏季优先推荐低卡凉拌菜。这一步不需要用户干预,完全由系统时间自动计算。

  3. 菜谱全量库:我们预先准备了 10 道菜谱的完整数据(后续可扩展),每道菜都带有标签、卡路里、食材等结构化信息。引擎将从这个库中筛选出候选菜谱。

  4. 推荐引擎核心RecommendEngine 类拿到偏好、季节因子和全量菜谱后,执行我们设计的规则+权重算法——过滤忌口和超标卡路里,计算标签匹配分、季节加权分、随机扰动分,并对最近推荐过的菜品进行去重惩罚。

  5. 排序结果:经过加权计算后,所有候选菜谱按分数降序排列,这一步会产生一个「带权重的推荐序列」。

  6. 父组件状态:引擎根据需要的数量(比如 4 道),截取 Top N 的结果,返回给首页父组件的 @State recipes 数组。这一步是“数据流入 UI”的关键节点。

  7. 推荐卡片组件:父组件通过 @Prop 将每一个 Recipe 对象单向传递给 RecommendCard 组件,卡片根据传入的数据渲染封面、菜名、标签和卡路里。卡片还预留了点击事件,后续会跳转到菜谱详情页。

整个数据流是单向且可溯源的:偏好改变 → 引擎重新计算 → 状态更新 → UI 自动刷新,没有任何隐式的 “幕后操作”,非常适合教学和后续替换为 AI 模型。

组件交互时序图

RecommendCard Grid/List 容器 RecommendEngine 首页父组件 Index 用户 RecommendCard Grid/List 容器 RecommendEngine 首页父组件 Index 用户 下拉刷新 getRecommendations(prefs, allRecipes, 4) 遍历菜谱,计算权重 排序 + 随机扰动 + 去重 返回 Recipe[4] 更新 @State recipes 声明式触发重新渲染 传入新 recipe(@Prop) UI 自动更新

组件交互时序图以“用户下拉刷新”这一典型场景为例,按时间顺序展示了各个组件和模块之间的交互过程。

  1. 用户 → 首页父组件:用户在推荐区域执行下拉手势,触发 Refresh 组件的 onRefreshing 回调,首页开始进入刷新状态。

  2. 首页父组件 → 推荐引擎:首页调用 recommendEngine.getRecommendations(),将当前用户偏好、全量菜谱和期望推荐数量(4)传入引擎。

  3. 推荐引擎内部:引擎开始遍历全量菜谱,对每一道菜执行过滤和权重计算,然后进行排序、随机扰动和去重处理。这一步是引擎的“自循环”,不涉及其他组件。

  4. 推荐引擎 → 首页父组件:引擎返回计算后的 Recipe 数组(长度为 4)。首页在拿到结果后,更新自身的 @State recipes 状态。

  5. 首页父组件 → Grid/List 容器:由于 @State 发生变化,ArkUI 的声明式渲染管线被触发,GridList 容器自动进入重建流程。

  6. Grid/List 容器 → RecommendCard:容器在遍历 recipes 数组时,将每一个菜谱对象通过 @Prop 单向传递给对应的 RecommendCard 实例。这里标注了 “Prop 装饰器单向传递”,强调父组件数据流向子组件的不可逆性。

  7. RecommendCard 自身:卡片接收到新的 recipe 后,UI 立即更新,展示新的封面、菜名、标签和卡路里信息。

整个交互流程清晰体现了声明式 UI 的数据驱动优势:开发者只需要更新数据(@State recipes),框架会自动完成从容器到卡片的全部更新,无需手动调用刷新方法或操作 DOM。这也是 HarmonyOS 开发中“一次状态变更,全局自动响应”理念的绝佳案例。


五、实战:为《灵犀厨房》注入推荐算法与动态数据

所有代码从第4篇工程直接生长,不破坏已有组件。

Step 1:定义用户偏好模型与推荐引擎类

entry/src/main/ets/model/ 下新建 UserPreference.ts

// model/UserPreference.ts
export interface UserPreference {
  // 用户喜欢的口味标签,如 '高蛋白'、'低脂'、'快手菜'
  favoriteTags: string[];
  // 用户忌口标签,如 '花生'、'海鲜'
  allergies: string[];
  // 卡路里上限(0 表示不设限)
  maxCalories: number;
}

// 默认偏好(后续第29篇会接入用户设置界面)
export const defaultPreference: UserPreference = {
  favoriteTags: ['快手菜', '高蛋白'],
  allergies: [],
  maxCalories: 800
};

变化点解读:用独立的 UserPreference 接口统一管理偏好,而不是散落在组件里。目前先写死一个 defaultPreference,后续个人中心篇会从持久化存储读取真实偏好。

entry/src/main/ets/common/ 下新建 RecommendEngine.ts:(注意:common目录需先创建)

// common/RecommendEngine.ts
import { Recipe } from '../model/Recipe';
import { UserPreference } from '../model/UserPreference';

// 定义接口
interface ScoredRecipe {
  recipe: Recipe;
  score: number;
}


export class RecommendEngine {
  // 最近推荐过的ID集合,用于去重
  private recentIds: Set<number> = new Set();
  private maxRecentSize: number = 10; // 最多记住10个

  /**
   * 核心推荐方法
   * @param preference 用户偏好
   * @param allRecipes 全量菜谱库
   * @param count 需要推荐的个数
   * @returns 推荐结果数组
   */
  getRecommendations(preference: UserPreference, allRecipes: Recipe[], count: number): Recipe[] {
    // 1. 过滤忌口
    let candidates = allRecipes.filter(recipe => {
      for (const tag of recipe.tags) {
        if (preference.allergies.includes(tag)) return false;
      }
      if (preference.maxCalories > 0 && recipe.calories > preference.maxCalories) return false;
      return true;
    });

    // 2. 计算权重,返回 ScoredRecipe 类型
    const scored: ScoredRecipe[] = candidates.map(recipe => {
      let score = 0;

      for (const tag of recipe.tags) {
        if (preference.favoriteTags.includes(tag)) score += 10;
      }

      const month = new Date().getMonth() + 1;
      if (month >= 12 || month <= 2) {
        if (recipe.calories >= 500) score += 5;
      } else if (month >= 6 && month <= 8) {
        if (recipe.calories <= 300) score += 5;
      }

      score += Math.floor(Math.random() * 7) - 3;

      if (this.recentIds.has(recipe.id)) score -= 20;

      return { recipe, score } as ScoredRecipe; // 明确类型断言
    });

    // 3. 按分数降序排列
    scored.sort((a, b) => b.score - a.score);

    // 4. 取 Top N
    const result = scored.slice(0, count).map(item => {
      this.recentIds.add(item.recipe.id);
      if (this.recentIds.size > this.maxRecentSize) {
        const keysIter = this.recentIds.keys();
        const first = keysIter.next();
        if (!first.done) {
          this.recentIds.delete(first.value);
        }
      }
      return item.recipe;
    });
    console.info(`[推荐引擎] 偏好标签: ${preference.favoriteTags}, 候选菜谱: ${candidates.length}, 最终推荐: ${result.map(r => r.name)}`);
    return result;
  }
}

变化点解读

  • 算法透明:不是魔法,就是标签匹配+季节加分+随机扰动,读者完全可以理解和修改。
  • 去重机制:用 Set 记录最近推荐的 ID,避免连续刷出同一道菜。
  • 独立封装:引擎类完全独立于 UI,第7篇接入AI推荐时,只需新建一个实现了相同接口的类即可。
  • 日志埋点:每次推荐打印候选数和最终结果,方便调试。
  • 更新后的项目结构
    在这里插入图片描述

Step 2:充实菜谱全量库

现在把 MockData.ts 升级为更丰富的菜谱库,供推荐引擎筛选:

// model/MockData.ts(替换原有内容)
import { Recipe } from './Recipe';

export const allRecipes: Recipe[] = [
  {
    id: 1, name: '番茄牛腩煲', cover: $r('app.media.placeholder_food'),
    ingredients: ['牛腩', '番茄', '洋葱'], steps: ['焯水', '煸炒', '慢炖'],
    tags: ['快手菜', '高蛋白', '冬季暖身'], calories: 580
  },
  {
    id: 2, name: '蒜蓉西兰花', cover: $r('app.media.placeholder_food'),
    ingredients: ['西兰花', '蒜', '蚝油'], steps: ['焯水', '爆香蒜', '翻炒'],
    tags: ['低脂', '维生素', '夏季清爽'], calories: 120
  },
  {
    id: 3, name: '照烧鸡腿饭', cover: $r('app.media.placeholder_food'),
    ingredients: ['鸡腿', '照烧汁', '米饭'], steps: ['去骨', '煎制', '调味'],
    tags: ['高蛋白', '便当', '便当友好'], calories: 650
  },
  {
    id: 4, name: '冬瓜排骨汤', cover: $r('app.media.placeholder_food'),
    ingredients: ['排骨', '冬瓜', '薏米'], steps: ['焯水', '炖煮', '调味'],
    tags: ['汤品', '滋补', '冬季暖身'], calories: 420
  },
  {
    id: 5, name: '凉拌鸡丝', cover: $r('app.media.placeholder_food'),
    ingredients: ['鸡胸肉', '黄瓜', '辣椒油'], steps: ['煮熟', '撕丝', '凉拌'],
    tags: ['快手菜', '低脂', '夏季清爽'], calories: 220
  },
  {
    id: 6, name: '红烧肉', cover: $r('app.media.placeholder_food'),
    ingredients: ['五花肉', '冰糖', '酱油'], steps: ['焯水', '炒糖色', '慢炖'],
    tags: ['经典', '高热量', '冬季暖身'], calories: 780
  },
  {
    id: 7, name: '虾仁蒸蛋', cover: $r('app.media.placeholder_food'),
    ingredients: ['虾仁', '鸡蛋', '葱花'], steps: ['打蛋', '过筛', '蒸制'],
    tags: ['快手菜', '高蛋白', '老少皆宜'], calories: 180
  },
  {
    id: 8, name: '酸辣土豆丝', cover: $r('app.media.placeholder_food'),
    ingredients: ['土豆', '干辣椒', '醋'], steps: ['切丝', '浸泡', '爆炒'],
    tags: ['快手菜', '素菜', '下饭'], calories: 200
  },
  {
    id: 9, name: '清蒸鲈鱼', cover: $r('app.media.placeholder_food'),
    ingredients: ['鲈鱼', '姜', '蒸鱼豉油'], steps: ['处理鱼', '蒸制', '淋油'],
    tags: ['高蛋白', '低脂', '宴客'], calories: 310
  },
  {
    id: 10, name: '麻辣香锅', cover: $r('app.media.placeholder_food'),
    ingredients: ['虾', '藕片', '午餐肉'], steps: ['焯水', '炒底料', '翻炒'],
    tags: ['重口味', '高热量', '聚会'], calories: 720
  }
];

变化点解读:菜谱库从4个扩展到10个,标签更细分(加入了季节标签),给推荐引擎足够的选择空间。文件名保持 MockData.ts 不变,但导出名改为语义更准确的 allRecipes

Step 3:改造首页,集成推荐引擎

修改 Index.ets,将静态数据源替换为推荐引擎动态输出,并增加下拉刷新:

// Index.ets(核心改动部分)
import { window, display } from '@kit.ArkUI';
import { Recipe } from '../model/Recipe';
import { Breakpoint } from './Breakpoint';
import { allRecipes } from '../model/MockData';
import { RecommendCard } from '../components/RecommendCard';
import { RecommendEngine } from '../common/RecommendEngine';
import { UserPreference, defaultPreference } from '../model/UserPreference';

@Entry
@Component
struct Index {
  @State appName: string = '灵犀厨房';
  @State slogan: string = '你的AI私人厨艺助手';
  @State currentBreakpoint: Breakpoint = Breakpoint.SM;
  @State recipes: Recipe[] = []; // 动态推荐结果,初始为空
  @State isRefreshing: boolean = false; // 下拉刷新状态

  private windowClass: window.Window | null = null;
  private sizeChangeCallback: ((size: window.Size) => void) | null = null;
  private density: number = 1;
  // 推荐引擎实例
  private recommendEngine: RecommendEngine = new RecommendEngine();
  private userPreference: UserPreference = defaultPreference;

  private getBreakpoint(pxWidth: number): Breakpoint {
    const vpWidth = pxWidth / this.density;
    if (vpWidth >= 840) return Breakpoint.LG;
    if (vpWidth >= 600) return Breakpoint.MD;
    return Breakpoint.SM;
  }

  // 执行推荐逻辑,封装为独立方法
  private refreshRecommendations(): void {
    this.recipes = this.recommendEngine.getRecommendations(
      this.userPreference,
      allRecipes,
      4 // 默认推4道菜
    );
    console.info(`[Index] 推荐刷新完成,当前推荐: ${this.recipes.map(r => r.name)}`);
  }

  async aboutToAppear(): Promise<void> {
    try {
      const defaultDisplay = display.getDefaultDisplaySync();
      this.density = defaultDisplay.densityPixels;

      this.windowClass = await window.getLastWindow(getContext(this));
      if (this.windowClass) {
        const rect: window.Rect = this.windowClass.getWindowProperties().windowRect;
        this.currentBreakpoint = this.getBreakpoint(rect.width);

        this.sizeChangeCallback = (size: window.Size): void => {
          this.currentBreakpoint = this.getBreakpoint(size.width);
        };
        this.windowClass.on('windowSizeChange', this.sizeChangeCallback);
      }

      // 首次加载触发推荐
      this.refreshRecommendations();
    } catch (err) {
      console.error('窗口初始化失败', JSON.stringify(err));
    }
  }

  aboutToDisappear(): void {
    if (this.windowClass && this.sizeChangeCallback) {
      this.windowClass.off('windowSizeChange', this.sizeChangeCallback);
    }
  }

  build() {
    Flex({
      direction: FlexDirection.Column,
      justifyContent: FlexAlign.Start,
      alignItems: ItemAlign.Center
    }) {
      // 品牌头部区(与第4篇相同,略作精简)
      Column() {
        Text('🍳')
          .fontSize(this.currentBreakpoint === Breakpoint.SM ? 50 : 80)
        Text(this.appName)
          .fontSize(this.currentBreakpoint === Breakpoint.SM ? 28 : 40)
          .fontWeight(FontWeight.Bold)
          .fontColor('#FF6B35')
        Text(this.slogan ?? '你的私人厨艺助手')
          .fontSize(14)
          .fontColor('#999')
          .margin({ top: 4 })
      }
      .margin({ top: 40, bottom: 10 })
      .width('100%')
      .alignItems(HorizontalAlign.Center)

      // 推荐区——用 Refresh 组件包裹,支持下拉刷新
      Refresh({ refreshing: $$this.isRefreshing }) {
        if (this.currentBreakpoint === Breakpoint.LG) {
          Grid() {
            ForEach(this.recipes, (recipe: Recipe) => {
              GridItem() {
                RecommendCard({ recipe: recipe })
                  .margin(10)
              }
            }, (recipe: Recipe) => recipe.id.toString())
          }
          .columnsTemplate('1fr 1fr')
          .columnsGap(12)
          .rowsGap(12)
          .padding({ left: 16, right: 16 })
          .width('100%')
          .layoutWeight(1)
        } else {
          List() {
            ForEach(this.recipes, (recipe: Recipe) => {
              ListItem() {
                RecommendCard({ recipe: recipe })
                  .margin({ left: 16, right: 16, bottom: 12 })
              }
            }, (recipe: Recipe) => recipe.id.toString())
          }
          .width('100%')
          .layoutWeight(1)
          .divider({ strokeWidth: 0 })
        }
      }
      .onRefreshing(() => {
        // 下拉刷新触发重新推荐
        this.isRefreshing = true;
        setTimeout(() => {
          this.refreshRecommendations();
          this.isRefreshing = false;
        }, 800); // 模拟800ms延迟,给用户反馈感
      })
      .layoutWeight(1)
    }
    .height('100%')
    .width('100%')
    .backgroundColor('#FFF8F0')
  }
}

变化点解读

  • 数据源替换recipes 不再用 mockRecipes 初始化,而是由 refreshRecommendations() 动态生成。
  • 首刷触发:在 aboutToAppear 末尾调用 refreshRecommendations(),App 打开即刻获得推荐。
  • 下拉刷新Refresh 组件包裹整个推荐区,onRefreshing 回调中调用重新推荐,模拟 800ms 延迟让用户感知到刷新动作。
  • ForEach 的 key:增加了 (recipe: Recipe) => recipe.id.toString() 作为唯一键,确保列表更新时动画正确、不闪烁。
  • 无改动组件RecommendCardBreakpoint 枚举、Recipe 接口完全未动,这就是好架构的威力。

Step 4:增加推荐结果的日志输出与可视化

RecommendEngine 中已埋好日志,每次推荐都会打印。同时,在首页的 refreshRecommendations() 中也追加了日志。这样运行后就能追踪完整的推荐决策。


六、运行与结果验证

  1. 连接模拟器或真机,部署运行。

  2. 首页自动加载:打开 App 后等待约 800ms,推荐卡片自动出现。

  3. 下拉刷新:在推荐区下拉,触发重新推荐,观察卡片是否更新。
    在这里插入图片描述

  4. 多设备测试:切换手机/平板模拟器,确认 Grid/List 切换依然正常。
    在这里插入图片描述
    5. 实验演示

    演示鸿蒙下拉推荐

运行后在 Log 中可观察到如下输出(示例):

[推荐引擎] 偏好标签: 快手菜,高蛋白, 候选菜谱: 10, 最终推荐: 番茄牛腩煲,虾仁蒸蛋,照烧鸡腿饭,凉拌鸡丝
[Index] 推荐刷新完成,当前推荐: 番茄牛腩煲,虾仁蒸蛋,照烧鸡腿饭,凉拌鸡丝

再次下拉刷新:

[推荐引擎] 偏好标签: 快手菜,高蛋白, 候选菜谱: 10, 最终推荐: 凉拌鸡丝,清蒸鲈鱼,虾仁蒸蛋,番茄牛腩煲
[Index] 推荐刷新完成,当前推荐: 凉拌鸡丝,清蒸鲈鱼,虾仁蒸蛋,番茄牛腩煲

日志解读

  1. 偏好生效:用户偏好“快手菜”“高蛋白”,推荐结果中这俩标签的菜明显更多。
  2. 随机扰动:两次推荐结果不完全相同,证明了随机因子在工作。
  3. 去重工作:虽然日志没直接展示去重处罚的具体值,但连续刷新很难出现“某道菜被永久置顶”的情况。
  4. 下拉刷新链路正常onRefreshing 触发 → 调用引擎 → 状态更新 → UI 刷新,整条链路无报错。

七、本阶段总结与下篇预告

今天,我们为《灵犀厨房》注入了第一缕“智能之光”。你学到了:

  • 推荐引擎的底层逻辑:规则+权重+随机因子+去重,简单但有效。
  • 状态管理与数据驱动@State 如何驱动 Grid/List 无缝更新,无需手动操作组件。
  • 下拉刷新落地Refresh 组件的声明式用法,链路清晰。
  • 工程迭代思维的胜利:从第4篇到第5篇,核心组件零改动,新增逻辑通过独立类注入——这就是“开放-封闭原则”在前端架构中的实战。

下篇预告:推荐已经“活”了,但食材还得手动输入?下一篇(第6篇)我们将调用相机进行食材识别,用 HarmonyOS 6.1.0 的图像分析服务,让你拍一张食材照片,App 就能自动推荐能做哪些菜。视觉智能的大门,即将打开!


八、互动引导与福利

💎 粉丝专属福利点赞 + 收藏 本专栏任意文章,并在评论区留言“纯血鸿蒙,我准备好了”,私信我即可领取《HarmonyOS 6.0 安全技术白皮书》电子版!

🔗 本文源码Gitee 仓库 - 灵犀厨房系列(第5章代码已同步更新)

如果你发现本文还有任何不严谨之处,欢迎随时指出,我们一起共建最优质的 HarmonyOS 6.1 学习内容!如果觉得有帮助,请不要吝啬你的点赞 👍、收藏 ⭐ 和评论 💬,我们下一篇见~


📚 专栏入口《从0到1开发灵犀厨房App》合集 | ⭐ 本文源码Gitee 仓库

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐