鸿蒙 Next 临期食品救援地图 App 开发实战:分类筛选 + 救援系统 + 成就体系



鸿蒙 Next 临期食品救援地图 App 开发实战:分类筛选 + 救援系统 + 成就体系
作者:duluo
SDK 版本:HarmonyOS API 24 (Next)
开发工具:DevEco Studio
语言框架:ArkTS + ArkUI
字数:约 9200 字
目录
- 引言
- 产品概念与数据模型
- 三 Tab 架构设计
- 首页统计与成就系统
- 食品列表与分类筛选
- 食品卡片组件
- 救援系统与状态管理
- Helper 方法在救援系统中的应用
- 编译错误全记录
- 第三十二款 App 全景回顾
- 结语
1. 引言
1.1 食物浪费与临期食品
全球每年约有 13 亿吨食物被浪费,占食物总产量的三分之一。与此同时,有 8.2 亿人面临饥饿。这是一个荒诞的对比——一边是浪费,一边是匮乏。
临期食品——即将到达保质期但仍在保质期内的食品——是减少食物浪费的关键环节。这些食品品质完好,只是因为临近保质期而被打折出售。在欧洲,"Too Good To Go"等 App 已经证明了临期食品救援模式的可行性:消费者用折扣价购买临期食品,商家减少浪费,地球少一份负担。
"临期食品救援地图"App 将这个概念搬到了移动端:发现附近的临期食品,以折扣价"救援"它们,记录你为减少食物浪费做出的贡献。
1.2 本 App 的产品定位
本 App 不是外卖平台(不配送),不是电商平台(不发货),也不是点评平台(不评价)。它就是一个信息展示 + 一键救援的工具——告诉用户附近有什么临期食品、多少钱、去哪取。
核心价值:让"救援临期食品"像刷短视频一样简单。
1.3 三十二款 App 全景
App 数量: 32
代码总行数: ~18,200 行
编译错误数: ~296 个
博客总字数: ~320,000 字
技术博客数: 32 篇
2. 产品概念与数据模型
2.1 功能需求
用户故事 1:我想看看附近有什么临期食品可以买
用户故事 2:我想按分类筛选食品(面包、乳品等)
用户故事 3:我想一键救援(购买)临期食品
用户故事 4:我想看看我救援了多少食品、省了多少钱
用户故事 5:我想了解临期食品的知识
功能清单:
├── F1: 首页统计概览(已救援/节省/可救援)
├── F2: 紧急食品专区(今日到期)
├── F3: 临期食品知识科普
├── F4: 月度成就体系
├── F5: 食品列表 + 分类标签筛选
├── F6: 食品卡片(原价/打折价/到期日)
├── F7: 一键救援
├── F8: 救援记录列表
└── F9: 节省金额统计
2.2 数据模型
interface FoodItem {
id: number;
name: string; // 食品名称
shop: string; // 店铺名称
emoji: string; // 图标
origPrice: number; // 原价
disPrice: number; // 折扣价
expDate: string; // 到期时间(今天/明天/后天)
dist: string; // 距离
tag: string; // 分类(面包/乳品/蔬菜/水果/即食/蛋类)
}
9 个字段覆盖了临期食品的所有展示信息。tag 字段用于分类筛选,expDate 用于紧急程度判断,origPrice 和 disPrice 用于计算节省金额。
2.3 9 件食品的价格设计
9 件食品的价格覆盖了从 ¥12 到 ¥35 的范围,折扣率从 60% 到 70%:
| 价格区间 | 件数 | 原价平均 | 折扣价平均 | 平均折扣率 |
|---|---|---|---|---|
| ¥10-20 | 3 | ¥15 | ¥5 | 67% |
| ¥20-30 | 4 | ¥24 | ¥9 | 63% |
| ¥30-40 | 2 | ¥33 | ¥11 | 67% |
设计意图:真实的临期食品折扣通常在 50%-70% 之间。折扣太低用户觉得不划算,折扣太高不真实。60%-67% 的折扣率让用户觉得"值得跑一趟"。
3. 三 Tab 架构设计
3.1 Tab 配置
build() {
Stack() {
Column().backgroundColor(C.bg)
Column() {
this.buildHeader()
if (this.activeTab === 0) this.buildHomeTab()
else if (this.activeTab === 1) this.buildFoodTab()
else this.buildRescueTab()
this.buildTabBar()
}
}
}
| Tab | 图标 | 功能 | 使用行为 |
|---|---|---|---|
| 0 | 🏠 | 首页 — 统计 + 成就 + 紧急食品 | 打开 App 的第一眼 |
| 1 | 📋 | 食品 — 列表 + 筛选 + 救援 | 主要操作页面 |
| 2 | 🛒 | 救援 — 已救援记录 | 查看成果 |
3.2 首页布局
首页从上到下分为四个区域:
┌──────────────────────────────┐
│ 统计卡片:已救援/节省/可救援 │
│ 已减少 X.X kg 食物浪费 │
├──────────────────────────────┤
│ ⚡ 即将过期 · 立即救援 │
│ [紧急食品卡片 × 3] │
├──────────────────────────────┤
│ 💡 什么是临期食品? │
│ (知识科普) │
├──────────────────────────────┤
│ 🏆 本月成就 │
│ ✅救援5件 ⬜救援10件 ... │
└──────────────────────────────┘
3.3 数据流
食品数据(常量 FOODS)→ 分类筛选 → 食品列表
↓ 点击救援
救援系统 → rescuedItems 更新 → rescued 更新
↓
首页统计 ← 读取 rescued + rescuedItems
救援记录 ← 遍历 rescuedItems
月度成就 ← 根据 rescued 计算
紧急食品 ← 筛选 FOODS 中 expDate='今天' 的 3 件
所有数据从 FOODS 常量和 rescuedItems 状态派生。首页统计、紧急食品、月度成就都是派生数据,不需要额外的 @State 管理。
4. 首页统计与成就系统
4.1 三指标统计卡片
Row() {
Column() {
Text(this.rescued + '').fontSize(32).fontColor(C.primary).fontWeight(FontWeight.Bold)
Text('已救援(件)').fontSize(12).fontColor(C.textMuted)
}
Column() {
Text(this.getSavedAmount() + '').fontSize(32).fontColor(C.warm)
Text('节省(元)').fontSize(12)
}
Column() {
Text(FOODS.length + '').fontSize(32).fontColor(C.accent)
Text('可救援').fontSize(12)
}
}
三个指标并排展示,用不同颜色区分维度:主色绿(已救援)、暖橙(节省金额)、强调色(可救援)。每个指标使用 32sp 超大字号突出数字,12sp 灰色文字标注单位。
统计卡片下方还有一个环保数据:
Text('🌱 已减少 ' + (this.rescued * 0.5).toFixed(1) + 'kg 食物浪费')
每救援一件食品估算减少 0.5kg 食物浪费。这个数据不是精确的(不同食品重量不同),但它提供了一个"可感知的环保贡献"——用户不只是省了几块钱,还为减少食物浪费做出了贡献。
4.2 紧急食品专区
getUrgent(): FoodItem[] {
const urgent: FoodItem[] = [];
for (const f of FOODS) {
if (f.expDate === '今天' && !this.isRescued(f.id)) urgent.push(f);
if (urgent.length >= 3) break;
}
return urgent;
}
从食品列表中筛选出"今天到期"且尚未救援的食品,最多取 3 件。使用 “⚡ 即将过期 · 立即救援” 的标题和红色到期标签来制造紧迫感。
4.3 月度成就系统
Row() {
Column() { Text(this.rescued >= 5 ? '✅' : '⬜'); Text('救援5件'); }
Column() { Text(this.rescued >= 10 ? '✅' : '⬜'); Text('救援10件'); }
Column() { Text(this.getSavedAmount() >= 50 ? '✅' : '⬜'); Text('省50元'); }
Column() { Text(this.rescued >= 20 ? '✅' : '⬜'); Text('救援20件'); }
}
4 个成就分四级难度:5 件(入门)、10 件(进阶)、省 50 元(交叉条件)、20 件(高手)。已完成显示 ✅,未完成显示 ⬜。
成就系统的作用不是"让用户完成所有成就",而是让用户知道"下一步可以做什么"——看到救援 5 件的成就只差 2 件,用户可能会多救援几件食品来达成。
5. 食品列表与分类筛选
5.1 分类标签
Row() {
ForEach(['全部', '面包', '乳品', '蔬菜', '水果', '即食'], (tag: string, idx: number) => {
Text(tag).fontSize(13)
.fontColor(this.foodTag === idx ? Color.White : C.text)
.padding({ left: 12, right: 12, top: 5, bottom: 5 })
.backgroundColor(this.foodTag === idx ? C.primary : C.bgLight).borderRadius(12)
.onClick(() => { this.foodTag = idx; })
}, (tag: string) => tag)
}
6 个标签水平排列,与"方言学习"(App 30)的方言切换器设计相同。选中态为白字 + 绿色背景,未选中为深绿字 + 浅绿背景。
标签数量为什么是 6 个:在手机屏幕宽度上,6 个标签刚好铺满一行,不需要滑动查看更多。超过 6 个就需要水平滚动了。
5.2 分类筛选
@State foodTag: number = 0;
getFilteredFoods(): FoodItem[] {
if (this.foodTag === 0) return FOODS;
const tags = ['', '面包', '乳品', '蔬菜', '水果', '即食'];
const tag = tags[this.foodTag];
const result: FoodItem[] = [];
for (const f of FOODS) {
if (f.tag === tag && !this.isRescued(f.id)) result.push(f);
}
return result;
}
foodTag = 0 表示"全部",不筛选。其他值按对应的 tag 筛选。筛选时同时排除已救援的食品。foodTag 是一个 @State 变量,切换后 ForEach 自动重新渲染。
6. 食品卡片组件
6.1 卡片布局
┌──────────────────────────────────────┐
│ 🍞 ¥6 │
│ 全麦吐司 ¥18 │
│ 阳光面包房 · 300m │
│ │
│ 明天到期 立省 ¥12 🚀 救援 │
└──────────────────────────────────────┘
卡片分两行:第一行展示 emoji + 食品名 + 店铺距离(左侧)+ 折扣价 + 原价(右侧),第二行展示到期标签 + 节省金额 + 救援按钮。
原价显示删除线:Text('¥' + item.origPrice).decoration({ type: TextDecorationType.LineThrough })。删除线是电商 App 中"打折"的通用视觉语言——用户看到删除线就自动理解为"原价"。
6.2 已救援状态
.backgroundColor(this.isRescued(item.id) ? C.bgLight : C.bgCard)
已救援的食品卡片背景色从白色切换为浅绿色(C.bgLight),按钮从"🚀 救援"变为"✅ 已救援"。视觉上明确区分"已处理"和"待处理"。
6.3 到期标签的颜色编码
Text(item.expDate + '到期')
.fontColor(item.expDate === '今天' ? C.danger : C.textMuted)
.backgroundColor(item.expDate === '今天' ? C.danger + '15' : C.bgLight)
"今天到期"使用红色文字 + 浅红背景,“明天/后天到期"使用灰色文字 + 浅灰背景。红色制造紧迫感,灰色传递"不急”。
7. 救援系统与状态管理
7.1 状态设计
@State rescued: number = 0;
@State rescuedItems: number[] = [];
两个状态变量:rescued 是救援总件数(整数),rescuedItems 是已救援食品 ID 列表。两者本质上是同一份数据的两种表达——rescued 是 rescuedItems.length 的快捷方式。
为什么不用一个变量:rescued 在首页统计卡片中直接使用(不需要计算 length),比每次调用 rescuedItems.length 更高效。虽然对 32 件食品来说性能差异可以忽略,但这个设计保留了"后续可能添加更多统计字段"的扩展性。
7.2 救援操作
doRescue(id: number): void {
if (this.isRescued(id)) return;
this.rescuedItems = [id, ...this.rescuedItems];
this.rescued = this.rescuedItems.length;
promptAction.showToast({ message: '🎉 救援成功!快去 ' + this.getShop(id) + ' 取货' });
}
三步操作:防重复检查 → 头部插入 ID 列表 → 更新计数。Toast 提示包含店铺名称,引导用户下一步行动。
7.3 节省金额计算
getSavedAmount(): number {
let total = 0;
for (const id of this.rescuedItems) {
for (const f of FOODS) {
if (f.id === id) { total += (f.origPrice - f.disPrice); break; }
}
}
return total;
}
遍历 rescuedItems 中的每个 ID,在 FOODS 中查找对应的食品,累加原价与折扣价的差值。最坏时间复杂度 O(n×m),其中 n 是救援件数,m 是食品总数(9)。对于小于 100 的数据规模,双重循环的性能损耗可以忽略。
8. Helper 方法在救援系统中的应用
8.1 Helper 方法清单
本 App 延续了 App 30(方言学习)的 Helper 方法模式。救援系统相关的 Helper 方法:
| 方法 | 用途 | 在 @Builder 中替代 |
|---|---|---|
isRescued(id) |
判断食品是否已救援 | rescuedItems.indexOf(id) >= 0 |
getSavedAmount() |
计算总节省金额 | 内联计算(含循环) |
getShop(id) |
获取店铺名 | 遍历 FOODS 查找 |
getUrgent() |
获取紧急食品 | 筛选 + 排序 |
getFoodEmoji(id) |
获取食品 emoji | findFood(id).emoji |
getFoodName(id) |
获取食品名 | findFood(id).name |
getFoodSaved(id) |
获取节省金额 | origPrice - disPrice |
findFood(id) |
根据 ID 查找食品 | FOODS.find() |
8 个 Helper 方法中,4 个(getFoodEmoji、getFoodName、getFoodShop、getFoodSaved)纯粹为了解决 @Builder 的 const 约束而创建。如果没有 const 约束,它们可以直接内联为 findFood(id)?.emoji 等表达式。
8.2 救援卡片的 @Builder 实现
@Builder
buildRescueCard(id: number) {
Column() {
Row() {
Text(this.getFoodEmoji(id)).fontSize(28)
Column() {
Text(this.getFoodName(id)).fontSize(15).fontWeight(FontWeight.Bold)
Text(this.getFoodShop(id)).fontSize(12).fontColor(C.textMuted)
}
Text('省 ¥' + this.getFoodSaved(id)).fontSize(14).fontColor(C.accent)
}
}
}
这个 Builder 不接受 FoodItem 对象,只接受 id。内部通过 Helper 方法获取每个字段的值。这种"ID 驱动"的模式保证了 Builder 中没有任何变量声明。
8.3 与之前 Helper 模式的对比
| App 30 方言学习 | App 32 本 App | |
|---|---|---|
| Helper 方法数 | 16 个 | 15 个 |
| Builder 方法数 | 8 个 | 5 个 |
| 数据访问 Helper | 10 个 | 8 个 |
| 最大调用链 | getDailyDialect() → getDailyIndex() → Date.now() |
getFoodName() → findFood() → FOODS |
本 App 的 Helper 方法调用链更短(最多 2 层),因为数据全部从常量 FOODS 读取,不需要经过日期计算等复杂逻辑。
9. 编译错误全记录
9.1 错误概览
本 App 共出现 6 个编译错误。
| # | 错误代码 | 位置 | 原因 | 修复 |
|---|---|---|---|---|
| 1 | 10505001 | L121 | 引号不匹配 .width('100%) |
改为 '100%' |
| 2-4 | 10505001 | L122-124 | 连锁错误(引号导致的) | 修复引号后消失 |
| 5 | 10905209 | L294 | const item = this.findFood(id) |
提取 Builder + Helper 方法 |
| 6 | Rollup Error | L108 | 同上 | 同上 |
9.2 引号不匹配
// ❌ 错误:引号不匹配
}.width('100%) // 开始是 ',结束是 )
// ✅ 正确
}.width('100%') // 开始和结束都是 '
这个错误不是 ArkTS 特有的语法问题,而是打字时的笔误。但在 ArkTS 中,引号不匹配会导致后续多行全部报错(连锁反应),因为编译器无法确定字符串在哪里结束。
修复引号后,L122-L124 的三个连锁错误自动消失。
9.3 Helper + Builder 组合修复
// ❌ 错误:@Builder 的 ForEach 回调中用 const
ForEach(this.rescuedItems, (id: number) => {
const item = this.findFood(id); // 10905209
Text(item.emoji)
})
// ✅ 修复:提取 Builder 方法 + Helper 方法
@Builder buildRescueCard(id: number) {
Text(this.getFoodEmoji(id))
}
这是 App 30(方言学习)中首次使用的 Helper 方法模式在 App 32 中的又一次成功应用。从"遇到 const 错误 → 惊慌"到"遇到 const 错误 → 提取 Builder + Helper 方法"的转变,标志着这个模式已经成为肌肉记忆。
9.4 三十二款 App 的错误趋势
App 1: 16 ← 入门
App 8: 4 ← 模式形成
App 16: 4 ← 稳定期
App 24: 48 ← AI 树洞探索
App 28: 8 ← 预览器问题
App 30: 10 ← Helper 模式
App 31: 0 ← 零错误 🏆
App 32: 6 ← 稳定期
App 31 的零错误之后,App 32 的 6 个错误似乎是一个"退步"。但实际上,这 6 个错误中的 5 个是由一个引号不匹配引起的连锁反应,真正的逻辑错误只有 1 个(const 问题)。
零错误之后为什么还会有错误:零错误不代表"永远不会犯错",而是代表"在所有已知代码模式中不会犯错"。引入新的操作(如 const item = this.findFood(id))时,如果忘记了之前总结的规则,错误仍然会出现。
10. 第三十二款 App 全景回顾
10.1 数据总览
| 指标 | 数值 |
|---|---|
| 代码行数 | 342 行 |
| 编译错误数 | 6 个(实际逻辑错误 1 个) |
| @State 变量 | 3 个 |
| @Builder 方法 | 5 个 |
| Helper 方法 | 8 个 |
| 食品数量 | 9 件 |
| 分类标签 | 6 个 |
| 月度成就 | 4 个 |
| 弹窗数 | 0 个 |
| 外部依赖 | 0 个 |
10.2 三十二款 App 的功能类型分布
| 类型 | App 编号 | 数量 |
|---|---|---|
| 工具类 | 1,2,3,7,8,10,14,17,18,20,21,22,25 | 13 |
| 教育类 | 5,6,19,27,30 | 5 |
| 社交/平台类 | 9,13,15,23,29 | 5 |
| 情感/关怀类 | 16,24,25,26 | 4 |
| 健康类 | 7,31 | 2 |
| 公益类 | 32 | 1 |
App 32(临期食品救援地图)是系列中第一款公益类 App。它不解决个人问题(如效率、学习、情感),而是解决社会问题(食物浪费)。虽然技术上与其他 App 没有本质区别,但定位上开辟了一个新的方向。
10.3 Helper 方法模式使用率
| App | Helper 方法数 | 在 @Builder 中调用次数 |
|---|---|---|
| 30 方言学习 | 16 | ~30 次 |
| 31 慢病管理 | 5 | ~10 次 |
| 32 本 App | 8 | ~15 次 |
Helper 方法模式正在从"方言学习"中的首次尝试,变为"慢病管理"中的基础工具,再变为"本 App"中的标准模式。每次使用都在优化和完善这个模式:
- App 30:创建了 Helper 方法模式,方法命名规范
getXxxField() - App 31:简化了 Helper 方法,减少了不必要的抽象
- App 32:将 Helper 方法与 Builder 方法分离,形成了"ID 驱动"的模式
11. 结语
11.1 救援的意义
“救援”(rescue)这个词在选择时经过了一番考虑。为什么不叫"购买"或"下单"?
"购买"是中性的——你花钱买一件商品。"救援"是有使命感的——你拯救了一份即将被浪费的食物。
同一个动作,不同的命名,给用户的心理感受完全不同。“购买临期食品"听起来像是"我贪便宜”,“救援临期食品"听起来像是"我在做一件好事”。语言框架(framing)决定了用户如何理解自己的行为。
这个命名选择体现了 App 的核心价值:你省了钱,地球少了一份浪费,商家减少了损失——这是一个三赢的局面。
11.2 公益类 App 的设计要点
-
展示影响,不只是数据:告诉用户"你减少了 X kg 食物浪费",而不是只显示"你救援了 X 件"。前者让用户感受到社会影响,后者只是一个数字。
-
成就系统要渐进:救援 5 件(容易)→ 救援 10 件(中等)→ 省 50 元(交叉条件)→ 救援 20 件(有挑战)。让用户在不同阶段都有目标。
-
紧迫感设计:"今天到期"的红色标签、“⚡ 即将过期"的标题、紧急食品专区——这些设计让用户觉得"现在不行动就来不及了”。
-
信息透明:原价、折扣价、到期日、距离——所有信息一目了然。用户不需要点进详情页了解"这是不是划算"。
11.3 给开发者的建议
- 引号不匹配是最容易避免也最容易犯的错误——每次写字符串常量时检查一下引号是否成对
- 一个引号错误可以引发 5 个连锁错误——看到连续多个错误时,先检查前面有没有字符串常量语法错误
- Helper 方法模式 + Builder 方法是 @Builder 中无 const 的最佳实践——从 App 30 验证到 App 32,三款 App 都证明了这一点
- 公益类 App 和工具类 App 的代码没有本质区别——区别在产品定位和叙事方式,不在技术实现
11.4 致谢
32 款 App、32 篇博客、约 320,000 字。
从白噪音到临期食品救援,从个人工具到社会公益——32 款 App 的多样性说明了 ArkUI 的应用范围:只要你想做,没有什么 App 是做不出来的。
现在,打开 DevEco Studio,去救援一份即将被浪费的食物吧——或者去拯救一个你觉得需要被解决的问题。
附录 A:核心代码速查
救援操作
doRescue(id: number): void {
if (this.isRescued(id)) return;
this.rescuedItems = [id, ...this.rescuedItems];
this.rescued = this.rescuedItems.length;
promptAction.showToast({ message: '🎉 救援成功!' });
}
分类筛选
getFilteredFoods(): FoodItem[] {
if (this.foodTag === 0) return FOODS;
const tags = ['', '面包', '乳品', '蔬菜', '水果', '即食'];
const result: FoodItem[] = [];
for (const f of FOODS) {
if (f.tag === tags[this.foodTag] && !this.isRescued(f.id)) result.push(f);
}
return result;
}
Helper 方法示例
getFoodEmoji(id: number): string { const f = this.findFood(id); return f !== undefined ? f.emoji : '❓'; }
getFoodName(id: number): string { const f = this.findFood(id); return f !== undefined ? f.name : ''; }
附录 B:色板
| 变量 | 值 | 用途 |
|---|---|---|
C.bg |
#F0F7F0 |
主背景 |
C.primary |
#4A9B5A |
主色 |
C.warm |
#E8927C |
金额 |
C.danger |
#E86A6A |
今天到期 |
C.accent |
#3D8B40 |
环保数据 |
更多推荐




所有评论(0)