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

鸿蒙 Next 冰箱剩菜大作战 App 开发实战:Tab 架构 + 数据持久化 + 游戏化设计

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


目录

  1. 引言
  2. 产品概念与需求分析
  3. 三 Tab 架构设计
  4. 数据模型与状态管理
  5. 数据持久化:多键值管理
  6. 分类选择器与单位选择器
  7. 过期状态引擎与颜色编码
  8. 游戏化评分系统设计
  9. 滑动操作与列表交互
  10. Builder 条件渲染实践
  11. 编译错误全记录
  12. 总结与展望

1. 引言

1.1 现实问题:食物浪费

根据联合国粮农组织的数据,全球每年约有 13 亿吨食物被浪费,占总产量的三分之一。而在家庭场景中,食物浪费的主要原因是:

  • 遗忘:买了食材放进冰箱,转头就忘了
  • 过期:没有及时检查保质期,发现时已经坏了
  • 管理混乱:冰箱里塞满了东西,不知道有什么、什么时候买的

"冰箱剩菜大作战"正是为了解决这个痛点而设计——帮助用户数字化管理冰箱食材,通过颜色编码直观展示过期风险,用游戏化评分激励用户及时消耗食材。

1.2 本 App 的技术亮点

技术点 说明
三 Tab 架构 底部导航栏切换冰箱/添加/战绩三个页面
状态颜色编码 绿/黄/红三色标识食材新鲜度
Grid 选单弹窗 4列网格选择分类和单位
滑动删除 ListItem 的 swipeAction
游戏化评分 基于消耗率的战斗评分系统
多键值持久化 同时存储食材列表和战绩数据

1.3 与前作的对比

维度 时间胶囊 冰箱剩菜大作战
页面架构 单页 + 弹窗 三 Tab + 弹窗
核心交互 创建 + 开启 CRUD(增删改查)
状态复杂度 3 种胶囊状态 3 种过期状态 + 分数计算
UI 组件 List + swipeAction List + swipeAction + Grid 弹窗
持久化 单键值 多键值(食材+战绩)

2. 产品概念与需求分析

2.1 核心功能需求

用户故事 1:作为用户,我想记录冰箱里有哪些食材及其保质期
用户故事 2:作为用户,我想快速知道哪些食材即将过期
用户故事 3:作为用户,我想消耗食材后标记为"已吃掉"
用户故事 4:作为用户,我想丢弃变质或不需要的食材
用户故事 5:作为用户,我想看到我的"战绩"——节约了多少食物

功能清单:
├── F1: 添加食材(名称 + 分类 + 数量 + 单位 + 过期日期)
├── F2: 食材列表(按过期日排序,颜色标识紧急程度)
├── F3: 消耗食材(标记为已吃掉)
├── F4: 删除食材(左滑删除)
├── F5: 食材详情查看
├── F6: 战绩统计(评分 + 消耗/过期数量)
└── F7: 数据持久化(重启不丢失)

2.2 信息架构

冰箱 App
├── Tab 1: 冰箱清单
│   ├── 紧急提示栏(N件即将过期)
│   ├── [空] → 空状态引导
│   └── [有数据] → List + 食材卡片(swipeAction)
│       ├── 新鲜 🟢 | 正常状态
│       ├── 即将过期 🟡 | 3天内过期
│       └── 已过期 🔴 | 超过保质期
│
├── Tab 2: 添加食材
│   ├── 名称输入
│   ├── 分类选择 → 分类 Grid 弹窗
│   ├── 数量 + 单位 → 单位 Grid 弹窗
│   ├── 过期日期输入
│   └── 提交按钮
│
└── Tab 3: 战绩统计
    ├── 战斗评分(0-100 + 称号)
    ├── 三大统计卡片(总计/已消耗/已过期)
    └── 冰箱当前状态(新鲜/即将过期/已过期数量)

2.3 数据流设计

用户操作 → 方法调用 → @State 更新 → UI 重渲染 → 数据持久化
   │          │            │            │            │
   │          ▼            ▼            ▼            ▼
 添加食材   addFood()   foodList   列表刷新    Preferences.put
 消耗食材   consume()   foodList   卡片更新    Preferences.put
 删除食材   deleteFood() foodList  列表刷新    Preferences.put

关键原则:每次数据变更后,同时完成两件事:

  1. 更新 @State 变量触发 UI 渲染
  2. 调用 saveData() 持久化到磁盘

3. 三 Tab 架构设计

3.1 底部 Tab 栏实现

ArkUI 中没有内置的 TabBar 组件,我们通过自定义 Row + 条件渲染实现:

