【HarmonyOS 6.1 开发者盛宴】《灵犀厨房》实战(五):首页“今日吃什么”智能推荐(下)——让推荐算法活起来
HarmonyOS 6.1 开发者盛宴|《灵犀厨房》实战(五):首页“今日吃什么”智能推荐(下)——让推荐算法活起来
摘要:上篇我们给“今日吃什么”搭好了响应式骨架,但推荐列表还是写死的——像个没灵魂的菜单架。今天,我们要给它注入真正的“智能”:用规则引擎+权重算法模拟一个轻量推荐系统,让它能根据你的偏好、食材、甚至时间季节,动态生成专属于你的菜谱列表。我们会从算法模型开始,一路写到UI刷新,全文代码严格基于 API 23。读完这篇,你不仅能掌握 ArkTS 中推荐逻辑的落地方式,更能深刻理解声明式UI与数据驱动的协奏之美。
一、引言与系列定位
第4篇中,我们用 List 和 Grid 搭建了一套断点自适应的推荐卡片流,但数据是写死在 MockData.ts 里的。“每天打开都是这4道菜”——这完全背离了《灵犀厨房》“消除吃饭焦虑”的初心。
本篇是“下”篇,核心使命只有一个:让推荐“活”起来。我们会设计一个可扩展的推荐引擎,基于用户偏好、关键词匹配和时间因子,每次打开都能给出不同且合理的推荐。同时,代码严格承接第4篇的组件结构,RecommendCard 零改动,仅替换数据源。
二、核心原理:推荐引擎——给菜谱排个“聪明序”
如果把第4篇的静态列表比作“把所有菜印在一张纸上”,那么推荐引擎就是“一个懂你口味的服务员”。
我们实现的不是一个黑盒AI,而是一个透明的规则+权重推荐引擎,核心思路如下:
- 偏好匹配:用户设置的口味标签(如“高蛋白”“低脂”)与菜谱标签做交集,匹配越多权重越高。
- 时间/季节加权:夏天偏向推荐清淡低卡,冬天偏向推荐高热量汤煲。
- 随机扰动因子:在权重上叠加小范围随机值,保证每次刷新结果不完全相同。
- 去重机制:记录最近几次推荐,避免重复出现同一道菜。
这套引擎用纯 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 组件的下拉手势触发,逻辑清晰可追踪。
四、架构设计 / 核心逻辑图解
推荐引擎数据流图
推荐引擎数据流图展示了《灵犀厨房》推荐系统从“用户偏好输入”到“UI 卡片展示”的完整数据流转链路,共涉及 7 个关键节点。
-
用户偏好设置:用户在个人中心(后续章节实现)设置的口味标签(如“高蛋白”“快手菜”)、忌口、目标卡路里等,以
UserPreference对象的形式传入推荐引擎。这是整个推荐逻辑的个性化起点。 -
时间/季节检测:引擎内部会根据当前月份注入季节权重,例如冬季优先推荐高热量汤煲,夏季优先推荐低卡凉拌菜。这一步不需要用户干预,完全由系统时间自动计算。
-
菜谱全量库:我们预先准备了 10 道菜谱的完整数据(后续可扩展),每道菜都带有标签、卡路里、食材等结构化信息。引擎将从这个库中筛选出候选菜谱。
-
推荐引擎核心:
RecommendEngine类拿到偏好、季节因子和全量菜谱后,执行我们设计的规则+权重算法——过滤忌口和超标卡路里,计算标签匹配分、季节加权分、随机扰动分,并对最近推荐过的菜品进行去重惩罚。 -
排序结果:经过加权计算后,所有候选菜谱按分数降序排列,这一步会产生一个「带权重的推荐序列」。
-
父组件状态:引擎根据需要的数量(比如 4 道),截取 Top N 的结果,返回给首页父组件的
@State recipes数组。这一步是“数据流入 UI”的关键节点。 -
推荐卡片组件:父组件通过
@Prop将每一个Recipe对象单向传递给RecommendCard组件,卡片根据传入的数据渲染封面、菜名、标签和卡路里。卡片还预留了点击事件,后续会跳转到菜谱详情页。
整个数据流是单向且可溯源的:偏好改变 → 引擎重新计算 → 状态更新 → UI 自动刷新,没有任何隐式的 “幕后操作”,非常适合教学和后续替换为 AI 模型。
组件交互时序图
组件交互时序图以“用户下拉刷新”这一典型场景为例,按时间顺序展示了各个组件和模块之间的交互过程。
-
用户 → 首页父组件:用户在推荐区域执行下拉手势,触发
Refresh组件的onRefreshing回调,首页开始进入刷新状态。 -
首页父组件 → 推荐引擎:首页调用
recommendEngine.getRecommendations(),将当前用户偏好、全量菜谱和期望推荐数量(4)传入引擎。 -
推荐引擎内部:引擎开始遍历全量菜谱,对每一道菜执行过滤和权重计算,然后进行排序、随机扰动和去重处理。这一步是引擎的“自循环”,不涉及其他组件。
-
推荐引擎 → 首页父组件:引擎返回计算后的
Recipe数组(长度为 4)。首页在拿到结果后,更新自身的@State recipes状态。 -
首页父组件 → Grid/List 容器:由于
@State发生变化,ArkUI 的声明式渲染管线被触发,Grid或List容器自动进入重建流程。 -
Grid/List 容器 → RecommendCard:容器在遍历
recipes数组时,将每一个菜谱对象通过@Prop单向传递给对应的RecommendCard实例。这里标注了 “Prop 装饰器单向传递”,强调父组件数据流向子组件的不可逆性。 -
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()作为唯一键,确保列表更新时动画正确、不闪烁。- 无改动组件:
RecommendCard、Breakpoint枚举、Recipe接口完全未动,这就是好架构的威力。
Step 4:增加推荐结果的日志输出与可视化
RecommendEngine 中已埋好日志,每次推荐都会打印。同时,在首页的 refreshRecommendations() 中也追加了日志。这样运行后就能追踪完整的推荐决策。
六、运行与结果验证
-
连接模拟器或真机,部署运行。
-
首页自动加载:打开 App 后等待约 800ms,推荐卡片自动出现。
-
下拉刷新:在推荐区下拉,触发重新推荐,观察卡片是否更新。

-
多设备测试:切换手机/平板模拟器,确认 Grid/List 切换依然正常。

5. 实验演示演示鸿蒙下拉推荐
运行后在 Log 中可观察到如下输出(示例):
[推荐引擎] 偏好标签: 快手菜,高蛋白, 候选菜谱: 10, 最终推荐: 番茄牛腩煲,虾仁蒸蛋,照烧鸡腿饭,凉拌鸡丝
[Index] 推荐刷新完成,当前推荐: 番茄牛腩煲,虾仁蒸蛋,照烧鸡腿饭,凉拌鸡丝
再次下拉刷新:
[推荐引擎] 偏好标签: 快手菜,高蛋白, 候选菜谱: 10, 最终推荐: 凉拌鸡丝,清蒸鲈鱼,虾仁蒸蛋,番茄牛腩煲
[Index] 推荐刷新完成,当前推荐: 凉拌鸡丝,清蒸鲈鱼,虾仁蒸蛋,番茄牛腩煲
日志解读:
- 偏好生效:用户偏好“快手菜”“高蛋白”,推荐结果中这俩标签的菜明显更多。
- 随机扰动:两次推荐结果不完全相同,证明了随机因子在工作。
- 去重工作:虽然日志没直接展示去重处罚的具体值,但连续刷新很难出现“某道菜被永久置顶”的情况。
- 下拉刷新链路正常:
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 仓库
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐



所有评论(0)