HarmonyOS 临时食品记录单开发实战 —— ArkTS 笔记录入与数据检索




目录
- 项目背景与产品定位
- 需求分析与功能设计
- 技术选型与架构设计
- 数据模型设计
- JSON 模拟数据构建
- UI 架构与组件拆解
- 核心功能一:三标签导航系统
- 核心功能二:表单录入与验证
- 核心功能三:列表展示与交互
- 核心功能四:搜索与分类筛选
- 核心功能五:食品保质期预警
- 核心功能六:分类统计概况
- ArkTS 关键语法要点
- 常见问题与调试指南
- 项目总结与扩展方向
1. 项目背景与产品定位
1.1 为什么做这个应用
在日常生活中,我们经常遇到以下场景:
- 冰箱里有什么忘了:买了菜放冰箱,过几天忘了,又重复购买
- 食品过期了不知道:有些食品保质期短,放久了忘记吃,造成浪费
- 采购没有计划:不知道家里还剩什么,采购时凭感觉买
「临时食品记录单」正是为解决这些痛点而生——一个轻量级、无后端、纯本地的食品库存管理工具。不需要注册账号、不需要联网、打开即用。
1.2 与同类产品的差异化
| 对比项 | 本应用 | 通用笔记 App | 专业库存管理 |
|---|---|---|---|
| 启动速度 | 瞬时启动 | 需加载 | 需登录/加载 |
| 操作流程 | 3 步录入 | 多步操作 | 复杂流程 |
| 数据存储 | 内存(临时) | 云端同步 | 数据库 |
| 食品专项功能 | 保质期预警 ✓ | 无 | ✓ |
| 适合场景 | 日常快速记 | 通用记录 | 商家/仓库 |
1.3 目标用户
- 家庭主妇/主夫:记录冰箱库存,避免食物浪费
- 租房一族:管理少量食品,采购有数
- 食品爱好者:记录尝试过的食品和评价
- HarmonyOS 开发者:学习 ArkTS 表单、列表、搜索、统计综合实战
2. 需求分析与功能设计
2.1 核心需求
| 优先级 | 需求 | 实现方案 |
|---|---|---|
| P0 | 添加食品记录 | 表单录入,含名称/分类/数量/日期/保质期/备注 |
| P0 | 查看食品列表 | 卡片式列表,显示关键信息 |
| P0 | 删除食品记录 | 点击删除,二次确认防误删 |
| P1 | 搜索食品 | 按名称关键词实时过滤 |
| P1 | 分类筛选 | 按 10 大分类标签筛选 |
| P1 | 保质期预警 | 显示剩余天数,3 天内标红提醒 |
| P1 | 统计概况 | 总数量、分类统计、过期提醒列表 |
| P2 | 清空全部 | 一键清空所有记录(带确认) |
| P2 | 深色主题 | 沉浸式深色 UI |
2.2 功能模块图
┌─────────────────────────────────────┐
│ 临时食品记录单 │
├─────────────────────────────────────┤
│ [📋 记录] [➕ 录入] [📊 概况] │
├──────────┬──────────┬───────────────┤
│ 记录页 │ 录入页 │ 概况页 │
│ ├ 搜索框 │ ├ 名称输入 │ ├ 总览卡片 │
│ ├ 分类筛选 │ ├ 分类选择 │ ├ 分类统计 │
│ ├ 记录卡片 │ ├ 数量调节 │ └ 过期提醒 │
│ │ ├ 基本信息│ ├ 单位选择 │ │
│ │ ├ 日期信息│ ├ 日期输入 │ │
│ │ ├ 备注 │ ├ 保质期 │ │
│ │ ├ 进度条 │ ├ 备注输入 │ │
│ │ └ 删除 │ └ 保存按钮 │ │
│ └ 空状态 │ │ │
└──────────┴──────────┴───────────────┘
2.3 交互流程
用户打开 App
│
▼
[记录列表] ←── 默认展示 10 条模拟数据
│
├── 点击 [➕ 录入]
│ │
│ ▼
│ [录入表单]
│ │
│ ├── 填写信息 → 点击 [保存记录]
│ │ │
│ │ ▼
│ │ 自动切回 [记录列表],新记录置顶
│ │
│ └── 点击 [清空所有记录] → 全部删除
│
├── 点击记录卡片 [🗑️] 按钮
│ │
│ ▼
│ 显示确认/取消 → 确认后删除该条
│
├── 输入搜索文字 → 实时过滤列表
│
├── 点击分类标签 → 按分类筛选
│
└── 点击 [📊 概况]
│
▼
[统计页]
├── 总数卡片
├── 各分类数量
└── 即将过期食品列表(3天内)
3. 技术选型与架构设计
3.1 开发环境
| 项目 | 版本 |
|---|---|
| 操作系统 | Windows 11 |
| 开发工具 | DevEco Studio NEXT |
| 框架 | HarmonyOS ArkTS |
| API 版本 | 24(HarmonyOS NEXT) |
| 构建设置 | hvigor / API 24 |
| 目标设备 | Phone(竖屏) |
3.2 项目结构
entry/src/main/ets/pages/
└── Index.ets ← 单文件架构,812 行全部代码
docs/
└── 临时食品记录单开发实战.md ← 本文
3.3 单文件架构的考量
延续五笔拆字工具的设计哲学,本应用同样采用单文件架构。812 行完整代码涵盖了 3 个页面模块、10 个 @Builder 组件、6 个工具函数,全部集中在一个文件中。
单文件的优势:
- 零模块化开销——没有 import/export,编译器解析快
- 完整可读——任何开发者打开一个文件就能看懂全部逻辑
- 便于调试——状态定义和 UI 渲染在同一范围,不存在跨文件数据流迷茫
- 快速部署——一个文件编译,发布包体积最小
4. 数据模型设计
4.1 核心数据接口
interface FoodRecord {
id: number // 主键编号
name: string // 食品名称(必填)
category: string // 分类(蔬菜/水果/肉类/...)
quantity: number // 数量(正整数)
unit: string // 单位(个/克/毫升/袋/瓶/盒/...)
date: string // 购入日期(YYYY-MM-DD)
notes: string // 备注(可选)
expireDays: number // 保质期天数(0=当天食用)
}
4.2 辅助数据类型
interface CategoryInfo {
name: string // 分类名称
icon: string // Emoji 图标
color: string // 十六进制颜色代码
}
4.3 数据设计的关键考量
为什么用字符串存日期而不是 Date 对象?
API 24 对 Date 对象的支持有限,Date 的构造函数、getTime(), toLocaleDateString() 等常用方法在内核裁剪后不可用。因此我们选择用固定格式的字符串 YYYY-MM-DD 存储日期,配合自定义的 dateDiff() 和 formatDate() 函数处理日期运算和格式化。
为什么分类信息独立成 CategoryInfo 接口?
将分类元数据(名称、图标、颜色)与食品记录分离,实现"数据与展示解耦"。这样:
- 新增分类只需在
CATEGORIES数组中加一条 - 修改某个分类的图标或颜色,所有引用处自动更新
- 统计模块可以遍历
CATEGORIES而不是遍历records,保持统计顺序稳定
为什么用 id: number 做主键?
在无数据库的纯内存场景中,自增数字 ID 是最简单的唯一标识方案。@State nextId 维护下一个可用 ID,每次新增时自增,确保删除后新增不会出现 ID 冲突。
5. JSON 模拟数据构建
5.1 初始数据设计
我们精心构建了 10 条初始记录,覆盖以下维度:
| 维度 | 覆盖情况 |
|---|---|
| 分类覆盖 | 蔬菜×2、乳制品×2、肉类×1、水果×1、主食×1、调味品×1、零食×1、饮料×1 |
| 保质期覆盖 | 极短保(3天内)、短保(7天内)、中等(30天内)、长保(30天以上)、超长保(365天) |
| 数量范围 | 1 件 ~ 12 件 |
| 单位覆盖 | 个、克、盒、袋、瓶、包、根 |
| 日期分布 | 3月10日 ~ 3月22日,模拟两周内的采购记录 |
5.2 日期设计策略
为了演示保质期预警功能,我们故意让某些记录接近过期:
| 食品 | 购入日期 | 保质期 | 剩余天数 | 状态 |
|---|---|---|---|---|
| 大葱 | 3月17日 | 4天 | 1天 | 🔴 即将过期 |
| 西红柿 | 3月20日 | 5天 | 3天 | 🔴 即将过期 |
| 纯牛奶 | 3月20日 | 7天 | 5天 | 🟠 注意 |
| 鸡蛋 | 3月19日 | 14天 | 11天 | 🟢 正常 |
5.3 数据不重复原则
模拟数据中,所有 name 字段都互不重复。这在搜索功能演示时效果更好——每个搜索结果唯一明确。
6. UI 架构与组件拆解
6.1 整体布局
应用采用纵向三区布局,核心是 Column 容器包裹三个区域:
Column (100% × 100%, backgroundColor: #0A1628)
├── 标题区 (Title)
│ └── Text × 2 (主标题 + 副标题)
├── 导航标签区 (Tabs)
│ └── TabBtn × 3 (Row 内平铺)
└── 内容区 (Content)
└── Scroll(Vertical)
└── Column
├── (activeTab === 'list') → ListSection
├── (activeTab === 'add') → AddFormSection
└── (activeTab === 'stats') → StatsSection
6.2 @Builder 组件树
整个应用共定义了 11 个 @Builder 函数:
| @Builder | 作用 | 参数 |
|---|---|---|
TabBtn |
导航标签按钮 | label, section |
ListSection |
记录列表页容器 | — |
FilterChip |
分类筛选标签 | label, catName |
EmptyState |
空状态占位 | — |
RecordCard |
单条记录卡片 | record: FoodRecord |
AddFormSection |
录入表单页容器 | — |
FormField |
表单字段标签 | label, hint |
StatsSection |
统计概况页容器 | — |
StatsCard |
统计信息卡片 | icon, value, color |
SearchSection |
(已内联到 ListSection) | — |
6.3 核心设计原则
条件渲染而非路由跳转:三个页面通过 if (this.activeTab === 'list') 条件渲染实现切换,而非使用页面路由。这种做法的好处是:
- 状态保持:切换回列表时,搜索文本、筛选条件仍然保留
- 零路由开销:不需要配置
router.push和页面栈管理 - 动画连贯:Scroll 的滚动位置保持(但 API 24 中 Scroll 不会记住位置,这是已知限制)
7. 核心功能一:三标签导航系统
7.1 标签状态管理
@State activeTab: string = 'list'
activeTab 值的改变驱动整个内容区的重新渲染。三个标签对应三个值:
| 标签 | activeTab 值 | 渲染内容 |
|---|---|---|
| 📋 记录 | 'list' |
ListSection |
| ➕ 录入 | 'add' |
AddFormSection |
| 📊 概况 | 'stats' |
StatsSection |
7.2 TabBtn 组件的视觉反馈
@Builder
TabBtn(label: string, section: string) {
Text(label)
.fontSize(14)
.fontColor(this.activeTab === section ? '#FFFFFF' : '#8899AA')
.padding({ left: 16, right: 16, top: 6, bottom: 6 })
.backgroundColor(this.activeTab === section ? '#3498DB' : '#1A2A44')
.borderRadius(16)
.onClick(() => {
this.activeTab = section
})
}
关键交互细节:
- 激活态:白色文字 + 蓝色背景 (
#3498DB) - 非激活态:灰色文字 + 暗色背景 (
#1A2A44) - 圆角药丸形状:
borderRadius(16)让标签看起来柔和 - 点击切换:直接赋值
this.activeTab,ArkTS 自动触发 UI 更新
7.3 切换时的状态重置
从其他标签切回列表时,重置删除确认状态:
.onClick(() => {
this.activeTab = section
if (section === 'list') {
this.deleteConfirmId = -1 // 重置删除确认
}
})
这是一个容易被忽略的细节——如果用户在录入页删除了"确认删除"状态,切回列表时可能还显示着确认按钮。在 onClick 中统一重置,确保 UI 状态一致。
8. 核心功能二:表单录入与验证
8.1 表单字段的状态定义
@State newName: string = ''
@State newCategory: string = '蔬菜'
@State newQuantity: number = 1
@State newUnit: string = '个'
@State newDate: string = '2025-03-22'
@State newNotes: string = ''
@State newExpireDays: number = 7
7 个 @State 变量对应表单的 7 个字段。为什么不用一个对象?因为 ArkTS 的 @State 监听的是引用变化——修改对象字段不会触发 UI 更新,必须重新赋值整个对象。拆分成独立变量可以享受字段级别的响应式更新。
8.2 分类选择器设计
分类选择使用横向滚动标签的交互模式:
Scroll() {
Row({ space: 6 }) {
ForEach(CATEGORIES, (cat: CategoryInfo) => {
Text(cat.icon + ' ' + cat.name)
.fontSize(13)
.fontColor(this.newCategory === cat.name ? '#FFFFFF' : '#AABBCC')
.backgroundColor(this.newCategory === cat.name ? cat.color : '#111D33')
.borderRadius(12)
.onClick(() => {
this.newCategory = cat.name
})
}, (cat: CategoryInfo) => cat.name)
}
}
.scrollable(ScrollDirection.Horizontal)
设计考量:
- 颜色反馈:选中时使用分类本身的颜色作为背景,视觉直观
- 水平滚动:10 个分类一行排不下,用 Scroll 提供横向滑动
- Emoji 图标:🥬🍎🥩🦐🥛🍪🧃🧂🍚📦 每个分类有对应的 Emoji,无需图片资源
8.3 数量调节器设计
数量输入提供三种操作方式:
[−] [__3__] [+] [个|克|毫升|袋|瓶|盒...]
- 减号按钮 (−):每次减 1,最小为 1
- 数字输入框:可直接键盘输入数字,自动校验正整数
- 加号按钮 (+):每次加 1
这种"按钮 + 输入"的组合兼顾了快捷操作(点击+/-)和精确输入(键盘输入)两种场景。
8.4 表单验证
canSubmit(): boolean {
return this.newName.length > 0 && this.newQuantity > 0 && this.newDate.length > 0
}
只有三个必填项:名称不能为空、数量大于 0、日期不能为空。分类/单位/保质期都有默认值,备注可选。
验证结果直接反映在提交按钮上:
.backgroundColor(this.canSubmit() ? '#2ECC71' : '#1A2A44')
- 可提交:绿色按钮 (
#2ECC71),点击有效 - 不可提交:暗色按钮 (
#1A2A44),点击无反应
8.5 提交后的状态重置
submitRecord() {
// 创建新记录,插入到列表头部
const newRecord: FoodRecord = { ... }
const newList: FoodRecord[] = [newRecord]
for (let i = 0; i < this.records.length; i++) {
newList.push(this.records[i])
}
this.records = newList
this.nextId = this.nextId + 1
// 重置表单
this.newName = ''
this.newCategory = '蔬菜'
this.newQuantity = 1
this.newUnit = '个'
this.newDate = '2025-03-22'
this.newNotes = ''
this.newExpireDays = 7
// 自动切换到列表页,显示新记录
this.activeTab = 'list'
}
关键设计:新记录插入到数组头部(而非尾部),这样在列表中最新记录在最上方,符合用户「最新录入最先看到」的心理模型。
9. 核心功能三:列表展示与交互
9.1 记录卡片的四层信息布局
┌──────────────────────────────────┐
│ 🥩 鸡胸肉 500克 🗑️│ ← 第一行:图标 + 名称 + 数量 + 删除
│ 肉类 │ ← 分类标签(带颜色)
├──────────────────────────────────┤
│ 📅 2025年3月18日 │ ← 第二行:日期
│ ⏳ 60天保质 · 剩余56天 │ ← 保质期信息
│ 💬 冷冻保存 │ ← 备注(可选)
├──────────────────────────────────┤
│ ▓▓▓▓▓▓▓▓▓▓░░░░░░░░░░ │ ← 保质期进度条
└──────────────────────────────────┘
第一行:最核心的信息行。分类 Emoji 提供视觉锚点,名称加粗突出,数量右对齐,删除按钮在最右侧。
分类标签:使用 this.getCatColor() 函数获取分类对应的颜色,作为卡片边框色 record.category + '22'(透明度约 13%),产生柔和的分色边框效果。
第二行:日期和保质期。日期用 formatDate() 格式化为中文格式。剩余天数根据数值显示不同颜色:
- 3 天内:红色
#E74C3C - 7 天内:橙色
#E67E22 - 其他:灰色
#8899AA
第三行:备注,仅在 record.notes.length > 0 时渲染。
进度条:根据剩余天数/保质期的比例渲染彩色进度条,颜色由 calcExpireColor() 函数决定。
9.2 删除操作的双重确认
🗑️ → 点击 → [🗑️] 变为 [确认] [取消]
设计原则:防误触。直接点击🗑️不会立刻删除,而是显示"确认/取消"按钮对:
if (this.deleteConfirmId === record.id) {
// 显示确认/取消按钮
Row({ space: 4 }) {
Text('确认')
.backgroundColor('#E74C3C')
.onClick(() => { this.deleteRecord(record.id); this.deleteConfirmId = -1 })
Text('取消')
.backgroundColor('#1A2A44')
.onClick(() => { this.deleteConfirmId = -1 })
}
} else {
// 显示删除图标
Text('🗑️')
.onClick(() => { this.deleteConfirmId = record.id })
}
@State deleteConfirmId: number = -1 存储当前正在确认删除的记录 ID。值为 -1 表示没有正在确认的删除,任何记录 ID 匹配时显示确认按钮。
9.3 删除逻辑
deleteRecord(id: number) {
let newRecords: FoodRecord[] = []
for (let i = 0; i < this.records.length; i++) {
if (this.records[i].id !== id) {
newRecords.push(this.records[i]) // 跳过要删除的记录
}
}
this.records = newRecords // 触发 UI 重新渲染
}
不使用 splice()(API 24 不支持),而是通过构建新数组 + 跳过目标元素的方式实现不可变删除。这同时触发了 ArkTS 的响应式更新——@State records 被赋予新数组引用,列表重新渲染。
9.4 清空所有记录
Text('清空所有记录')
.fontSize(12).fontColor('#E74C3C')
.onClick(() => {
this.records = []
this.nextId = 1
})
清空时同时重置 nextId = 1,确保后续新增的记录 ID 从 1 开始,不会无限制增长。
10. 核心功能四:搜索与分类筛选
10.1 搜索实现
搜索框使用 TextInput 的 onChange 回调实现输入即搜索:
TextInput({ placeholder: '🔍 搜索食品名称...', text: this.searchText })
.onChange((val: string) => {
this.searchText = val
})
filteredRecords() 方法在每次渲染时被调用,实现实时过滤:
filteredRecords(): FoodRecord[] {
let result: FoodRecord[] = []
for (let i = 0; i < this.records.length; i++) {
const r: FoodRecord = this.records[i]
let match: boolean = true
// 按名称搜索
if (this.searchText.length > 0) {
if (r.name.indexOf(this.searchText) === -1) {
match = false // 名称不包含搜索文字
}
}
// 按分类筛选
if (this.filterCategory.length > 0) {
if (r.category !== this.filterCategory) {
match = false // 分类不匹配
}
}
if (match) {
result.push(r)
}
}
return result
}
搜索和筛选是同时生效的「AND」关系——既匹配搜索关键词又匹配分类的记录才会显示。
10.2 分类筛选条
[全部] [🥬 蔬菜] [🍎 水果] [🥩 肉类] [🦐 海鲜] [🥛 乳制品] [🍪 零食] ...
'全部' 标签将 filterCategory 置为空字符串 '',表示不过滤。点击已选中的标签会取消选中(toggle 行为):
.onClick(() => {
if (this.filterCategory === catName) {
this.filterCategory = '' // 取消选中,显示全部
} else {
this.filterCategory = catName // 选中该分类
}
})
10.3 列表顶部统计摘要
共 3 件食品 | 即将过期: 1 件
当筛选生效时,filteredRecords().length 显示的是过滤后的数量;expiringCount() 计算所有记录中即将过期的数量(不受筛选影响),起到全局提醒作用。
11. 核心功能五:食品保质期预警
11.1 日期计算的简化策略
由于 API 24 不完整支持 Date 对象,我们采用简化日期算法:
function dateDiff(from: string, to: string): number {
const fromParts: number[] = from.split('-').map(Number)
const toParts: number[] = to.split('-').map(Number)
const fromDays: number = fromParts[0] * 365 + fromParts[1] * 30 + fromParts[2]
const toDays: number = toParts[0] * 365 + toParts[1] * 30 + toParts[2]
return toDays - fromDays
}
这个算法将日期转换为"总天数":年 × 365 + 月 × 30 + 日。这只是一个近似值(不考虑闰年、不同月份的实际天数),但对于保质期预警的精度需求(天级别)完全足够。
11.2 剩余天数计算
function calcDaysLeft(expireDays: number, purchaseDate: string): number {
const todayStr: string = '2025-03-22' // 固定为模拟日期
const diffDays: number = dateDiff(purchaseDate, todayStr) // 已过去的天数
return Math.max(0, expireDays - diffDays) // 剩余天数,最小为 0
}
Math.max(0, ...) 确保不会出现负数——如果食品已经过期,剩余天数显示为 0 而不是负数,UI 上显示更友好。
11.3 保质期颜色与进度条
颜色函数:
function calcExpireColor(days: number, purchaseDate: string): string {
if (days <= 3) return '#E74C3C' // ⚫ 红 - 极短保
if (days <= 7) return '#E67E22' // ⚫ 橙 - 短保
if (days <= 30) return '#F1C40F' // ⚫ 黄 - 中等
return '#2ECC71' // ⚫ 绿 - 长保
}
进度条:
const daysLeft: number = calcDaysLeft(record.expireDays, record.date)
const ratio: number = Math.max(0, Math.min(1, daysLeft / Math.max(1, record.expireDays)))
ratio范围:0(完全过期)~ 1(刚购入)- 宽度:
(ratio * 100)%显示比例 - 颜色:使用
calcExpireColor()的结果 Math.max(1, record.expireDays)防止除零——如果expireDays === 0(当天食用),分母为 1,进度条直接变红
11.4 概况页的过期提醒列表
在统计页面,专门列出 3 天内即将过期的食品:
⚠️ 即将过期(3天内)
🥬 大葱 1天后过期
🍅 西红柿 3天后过期
每个条目使用红色边框 (#E74C3C44) 和暗红色背景 (#1A0A0A) 形成强烈的视觉警示。
12. 核心功能六:分类统计概况
12.1 分类统计算法
categoryStats(): string[][] {
let catCount: Record<string, number> = {}
// 第一步:统计每个分类的食品数量
for (let i = 0; i < this.records.length; i++) {
const cat: string = this.records[i].category
if (catCount[cat] === undefined) {
catCount[cat] = 0
}
catCount[cat] = catCount[cat] + 1
}
// 第二步:按 CATEGORIES 顺序输出,保持顺序稳定
let result: string[][] = []
for (let i = 0; i < CATEGORIES.length; i++) {
const cat: string = CATEGORIES[i].name
if (catCount[cat] !== undefined && catCount[cat] > 0) {
result.push([CATEGORIES[i].icon + ' ' + cat, String(catCount[cat]), CATEGORIES[i].color])
}
}
return result
}
按 CATEGORIES 数组的顺序遍历而不是按 catCount 字典的顺序遍历,确保:
- 顺序可预测:蔬菜永远排在肉类前面,符合直觉
- 空分类不显示:如果某个分类没有食品,不会出现「饮料 0 件」的无意义行
- 三元组结构:每个返回项是
[图标+名称, 数量字符串, 颜色],直接传给StatsCard渲染
12.2 StatsCard 通用统计卡片
@Builder
StatsCard(icon: string, value: string, color: string) {
Row() {
Text(icon).fontSize(14).margin({ right: 8 })
Text(value).fontSize(14).fontColor('#FFFFFF').fontWeight(FontWeight.Medium)
}
.padding(14)
.backgroundColor('#111D33')
.borderRadius(10)
.margin({ bottom: 8 })
.border({ width: 1, color: color + '44' })
}
这是一个高复用的卡片组件,用在三个地方:
- 总览:
StatsCard('📦 总计', '10 件食品', '#3498DB') - 分类:
StatsCard('🥬 蔬菜', '2 件', '#2ECC71') - 扩展:未来可以添加更多统计卡片
13. ArkTS 关键语法要点
13.1 @Builder 参数传递
与五笔拆字工具不同,本应用的 @Builder 全部通过参数传递数据,避开了 API 24 的限制。例如 RecordCard 接受 FoodRecord 参数:
@Builder
RecordCard(record: FoodRecord) {
// 直接使用 record 参数,不使用 this.xxx 访问
Text(record.name)
}
调用时传入:
ForEach(this.filteredRecords(), (record: FoodRecord) => {
this.RecordCard(record)
}, (record: FoodRecord) => String(record.id))
13.2 ForEach 的三参数规范
所有 ForEach 调用都严格提供了三个参数:
ForEach(
CATEGORIES, // 1. 数据源
(cat: CategoryInfo) => { /* 渲染 */ }, // 2. 渲染函数
(cat: CategoryInfo) => cat.name // 3. keyGenerator
)
特殊的 key 设计:
| 场景 | Key 生成 | 说明 |
|---|---|---|
| 分类列表 | cat.name |
分类名称在 CATEGORIES 中唯一 |
| 单位列表 | unit |
单位字符串本身唯一 |
| 记录列表 | String(record.id) |
ID 主键唯一 |
| 过期提醒 | 'exp_' + String(record.id) |
加前缀避免与主列表 key 冲突 |
13.3 颜色值拼接与透明度
ArkTS 支持标准十六进制颜色值,我们可以通过字符串拼接来添加透明度通道:
.border({ width: 1, color: '#E74C3C' + '44' })
// 等同于 #E74C3C44 = 红色 + 26.7% 透明度
透明度对照:
| 后缀 | 透明度百分比 | 效果 |
|---|---|---|
'44' |
~27% | 弱边框,只起暗示作用 |
'88' |
~53% | 中等边框,视觉可见 |
'CC' |
~80% | 接近实色 |
13.4 条件渲染的三种形式
本应用使用了三种条件渲染模式:
模式一:if/else if/else (三标签切换)
if (this.activeTab === 'list') {
this.ListSection()
} else if (this.activeTab === 'add') {
this.AddFormSection()
} else {
this.StatsSection()
}
模式二:if-only (备注、批量清空等片段)
if (record.notes.length > 0) {
// 渲染备注行
}
模式三:三元表达式 (内联样式切换)
.fontColor(this.activeTab === section ? '#FFFFFF' : '#8899AA')
14. 常见问题与调试指南
14.1 列表状态不更新
现象:调用 this.records = newList 后,列表 UI 没有刷新。
排查:
- 确认
newList是一个新数组引用——this.records.push(item)不会触发更新 - 确认变量是
@State装饰的 - 检查
ForEach的 keyGenerator 是否返回了稳定的值
修复:确保在修改 @State 数组时使用全量替换:
// ❌ 不会触发更新
this.records.push(newRecord)
// ✅ 会触发更新
const newList: FoodRecord[] = [newRecord]
for (let i = 0; i < this.records.length; i++) {
newList.push(this.records[i])
}
this.records = newList
14.2 搜索无反应
现象:在搜索框输入文字,列表没有过滤。
排查:
onChange是否确实被触发(可以在回调中加临时标记)filteredRecords()方法是否在渲染中调用ForEach(this.filteredRecords(), ...)是否正确传入了过滤后的数组
修复:确保列表渲染使用的是 filteredRecords() 而不是 this.records:
// ❌ 显示全部
ForEach(this.records, ...)
// ✅ 显示过滤后的
ForEach(this.filteredRecords(), ...)
14.3 删除确认按钮不在正确位置
现象:点击某个记录的 🗑️ 按钮,其他记录也显示确认按钮。
原因:@State deleteConfirmId: number 是全局的,只存储正在确认的 ID。如果多个记录同时显示确认按钮,确认逻辑会混乱。
确认:检查代码,确认 deleteConfirmId 是唯一的状态变量,且使用 === record.id 精确匹配:
if (this.deleteConfirmId === record.id) {
// 只对匹配的记录显示确认按钮
}
14.4 表单提交后没有切回列表
现象:点击保存按钮后,页面没有自动跳转到列表。
排查:
canSubmit()返回true了吗?submitRecord()中最后一行this.activeTab = 'list'执行了吗?- 构建按钮的条件渲染是否正确?
修复:检查提交按钮的 onClick 逻辑:
.onClick(() => {
if (this.canSubmit()) {
this.submitRecord()
}
})
14.5 真机调试技巧
- 使用
console.info()输出状态:在关键操作中加入console.info('records length:', this.records.length)在 Log 面板观察 - 检查 DevEco Studio 的 Profiler:查看 @State 变量的变化频率
- 使用模拟器调试键盘输入:真机上 TextInput 弹出键盘可能遮挡表单下半部分
15. 项目总结与扩展方向
15.1 项目数据
| 指标 | 数值 |
|---|---|
| 源文件 | 1 个(Index.ets) |
| 总代码行数 | 812 行 |
| 模拟数据 | 10 条初始记录 |
| @Builder 组件数 | 11 个 |
| 页面模块 | 3 个(记录/录入/概况) |
| 食品分类 | 10 大类 |
| 可用单位 | 12 种 |
| 工具函数 | 5 个(getCategoryInfo/formatDate/calcExpireColor/calcDaysLeft/dateDiff) |
15.2 学到的关键技术点
| 技术点 | 应用位置 | 关键教训 |
|---|---|---|
| 表单状态管理 | AddFormSection | 拆分成独立 @State 比用对象更可靠 |
| 搜索与筛选 | filteredRecords | 搜索和筛选用 AND 逻辑组合 |
| 数据不可变性 | deleteRecord/submitRecord | 全量替换数组触发更新 |
| 条件渲染 | build()/RecordCard | if/else + 三元表达式灵活搭配 |
| 颜色系统 | 全应用 | 分类颜色统一管理,透明度拼接 |
| @Builder 组件化 | 11 处 | 通过参数传递数据,避免局部变量 |
15.3 扩展方向
短期可扩展(不改架构):
- 数据持久化:接入
@ohos.data.preferencesAPI,将 records 保存到本地,重新打开 App 数据不丢失 - 编辑功能:长按记录卡片进入编辑模式,修改已有记录的信息
- 排序功能:按日期排序、按保质期排序、按分类分组显示
- 导出分享:将当前库存列表导出为文本或分享给家人
中期扩展(需适度重构):
- 收藏/购物清单:将常用食品加到收藏列表,下次直接勾选添加
- 库存预警设置:用户可以自定义"即将过期"的阈值(默认 3 天)
- 多冰箱管理:支持家庭冰箱/办公室冰箱等不同位置的独立管理
- 统计图表:使用 Canvas 绘制柱状图/饼图展示分类占比
长期扩展(需大幅重构):
- 多端协同:搭载
@ohos.distributedDeviceManager,实现手机和平板的数据同步 - OCR 识别:拍照识别食品包装上的生产日期和保质期,自动填入
- 智能推荐:根据历史记录推荐采购清单,减少食物浪费
15.4 给 ArkTS 初学者的建议
- 先画 UI 再写代码:在纸上勾画出每个页面的布局,标注出需要的
@State变量 - 从 @Builder 开始:把页面拆分成小块 @Builder,每个只做一件事
- 数据集与 UI 分离:
filteredRecords()这种纯计算函数放在 @Builder 外部,让 UI 组件保持干净 - 善用颜色:给不同分类赋予不同的颜色,用户界面会更有层次感
- 测试边界情况:空列表、搜索不到结果、数量为 1 时再减等
附录 A:数据模型参考
interface FoodRecord {
id: number
name: string
category: string
quantity: number
unit: string
date: string
notes: string
expireDays: number
}
interface CategoryInfo {
name: string
icon: string
color: string
}
附录 B:常用工具函数速查
| 函数 | 输入 | 输出 |
|---|---|---|
formatDate('2025-03-22') |
'2025-03-22' |
'2025年3月22日' |
calcDaysLeft(7, '2025-03-20') |
7, '2025-03-20' |
5(假设今天 3月22日) |
calcExpireColor(7, ...) |
7 |
'#E67E22'(橙色,短保) |
dateDiff('2025-03-20', '2025-03-22') |
两个日期 | 2(相差 2 天) |
getCategoryInfo('蔬菜') |
'蔬菜' |
{ name:'蔬菜', icon:'🥬', color:'#2ECC71' } |
getCatColor('肉类') |
'肉类' |
'#E67E22' |
更多推荐



所有评论(0)