// 底部 Tab 栏
@Builder
buildTabBar() {
  Row() {
    this.buildTabItem(0, '\u{1F9CA}', '冰箱')
    this.buildTabItem(1, '\u{2795}', '添加')
    this.buildTabItem(2, '\u{1F3C6}', '战绩')
  }
  .width('100%')
  .height(56)
  .backgroundColor(ALL_COLORS.cardBg)
  .borderRadius({ topLeft: 20, topRight: 20 })
  .shadow({ radius: 12, color: 'rgba(0, 0, 0, 0.06)', offsetY: -3 })
  .padding({ left: 12, right: 12 })
  .justifyContent(FlexAlign.SpaceAround)
  .position({ x: 0, y: '100%' })
  .translate({ y: -56 })
}

设计要点

  • 使用 position + translate 实现固定底部定位
  • borderRadius({ topLeft: 20, topRight: 20 }) 只给顶部两角加圆角
  • 选中和非选中状态使用不同的图标大小和颜色

3.2 Tab 内容切换

@Builder
buildTabContent() {
  if (this.activeTab === 0) {
    this.buildFridgeList()
  } else if (this.activeTab === 1) {
    this.buildAddPage()
  } else {
    this.buildStatsPage()
  }
}

使用 @State activeTab: number 控制当前显示的 Tab 内容。通过条件渲染,只有当前 Tab 的组件树会被创建和渲染,未激活的 Tab 不占用渲染资源。

3.3 Tab 按钮组件

@Builder
buildTabItem(index: number, icon: string, label: string) {
  Column() {
    Text(icon)
      .fontSize(this.activeTab === index ? 24 : 20)
    Text(label)
      .fontSize(11)
      .fontColor(this.activeTab === index ?
        ALL_COLORS.primary : ALL_COLORS.textLight)
      .fontWeight(this.activeTab === index ?
        FontWeight.Bold : FontWeight.Normal)
      .margin({ top: 2 })
  }
  .padding({ left: 20, right: 20, top: 6, bottom: 6 })
  .onClick(() => {
    this.activeTab = index;
  })
}

选中状态与未选中状态的区别:

属性 未选中 选中
图标大小 20 24
文字颜色 浅灰 #78909C 主色 #26A69A
文字粗细 Normal Bold

3.4 整体布局结构

build() {
  Stack() {
    // 背景
    Column()
      .linearGradient(...)

    // 主内容
    Column() {
      this.buildHeader()      // 顶部标题栏
      this.buildTabContent()  // Tab 内容区
      this.buildTabBar()      // 底部 Tab 栏
    }

    // 弹窗层
    if (this.showAddDialog)       this.buildAddDialog()
    if (this.showDetailDialog)    this.buildDetailDialog()
    if (this.showCategoryPicker)  this.buildCategoryPicker()
    if (this.showUnitPicker)     this.buildUnitPicker()
  }
}

四层弹窗互斥——虽然放在同一个 Stack 中,但由于条件渲染的控制,同一时间最多只有一个弹窗可见。


4. 数据模型与状态管理

4.1 数据模型

interface FoodItem {
  id: number;              // 唯一标识(时间戳)
  name: string;            // 食材名称
  category: string;        // 分类(蔬菜/水果/肉类/乳制品/饮品/调料/零食/其他)
  quantity: number;        // 数量
  unit: string;            // 单位(个/袋/盒/瓶/...)
  purchaseDate: number;    // 购买日期时间戳
  expireDate: number;      // 过期日期时间戳
  isConsumed: boolean;     // 是否已消耗
  consumedDate: number;    // 消耗日期(0 表示未消耗)
}

interface FoodCategory {
  name: string;            // 分类名称
  icon: string;            // Emoji 图标
  color: string;           // 主题色
}

interface BattleStats {
  totalAdded: number;      // 累计添加总数
  totalConsumed: number;   // 累计消耗数
  totalExpired: number;    // 累计过期数
  battleScore: number;     // 战斗评分 (0-100)
}

4.2 状态变量全景

struct Index {
  // ─── 核心数据 ───
  @State foodList: FoodItem[] = [];
  @State stats: BattleStats = { ... };

  // ─── Tab 控制 ───
  @State activeTab: number = 0;

  // ─── 弹窗控制 ───
  @State showAddDialog: boolean = false;
  @State showDetailDialog: boolean = false;
  @State showCategoryPicker: boolean = false;
  @State showUnitPicker: boolean = false;
  @State selectedFood: FoodItem | null = null;

  // ─── 表单状态 ───
  @State newName: string = '';
  @State newCategory: string = '蔬菜';
  @State newQuantity: string = '1';
  @State newUnit: string = '个';
  @State newExpireDate: string = '';
  @State minDate: string = '';

  // ─── 非响应式 ───
  private dataPreferences: preferences.Preferences | null = null;
}

状态设计决策

