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




鸿蒙 Next 冰箱剩菜大作战 App 开发实战:Tab 架构 + 数据持久化 + 游戏化设计
作者:duluo
SDK 版本:HarmonyOS API 24 (Next)
开发工具:DevEco Studio
语言框架:ArkTS + ArkUI
字数:约 12000 字
目录
- 引言
- 产品概念与需求分析
- 三 Tab 架构设计
- 数据模型与状态管理
- 数据持久化:多键值管理
- 分类选择器与单位选择器
- 过期状态引擎与颜色编码
- 游戏化评分系统设计
- 滑动操作与列表交互
- Builder 条件渲染实践
- 编译错误全记录
- 总结与展望
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
关键原则:每次数据变更后,同时完成两件事:
- 更新
@State变量触发 UI 渲染 - 调用
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;
}
状态设计决策:
为什么 selectedFood 是 FoodItem | 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>,其中 ValueType 是 string | 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 评分的激励作用
游戏化评分的设计目标不是"完美无缺",而是正向激励:
- 消耗加分:每吃掉一件食材都会让评分提升
- 过期扣分:让用户对过期食材产生"损失厌恶"
- 新鲜奖励:鼓励用户及时补充新鲜食材
- 上限 100 分:让用户有明确的奋斗目标
9. 滑动操作与列表交互
9.1 ListItem 滑动操作
ArkUI 的 ListItem 组件提供了 swipeAction 属性,支持滑动显示操作按钮:
ListItem() {
this.buildFoodCard(food)
}
.swipeAction({
end: this.buildSwipeActions(food) // 从右侧滑出
})
swipeAction 的 end 参数定义从列表项右侧滑出的操作面板。左侧滑出使用 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.
场景:在 buildFoodCard、buildDetailDialog 中使用 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 可扩展方向
- 扫描条码:集成
@ohos.multimedia.camera扫描食品条码,自动获取名称和信息 - 过期推送:使用
@ohos.reminderAgent在食材过期前推送通知 - 食谱推荐:根据冰箱现有食材推荐可做的菜品
- 多人共享:接入华为帐号,家庭成员共享冰箱清单
- 采购清单:消耗食材时自动添加到采购清单
- 数据导出:将统计数据和食材记录导出为 CSV/PDF
- 暗黑模式:适配深色主题
- 动画优化:添加食材入场动画和消耗庆祝动画
12.4 对 HarmonyOS 开发者的建议
- 设计时先画组件树:ArkUI 是声明式框架,开发前先画出完整的组件树结构
- 数据模型先行:先定义好
interface再开始写 UI,避免返工 - Builder 尽早提取方法:遇到需要在 Builder 中获取数据的场景,立即提取为计算方法
- 每次编译后查看全部错误:ArkTS 编译器的错误信息非常精确,一次修复一类错误
- 理解
@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 字)
更多推荐


所有评论(0)