在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

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

作者:duluo
SDK 版本:HarmonyOS API 24 (Next)
开发工具:DevEco Studio
语言框架:ArkTS + ArkUI
字数:约 9200 字


目录

  1. 引言
  2. 产品概念与数据模型
  3. 三 Tab 架构设计
  4. 首页统计与成就系统
  5. 食品列表与分类筛选
  6. 食品卡片组件
  7. 救援系统与状态管理
  8. Helper 方法在救援系统中的应用
  9. 编译错误全记录
  10. 第三十二款 App 全景回顾
  11. 结语

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 用于紧急程度判断,origPricedisPrice 用于计算节省金额。

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 列表。两者本质上是同一份数据的两种表达——rescuedrescuedItems.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 个(getFoodEmojigetFoodNamegetFoodShopgetFoodSaved)纯粹为了解决 @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"中的标准模式。每次使用都在优化和完善这个模式:

  1. App 30:创建了 Helper 方法模式,方法命名规范 getXxxField()
  2. App 31:简化了 Helper 方法,减少了不必要的抽象
  3. App 32:将 Helper 方法与 Builder 方法分离,形成了"ID 驱动"的模式

11. 结语

11.1 救援的意义

“救援”(rescue)这个词在选择时经过了一番考虑。为什么不叫"购买"或"下单"?

"购买"是中性的——你花钱买一件商品。"救援"是有使命感的——你拯救了一份即将被浪费的食物。

同一个动作,不同的命名,给用户的心理感受完全不同。“购买临期食品"听起来像是"我贪便宜”,“救援临期食品"听起来像是"我在做一件好事”。语言框架(framing)决定了用户如何理解自己的行为。

这个命名选择体现了 App 的核心价值:你省了钱,地球少了一份浪费,商家减少了损失——这是一个三赢的局面。

11.2 公益类 App 的设计要点

  1. 展示影响,不只是数据:告诉用户"你减少了 X kg 食物浪费",而不是只显示"你救援了 X 件"。前者让用户感受到社会影响,后者只是一个数字。

  2. 成就系统要渐进:救援 5 件(容易)→ 救援 10 件(中等)→ 省 50 元(交叉条件)→ 救援 20 件(有挑战)。让用户在不同阶段都有目标。

  3. 紧迫感设计:"今天到期"的红色标签、“⚡ 即将过期"的标题、紧急食品专区——这些设计让用户觉得"现在不行动就来不及了”。

  4. 信息透明:原价、折扣价、到期日、距离——所有信息一目了然。用户不需要点进详情页了解"这是不是划算"。

11.3 给开发者的建议

  1. 引号不匹配是最容易避免也最容易犯的错误——每次写字符串常量时检查一下引号是否成对
  2. 一个引号错误可以引发 5 个连锁错误——看到连续多个错误时,先检查前面有没有字符串常量语法错误
  3. Helper 方法模式 + Builder 方法是 @Builder 中无 const 的最佳实践——从 App 30 验证到 App 32,三款 App 都证明了这一点
  4. 公益类 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 环保数据

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