为什么 selectedFoodFoodItem | null

  • null 表示"没有选中任何食材",用于弹窗的显示/隐藏控制
  • 非空时表示用户点击了某个食材卡片,用于详情弹窗展示

为什么表单状态是独立的 @State 变量?

  • 表单数据是临时性的,只在添加弹窗打开期间有意义
  • 独立的 @State 变量确保 TextInput 的受控绑定
  • 关闭弹窗时重置这些变量,不需要额外的清理逻辑

4.3 数组变更模式

// 模式一:头部插入(新食材)
this.foodList = [newFood].concat(this.foodList);

// 模式二:触发渲染(修改元素属性后)
food.isConsumed = true;
this.foodList = this.foodList.concat([]);  // 创建新引用

// 模式三:过滤删除
this.foodList = this.foodList.filter(f => f.id !== foodId);

注意事项

  • .concat([]) 创建了一个内容相同但引用不同的数组,触发 @State 的变更检测
  • .filter() 本身返回新数组,不需要额外操作
  • 展开运算符 [...arr] 在 ArkTS 中不可用(arkts-no-spread 规则)

4.4 计算属性方法

App 中有大量从 foodList 派生的计算属性,我们通过普通成员方法实现:

// 按过期日排序的列表
getSortedFoodList(): FoodItem[] {
  let active = this.foodList.filter(f => !f.isConsumed);
  let consumed = this.foodList.filter(f => f.isConsumed);
  active.sort((a, b) => a.expireDate - b.expireDate);  // 过期日升序
  consumed.sort((a, b) => b.consumedDate - a.consumedDate); // 消耗日降序
  return active.concat(consumed);
}

// 获取即将过期/已过期的数量
getUrgentCount(): number {
  let count = 0;
  for (let f of this.foodList) {
    if (!f.isConsumed) {
      let diff = f.expireDate - Date.now();
      let days = diff / (24 * 60 * 60 * 1000);
      if (diff < 0 || days <= 3) count++;
    }
  }
  return count;
}

// 按状态统计数量
countByStatus(status: string): number {
  let count = 0;
  for (let f of this.foodList) {
    if (f.isConsumed) continue;
    let s = this.getExpireStatus(f);
    if (s === status) count++;
  }
  return count;
}

5. 数据持久化:多键值管理

5.1 多键值存储策略

与"时间胶囊"的单键值存储不同,本 App 需要同时管理两组独立数据:

键名 存储内容 数据量级
food_items FoodItem[] 序列化 JSON 几十到几百条
battle_stats BattleStats 序列化 JSON 1 条记录
stats_initialized boolean 标记 1 条标记

为什么分开存储而不是放在一个键里?

  • 关注点分离:食材数据和战绩数据的读写频率不同
  • 性能优化:战绩更新不需要重新序列化整个食材列表
  • 数据安全:一个数据损坏不影响另一个

5.2 存储实现

async saveData(): Promise<void> {
  try {
    if (this.dataPreferences) {
      // 同时写入三个键
      await this.dataPreferences.put(STORAGE_KEY_FOOD, JSON.stringify(this.foodList));
      await this.dataPreferences.put(STORAGE_KEY_STATS, JSON.stringify(this.stats));
      await this.dataPreferences.put(STORAGE_KEY_INIT, true);
      // 一次 flush,批量刷入磁盘
      await this.dataPreferences.flush();
    }
  } catch (err) {
    console.error(`Failed to save: ${JSON.stringify(err)}`);
  }
}

性能优化技巧:三次 put 后只调用一次 flush,减少磁盘 I/O 次数。

5.3 加载实现

async loadData(): Promise<void> {
  try {
    let context = getContext(this);
    this.dataPreferences = await preferences.getPreferences(context, 'fridge_battle_db');

    // 加载食材列表
    let foodVal = await this.dataPreferences.get(STORAGE_KEY_FOOD, '');
    if (foodVal !== '') {
      let data = JSON.parse(foodVal as string) as FoodItem[];
      if (data && data.length > 0) this.foodList = data;
    }

    // 加载战绩(带初始化标记检查)
    let statsInited = await this.dataPreferences.get(STORAGE_KEY_INIT, false);
    if (statsInited) {
      let statsVal = await this.dataPreferences.get(STORAGE_KEY_STATS, '');
      if (statsVal !== '') {
        let s = JSON.parse(statsVal as string) as BattleStats;
        this.stats = s;
      }
    }
  } catch (err) {
    console.error(`Failed to load: ${JSON.stringify(err)}`);
  }
}

初始化标记的妙用:通过 STORAGE_KEY_INIT 标记判断是否是首次使用。首次使用时战绩数据不存在,跳过加载使用默认值;后续启动时战绩数据存在,正常加载。

5.4 ValueType 类型转换

