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

目录

  1. 项目背景与产品定位
  2. 需求分析与功能设计
  3. 技术选型与架构设计
  4. 数据模型设计
  5. JSON 模拟数据构建
  6. UI 架构与组件拆解
  7. 核心功能一:三标签导航系统
  8. 核心功能二:表单录入与验证
  9. 核心功能三:列表展示与交互
  10. 核心功能四:搜索与分类筛选
  11. 核心功能五:食品保质期预警
  12. 核心功能六:分类统计概况
  13. ArkTS 关键语法要点
  14. 常见问题与调试指南
  15. 项目总结与扩展方向

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 个工具函数,全部集中在一个文件中。

单文件的优势:

  1. 零模块化开销——没有 import/export,编译器解析快
  2. 完整可读——任何开发者打开一个文件就能看懂全部逻辑
  3. 便于调试——状态定义和 UI 渲染在同一范围,不存在跨文件数据流迷茫
  4. 快速部署——一个文件编译,发布包体积最小

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') 条件渲染实现切换,而非使用页面路由。这种做法的好处是:

  1. 状态保持:切换回列表时,搜索文本、筛选条件仍然保留
  2. 零路由开销:不需要配置 router.push 和页面栈管理
  3. 动画连贯: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
  2. 数字输入框:可直接键盘输入数字,自动校验正整数
  3. 加号按钮 (+):每次加 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 搜索实现

搜索框使用 TextInputonChange 回调实现输入即搜索

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 字典的顺序遍历,确保:

  1. 顺序可预测:蔬菜永远排在肉类前面,符合直觉
  2. 空分类不显示:如果某个分类没有食品,不会出现「饮料 0 件」的无意义行
  3. 三元组结构:每个返回项是 [图标+名称, 数量字符串, 颜色],直接传给 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 没有刷新。

排查

  1. 确认 newList 是一个新数组引用——this.records.push(item) 不会触发更新
  2. 确认变量是 @State 装饰的
  3. 检查 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 搜索无反应

现象:在搜索框输入文字,列表没有过滤。

排查

  1. onChange 是否确实被触发(可以在回调中加临时标记)
  2. filteredRecords() 方法是否在渲染中调用
  3. 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 表单提交后没有切回列表

现象:点击保存按钮后,页面没有自动跳转到列表。

排查

  1. canSubmit() 返回 true 了吗?
  2. submitRecord() 中最后一行 this.activeTab = 'list' 执行了吗?
  3. 构建按钮的条件渲染是否正确?

修复:检查提交按钮的 onClick 逻辑:

.onClick(() => {
  if (this.canSubmit()) {
    this.submitRecord()
  }
})

14.5 真机调试技巧

  1. 使用 console.info() 输出状态:在关键操作中加入 console.info('records length:', this.records.length) 在 Log 面板观察
  2. 检查 DevEco Studio 的 Profiler:查看 @State 变量的变化频率
  3. 使用模拟器调试键盘输入:真机上 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 扩展方向

短期可扩展(不改架构)

  1. 数据持久化:接入 @ohos.data.preferences API,将 records 保存到本地,重新打开 App 数据不丢失
  2. 编辑功能:长按记录卡片进入编辑模式,修改已有记录的信息
  3. 排序功能:按日期排序、按保质期排序、按分类分组显示
  4. 导出分享:将当前库存列表导出为文本或分享给家人

中期扩展(需适度重构)

  1. 收藏/购物清单:将常用食品加到收藏列表,下次直接勾选添加
  2. 库存预警设置:用户可以自定义"即将过期"的阈值(默认 3 天)
  3. 多冰箱管理:支持家庭冰箱/办公室冰箱等不同位置的独立管理
  4. 统计图表:使用 Canvas 绘制柱状图/饼图展示分类占比

长期扩展(需大幅重构)

  1. 多端协同:搭载 @ohos.distributedDeviceManager,实现手机和平板的数据同步
  2. OCR 识别:拍照识别食品包装上的生产日期和保质期,自动填入
  3. 智能推荐:根据历史记录推荐采购清单,减少食物浪费

15.4 给 ArkTS 初学者的建议

  1. 先画 UI 再写代码:在纸上勾画出每个页面的布局,标注出需要的 @State 变量
  2. 从 @Builder 开始:把页面拆分成小块 @Builder,每个只做一件事
  3. 数据集与 UI 分离filteredRecords() 这种纯计算函数放在 @Builder 外部,让 UI 组件保持干净
  4. 善用颜色:给不同分类赋予不同的颜色,用户界面会更有层次感
  5. 测试边界情况:空列表、搜索不到结果、数量为 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'
Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