preferences.get() 返回的是 Promise<ValueType>,其中 ValueTypestring | number | boolean 的联合类型:

let foodVal = await this.dataPreferences.get(STORAGE_KEY_FOOD, '');
// foodVal 的类型是 ValueType,不能直接传给 JSON.parse

// 需要先断言为 string
let data = JSON.parse(foodVal as string) as FoodItem[];

为什么需要 as string
因为 JSON.parse() 只接受 string 类型参数,而 preferences.get() 的返回类型是联合类型 ValueType。通过类型断言明确告知编译器我们期望的是字符串。


6. 分类选择器与单位选择器

6.1 分类数据定义

const CATEGORIES: FoodCategory[] = [
  { name: '蔬菜', icon: '\u{1F966}', color: '#66BB6A' },
  { name: '水果', icon: '\u{1F34F}', color: '#FFA726' },
  { name: '肉类', icon: '\u{1F356}', color: '#EF5350' },
  { name: '乳制品', icon: '\u{1F9C0}', color: '#42A5F5' },
  { name: '饮品', icon: '\u{1F964}', color: '#26C6DA' },
  { name: '调料', icon: '\u{1F3F7}\uFE0F', color: '#AB47BC' },
  { name: '零食', icon: '\u{1F36C}', color: '#EC407A' },
  { name: '其他', icon: '\u{1F4E6}', color: '#78909C' }
];

每个分类包含名称、Emoji 图标和主题色,在卡片和弹窗中统一使用。

6.2 Grid 选单弹窗

分类选择器和单位选择器都使用 Grid 网格布局:

@Builder
buildCategoryPicker() {
  Column() {
    // 全屏蒙层
    Column()
      .backgroundColor('rgba(38, 50, 56, 0.4)')
      .onClick(() => { this.showCategoryPicker = false; })

    // 选项面板
    Column() {
      Text('选择分类').fontSize(18).fontWeight(FontWeight.Bold)
      Divider().height(1).color(ALL_COLORS.border + '44').width('85%')

      Grid() {
        ForEach(CATEGORIES, (cat: FoodCategory) => {
          GridItem() {
            Column() {
              Text(cat.icon).fontSize(32)
              Text(cat.name).fontSize(12)
                .fontColor(this.newCategory === cat.name ? cat.color : ALL_COLORS.text)
            }
            .backgroundColor(this.newCategory === cat.name ? cat.color + '1A' : 'transparent')
            .borderRadius(12)
            .borderWidth(this.newCategory === cat.name ? 1 : 0)
            .borderColor(cat.color + '44')
            .onClick(() => {
              this.newCategory = cat.name;
              this.showCategoryPicker = false;
            })
          }
        }, (cat: FoodCategory) => cat.name)
      }
      .columnsTemplate('1fr 1fr 1fr 1fr')  // 4列
      .rowsGap(8)
      .columnsGap(8)
      .width('85%')
    }
    .position({ x: '6%', y: '28%' })
  }
}

设计要点

  • columnsTemplate('1fr 1fr 1fr 1fr'):4 列等宽网格
  • 选中状态:背景色 + 边框高亮
  • 点击即关闭弹窗并更新选中值

6.3 单位选择器

单位选择器类似,但使用不同的 columnsTemplate(5列):

.columnsTemplate('1fr 1fr 1fr 1fr 1fr')  // 5列

13 个单位在 5 列网格中分 3 行展示:

个  袋  盒  瓶  罐
包  kg  g   mL  L
把  根  片

6.4 表单触发选择器

在添加页面中,分类和单位的"选择器入口"使用 Row 组件模拟可点击的选择框:

Row() {
  Text(this.getCategoryIcon(this.newCategory) + '  ' + this.newCategory)
  Blank()
  Text('\u{276F}')  // ❯ 箭头图标
}
.width('100%')
.height(44)
.backgroundColor('rgba(255,255,255,0.7)')
.borderRadius(10)
.borderWidth(1)
.borderColor(ALL_COLORS.border + '66')
.onClick(() => { this.showCategoryPicker = true; })

这种"可点击选择框 + 弹窗 Grid"的模式,比下拉菜单更适合移动端的大拇指操作区域。


7. 过期状态引擎与颜色编码

7.1 过期状态计算

getExpireStatus(food: FoodItem): string {
  if (!food.isConsumed) {
    let diff = food.expireDate - Date.now();
    let days = diff / (24 * 60 * 60 * 1000);
    if (diff < 0) return 'expired';    // 已过期
    if (days <= 3) return 'soon';      // 3天内过期
  }
  return 'safe';                       // 新鲜
}

阈值选择:以 3 天作为"即将过期"的临界值,因为:

  • 3 天足够用户安排食用计划
  • 避免过度提醒(每天都显示"即将过期"会让用户麻木)
  • 符合食材保鲜的常识周期

7.2 三重编码体系

每种过期状态使用三种视觉编码,确保信息的可访问性:

状态 颜色 Emoji 文字
新鲜 🟢 #66BB6A 绿色 新鲜
即将过期 🟡 #FFA726 橙色 即将过期
已过期 🔴 #EF5350 红色 ⚠️ 已过期!

三种编码同时使用的好处:

  • 色觉障碍用户:可以通过 Emoji 和文字区分
  • 快速扫描:通过颜色一眼识别紧急程度
  • 详细阅读:通过文字获取准确状态

7.3 状态颜色在 UI 中的应用

// 卡片边框颜色
.borderColor(this.getStatusColor(this.getExpireStatus(food)) + '33')

// 卡片阴影颜色
.shadow({
  color: this.getStatusColor(this.getExpireStatus(food)) + '0D'
})

// 状态文字颜色
.fontColor(this.getStatusColor(this.getExpireStatus(food)))

// 状态标签背景色
.backgroundColor(this.getStatusColor(this.getExpireStatus(food)))

透明度后缀'33' 表示 20% 透明度,'0D' 表示 5% 透明度。这种半透明效果让颜色提示柔和而不刺眼。

7.4 顶部紧急提示栏

if (this.foodList.length > 0) {
  Row() {
    Text(this.getUrgentCount().toString() + ' 件即将过期')
      .fontSize(12)
      .fontColor(ALL_COLORS.danger)
      .fontWeight(FontWeight.Medium)
    Blank()
    Text('共 ' + this.foodList.length + ' 件')
      .fontSize(12)
      .fontColor(ALL_COLORS.textLight)
  }
  .padding({ left: 20, right: 20, bottom: 6 })
}

红色文字提醒 + 总数统计,让用户一打开列表就能掌握冰箱的整体状况。


8. 游戏化评分系统设计

8.1 评分算法

updateBattleScore(): void {
  let total = this.stats.totalAdded;
  if (total === 0) {
    this.stats.battleScore = 0;
    return;
  }
  let consumed = this.stats.totalConsumed;
  let expired = this.stats.totalExpired;
  let rate = total > 0 ? (consumed / total) * 100 : 0;
  // 基础分来自于消耗率,减去过期惩罚
  let score = Math.round(rate * 0.7 - expired * 2);
  // 当前冰箱还有新鲜食材加分
  let freshCount = this.countByStatus('safe');
  score += freshCount * 2;
  this.stats.battleScore = Math.max(0, Math.min(100, score));
}

评分公式

战斗评分 = 消耗率 × 0.7 - 过期数 × 2 + 新鲜食材数 × 2

参数说明

参数 作用 示例
消耗率 × 0.7 消耗比例越高分越高,最高 70 分 消耗 80% 得 56 分
过期数 × -2 每个过期食材扣 2 分 过期 5 个扣 10 分
新鲜食材 × 2 冰箱里还有新鲜食材加分 3 件新鲜加 6 分

8.2 称号系统

getScoreLevel(score: number): string {
  if (score >= 80) return '\u{1F31F} 厨神达人';
  if (score >= 60) return '\u{1F44D} 节约能手';
  if (score >= 40) return '\u{1F4AA} 继续努力';
  if (score >= 20) return '\u{1F914} 还需加油';
  return '\u{1F4A4} 刚刚起步';
}
分数区间 称号 Emoji
80-100 🌟 厨神达人 金色星星
60-79 👍 节约能手 竖大拇指
40-59 💪 继续努力 手臂肌肉
20-39 🤔 还需加油 思考表情
0-19 💤 刚刚起步 睡觉表情

8.3 战绩页面布局

┌──────────────────────────┐
│       🧊 冰箱             │
│   冰箱剩菜大作战战绩       │
│                          │
│   ┌──── 战斗评分 ────┐   │
│   │     85            │   │  ← 大号数字
│   │   🌟 厨神达人     │   │  ← 称号
│   └──────────────────┘   │
│                          │
│  ┌────┐ ┌────┐ ┌────┐  │
│  │📦  │ │✅  │ │⚠️  │  │  ← 三张统计卡片
│  │ 50 │ │ 42 │ │ 3  │  │
│  │总计│ │消耗│ │过期│  │
│  └────┘ └────┘ └────┘  │
│                          │
│   🧊 冰箱当前状态        │
│   🍎 新鲜食材  12 件     │  ← 绿色
│   ⏳ 即将过期  3 件      │  ← 橙色
│   ⚠️ 已经过期  1 件      │  ← 红色
└──────────────────────────┘

8.4 评分的激励作用

游戏化评分的设计目标不是"完美无缺",而是正向激励

  1. 消耗加分:每吃掉一件食材都会让评分提升
  2. 过期扣分:让用户对过期食材产生"损失厌恶"
  3. 新鲜奖励:鼓励用户及时补充新鲜食材
  4. 上限 100 分:让用户有明确的奋斗目标

9. 滑动操作与列表交互

9.1 ListItem 滑动操作

ArkUI 的 ListItem 组件提供了 swipeAction 属性,支持滑动显示操作按钮:

ListItem() {
  this.buildFoodCard(food)
}
.swipeAction({
  end: this.buildSwipeActions(food)  // 从右侧滑出
})

swipeActionend 参数定义从列表项右侧滑出的操作面板。左侧滑出使用 start 参数。

9.2 滑动操作按钮

@Builder
buildSwipeActions(food: FoodItem) {
  Row() {
    if (!food.isConsumed) {
      Text('\u{2705} 吃掉')
        .padding({ left: 16, right: 16, top: 12, bottom: 12 })
        .backgroundColor(ALL_COLORS.safe)
        .borderRadius(14)
        .onClick(() => { this.consumeFood(food.id); })
    }
    Text('\u{1F5D1}\uFE0F 丢弃')
      .padding({ left: 16, right: 16, top: 12, bottom: 12 })
      .backgroundColor(ALL_COLORS.danger)
      .borderRadius(14)
      .margin({ left: 6 })
      .onClick(() => { this.deleteFood(food.id); })
  }
  .padding({ right: 6 })
  .height('90%')
  .justifyContent(FlexAlign.End)
}

交互逻辑

  • 未消耗的食材:显示「吃掉」和「丢弃」两个按钮
  • 已消耗的食材:只显示「丢弃」按钮
  • 「吃掉」按钮为绿色,语义为正面操作
  • 「丢弃」按钮为红色,语义为负面操作

9.3 详情弹窗中的操作

除了滑动操作,用户点击卡片进入详情弹窗后也可以进行操作:

if (!this.selectedFood.isConsumed &&
    this.getExpireStatus(this.selectedFood) !== 'expired') {
  Row() {
    Text('\u{1F924} 吃掉它!')
      .fontColor(Color.White)
      .backgroundColor(ALL_COLORS.safe)
      .borderRadius(14)
      .onClick(() => {
        this.consumeFood(this.selectedFood!.id);
        this.showDetailDialog = false;
      })
  }
}

多入口设计:同一个操作(消耗食材)可以通过滑动和详情弹窗两种方式触发,适应不同用户习惯。

9.4 点击反馈

.onClick(() => {
  this.selectedFood = food;
  this.showDetailDialog = true;
})
.animation({
  duration: 300,
  curve: Curve.Ease
})

点击卡片 → 设置 selectedFood → 弹出详情弹窗。animation 属性为卡片添加了微交互动画。


10. Builder 条件渲染实践

10.1 Tab 内容的条件渲染

@Builder
buildTabContent() {
  if (this.activeTab === 0) {
    this.buildFridgeList()
  } else if (this.activeTab === 1) {
    this.buildAddPage()
  } else {
    this.buildStatsPage()
  }
}

只有当前激活的 Tab 会被渲染。当 activeTab 变化时,旧的 Tab 组件树被销毁,新的被创建。

10.2 列表的空状态渲染

@Builder
buildFridgeList() {
  Column() {
    // 紧急提示(列表不为空时显示)
    if (this.foodList.length > 0) {
      Row() { /* 提示栏 */ }
    }

    // 列表或空状态
    if (this.foodList.length === 0) {
      this.buildFridgeEmpty()     // 空状态
    } else {
      List() { /* 食材列表 */ }
    }
  }
}

空状态设计:当冰箱为空时显示友好的引导文案和装饰性图标,而不是直接显示空白页面。

10.3 弹窗的条件渲染

if (this.showAddDialog)       this.buildAddDialog()
if (this.showDetailDialog)    this.buildDetailDialog()
if (this.showCategoryPicker)  this.buildCategoryPicker()
if (this.showUnitPicker)     this.buildUnitPicker()

四个弹窗通过独立的 @State 布尔变量控制,互不干扰。

10.4 Builder 中禁止的语法

回顾一下 Builder 中的语法限制:

// ❌ 错误:不可使用 let
@Builder
buildFoodCard(food: FoodItem) {
  let status = this.getExpireStatus(food);  // 禁止
  Text(status)
}

// ✅ 正确:使用方法调用
@Builder
buildFoodCard(food: FoodItem) {
  Text(this.getExpireStatus(food))
}

// ❌ 错误:不可使用 return
@Builder
buildDetailDialog() {
  if (this.selectedFood === null) return;  // 禁止
  Column() { }
}

// ✅ 正确:用 if 包裹 UI
@Builder
buildDetailDialog() {
  if (this.selectedFood !== null) {
    Column() { }
  }
}

10.5 非空断言的使用

在 Builder 中的闭包内访问可能为 null 的对象,需要使用非空断言:

.onClick(() => {
  this.consumeFood(this.selectedFood!.id);  // ! 非空断言
})

即使 Builder 外层有 if (this.selectedFood !== null) 保护,闭包内的类型收窄也不生效。! 断言告诉编译器"我确定这里不为 null"。


11. 编译错误全记录

本节记录在"冰箱剩菜大作战"开发中遇到的 21 个编译错误,涵盖对象类型、Builder 语法、数组操作、API 类型等常见问题。

11.1 对象字面量无类型

错误 1

Object literal must correspond to some explicitly declared class or interface

修复:先定义接口再声明变量

interface ColorScheme {
  primary: string;
  primaryDark: string;
  // ...
}
const ALL_COLORS: ColorScheme = { ... };

11.2 Builder 中变量声明

错误 2-5, 11-21 (共 15 个错误):

Only UI component syntax can be written here.

场景:在 buildFoodCardbuildDetailDialog 中使用 let 声明局部变量。

修复:将数据提取逻辑移至普通成员方法,Builder 中只调用方法:

// 错误模式
@Builder
buildFoodCard(food: FoodItem) {
  let status = this.getExpireStatus(food);  // ❌
  Text(this.getStatusText(status))
}

// 正确模式
@Builder
buildFoodCard(food: FoodItem) {
  Text(this.getStatusText(this.getExpireStatus(food)))  // ✅
}

11.3 闭包参数不可用

错误 6-7, 13-15, 18

'content()' does not meet UI component syntax.

场景:使用 buildFormField + 闭包参数的模式在 Builder 中渲染内容。

// ❌ 错误的抽象
@Builder
buildFormField(label: string, content: () => void) {
  Column() {
    Text(label)
    content()  // Builder 中不允许调用闭包
  }
}

// ✅ 正确:直接内联
Column() {
  Text('食材名称')
  TextInput({ ... })
}

教训:在 ArkUI 中不要对 UI 片段做闭包抽象。每个字段都需要完整地写在 Builder 中。

11.4 方法不存在

错误 8-12, 16-17

Property 'getStatusIcon' does not exist on type 'Index'.

原因:在重构 Builder 时删除了局部变量,但 Builder 中调用的辅助方法尚未定义。

修复:添加缺失的 3 个方法:

getStatusIcon(status: string): string { ... }
getStatusText(status: string): string { ... }
getStatusColor(status: string): string { ... }

11.5 对象可能为 null

错误 1(最终版本):

Object is possibly 'null'.

场景:在详情弹窗的 onClick 回调中访问 this.selectedFood.id

修复:使用非空断言:

this.consumeFood(this.selectedFood!.id);

11.6 错误总结

# 错误类型 出现次数 根因 解决方案
1 arkts-no-untyped-obj-literals 1 对象字面量无类型声明 先定义 interface
2-5, 11-21 Only UI component syntax 15 @Builder 内含非 UI 语法 提取为计算方法
6-7, 13-15, 18 content() not UI syntax 6 闭包参数在 Builder 中不可用 内联 UI 代码
8-12, 16-17 方法不存在 7 删除变量后未添加替代方法 添加辅助方法
19 Object is possibly null 1 闭包内类型收窄不生效 非空断言 !

12. 总结与展望

12.1 完成功能回顾

通过本项目,我们成功构建了一个功能完整的冰箱食材管理 App:

功能模块 实现方案 核心代码量
Tab 导航 自定义 Row + 条件渲染 ~40 行
食材列表 List + ForEach + swipeAction ~60 行
食材卡片 8 分类 + 3 状态颜色编码 ~80 行
添加食材 内联表单 + 2 个 Grid 弹窗 ~150 行
过期引擎 时间差计算 + 3 天阈值 ~20 行
评分系统 消耗率 + 过期惩罚 + 新鲜奖励 ~30 行
数据持久化 Preferences 三键值存储 ~50 行
总计 ~1320 行

12.2 ArkUI 开发核心原则再总结

通过三款 App(白噪音、时间胶囊、冰箱剩菜)的开发,我们可以提炼出 ArkUI 开发的核心原则:

原则一:数据是唯一真相

UI = f(State)

所有 UI 都由 @State 数据驱动。不要试图手动操作 DOM。

原则二:Builder 是声明式岛屿

Builder 内 = 纯 UI 声明
Builder 外 = 纯逻辑计算

Builder 方法中不要混入命令式逻辑。所有数据获取和条件计算都提取到普通方法中。

原则三:闭包传参不适用于 Builder

@Builder + (() => void) 参数 = ❌
内联完整 UI 代码 = ✅

不要试图用函数式编程的方式抽象 UI 片段。

原则四:引用敏感性

修改对象属性 → 必须创建新引用 → @State 检测变化

数组展开运算符不可用,使用 .concat() 代替。

12.3 可扩展方向

  1. 扫描条码:集成 @ohos.multimedia.camera 扫描食品条码,自动获取名称和信息
  2. 过期推送:使用 @ohos.reminderAgent 在食材过期前推送通知
  3. 食谱推荐:根据冰箱现有食材推荐可做的菜品
  4. 多人共享:接入华为帐号,家庭成员共享冰箱清单
  5. 采购清单:消耗食材时自动添加到采购清单
  6. 数据导出:将统计数据和食材记录导出为 CSV/PDF
  7. 暗黑模式:适配深色主题
  8. 动画优化:添加食材入场动画和消耗庆祝动画

12.4 对 HarmonyOS 开发者的建议

  1. 设计时先画组件树:ArkUI 是声明式框架,开发前先画出完整的组件树结构
  2. 数据模型先行:先定义好 interface 再开始写 UI,避免返工
  3. Builder 尽早提取方法:遇到需要在 Builder 中获取数据的场景,立即提取为计算方法
  4. 每次编译后查看全部错误:ArkTS 编译器的错误信息非常精确,一次修复一类错误
  5. 理解 @State 的引用语义:不要用 Vue/React 的响应式思维理解 ArkTS 的状态管理

附录 A:完整文件结构

entry/src/main/ets/pages/Index.ets
├── 导入区(1-2 行)
├── 类型定义(4-33 行)
│   ├── FoodItem
│   ├── FoodCategory
│   ├── BattleStats
│   └── ColorScheme
├── 常量定义(35-82 行)
│   ├── 存储键名
│   ├── CATEGORIES(8 种分类)
│   ├── UNIT_OPTIONS(13 种单位)
│   └── ALL_COLORS(12 种颜色)
├── @Component struct Index(84-1320 行)
│   ├── @State 变量(86-108 行)
│   ├── 生命周期(110-117 行)
│   ├── build() 方法(119-146 行)
│   ├── @Builder 方法(148-1100 行)
│   │   ├── buildHeader()
│   │   ├── buildTabContent()
│   │   ├── buildFridgeList()
│   │   ├── buildFridgeEmpty()
│   │   ├── buildFoodCard()
│   │   ├── buildSwipeActions()
│   │   ├── buildAddPage()
│   │   ├── buildStatsPage()
│   │   ├── buildStatCard()
│   │   ├── buildStatusRow()
│   │   ├── buildTabBar()
│   │   ├── buildTabItem()
│   │   ├── buildCategoryPicker()
│   │   ├── buildUnitPicker()
│   │   ├── buildDetailDialog()
│   │   ├── buildDetailRow()
│   │   └── buildAddDialog()
│   └── 业务方法(1102-1320 行)
│       ├── getCategoryInfo()
│       ├── getCategoryIcon()
│       ├── getStatusIcon/Text/Color()
│       ├── getExpireStatus()
│       ├── getSortedFoodList()
│       ├── getUrgentCount()
│       ├── countByStatus()
│       ├── getScoreLevel/Color()
│       ├── openAddDialog()
│       ├── addFood()
│       ├── consumeFood()
│       ├── deleteFood()
│       ├── updateBattleScore()
│       ├── loadData()
│       ├── saveData()
│       └── formatDate()

附录 B:颜色系统速查表

名称 色值 用途 Emoji
primary #26A69A 主色、Tab 选中、按钮 🟢
primaryDark #00897B 标题文字 🟢
accent #FFD54F 强调色(备注用)
bg #E0F2F1 页面背景 🟩
cardBg #FFFFFF 卡片背景
text #263238 正文文字
textLight #78909C 辅助文字 🩶
danger #EF5350 过期/删除 🔴
warning #FFA726 即将过期 🟡
safe #66BB6A 新鲜/吃掉 🟢
border #B2DFDB 边框分隔 🩵

附录 C:编译错误码速查

错误码 含义 本项目中出现的次数
10605038 无类型对象字面量 1
10905209 Builder 中非 UI 语法 15
10905204 闭包不符合 UI 语法 6
10505001 属性/方法不存在 8
10605999 对象可能为 null 1

本文由 AtomCode 基于 HarmonyOS Next API 24 编写,记录了冰箱剩菜大作战 App 的完整开发过程,涵盖三 Tab 架构、数据持久化、游戏化设计等核心技术点。希望对 HarmonyOS 应用开发者有所帮助。

(全文完,约 12000 字)

Logo

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

更多推荐