基于鸿蒙os开发跑步管理系统(六)-饮食管理
饮食管理
饮食页面概述
饮食管理页面 (DietPage) 是应用的核心功能模块之一,提供食物浏览、搜索、分类筛选和摄入记录功能。页面内置约 100 种中国常见食物的热量数据库,用户可快速记录每日饮食并追踪热量摄入。
页面布局
DietPage 采用三段式布局:头部信息区、食物列表区和添加按钮区:
+----------------------------------+
| 饮食记录 1580 kcal | <-- DietHeader
| [搜索食物...] |
| [谷物] [肉类] [蔬菜] [水果] [乳]| <-- 分类标签
+----------------------------------+
| 米饭 116 kcal/100g +| <-- DietRecordList
| 馒头 221 kcal/100g +|
| 面条(熟) 110 kcal/100g +|
| 面包 312 kcal/100g +|
| ... |
+----------------------------------+
代码结构
@Builder
DietPage() {
Column() {
this.DietHeader()
this.DietRecordList()
this.DietAddButton()
}
.width('100%')
.height('100%')
.backgroundColor(Constants.COLOR_BG)
}
头部区域 (DietHeader)
布局结构
头部区域包含三行内容:标题行、搜索行和分类标签行。
+----------------------------------+
| 饮食记录 1580 kcal | 标题行
+----------------------------------+
| [搜索食物...] | 搜索行
+----------------------------------+
| [谷物] [肉类] [蔬菜] [水果] [乳]| 分类行
+----------------------------------+
标题行
左侧显示页面标题,右侧显示今日已摄入总热量:
Row() {
Text('饮食记录')
.fontSize(Constants.FONT_SIZE_TITLE) // 24
.fontWeight(FontWeight.Bold)
Blank()
Text(`${this.todayCalories} kcal`)
.fontSize(Constants.FONT_SIZE_SUBTITLE) // 18
.fontColor(Constants.COLOR_ORANGE) // #FF9800
}
.width('100%')
搜索栏
使用 TextInput 组件实现实时搜索:
TextInput({ placeholder: '搜索食物...', text: this.dietSearchKeyword })
.width('100%')
.height(40)
.fontSize(Constants.FONT_SIZE_BODY)
.onChange((value: string) => {
this.dietSearchKeyword = value;
})
搜索通过 @State dietSearchKeyword 驱动,输入变化时自动刷新食物列表。
分类标签
使用 ForEach 渲染五个分类标签,支持切换和取消选择:
Row({ space: Constants.PADDING_TINY }) {
ForEach(CalorieService.getInstance().getCategories(), (cat: string) => {
Row() {
Text(cat)
.fontColor(this.selectedCategory === cat
? Constants.COLOR_TEXT_WHITE
: Constants.COLOR_TEXT_PRIMARY)
}
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
.backgroundColor(this.selectedCategory === cat
? Constants.COLOR_PRIMARY // 选中: 蓝色背景白色文字
: Constants.COLOR_BG) // 未选: 灰色背景黑色文字
.borderRadius(16)
.onClick(() => {
this.selectedCategory =
this.selectedCategory === cat ? '' : cat;
})
}, (cat: string) => cat)
}
食物列表 (DietRecordList)
列表渲染
使用 List + ForEach 渲染食物条目,支持按分类或关键词筛选:
List({ space: Constants.PADDING_TINY }) {
ForEach(this.getDietFoods(), (food: FoodItem) => {
ListItem() {
Row() {
Column() {
Text(food.name)
Text(`${food.caloriesPer100g} kcal/100g`)
}
Blank()
Text('+')
.fontSize(20)
.fontColor(Constants.COLOR_PRIMARY)
}
.onClick(() => {
this.selectedFood = food;
this.foodGrams = String(food.defaultGrams);
this.showFoodDialog = true;
})
}
}, (food: FoodItem) => food.id)
}
.width('100%')
.layoutWeight(1)
筛选逻辑
getDietFoods() 方法根据用户操作返回不同结果,优先级为:分类 > 搜索 > 全部:
private getDietFoods(): FoodItem[] {
const cs: CalorieService = CalorieService.getInstance();
if (this.selectedCategory !== '') {
return cs.getFoodsByCategory(this.selectedCategory);
}
if (this.dietSearchKeyword !== '') {
return cs.searchFoods(this.dietSearchKeyword);
}
return cs.getAllFoods();
}
食物添加对话框 (FoodInputDialog)
点击食物条目的 “+” 按钮后,弹出模态对话框,用户可设置摄入量并确认添加。
g)
对话框布局
+----------------------------------+
| (半透明遮罩) |
| +--------------------------+ |
| | 鸡胸肉 | |
| | | |
| | 重量(g): [ 150 ] | |
| | | |
| | 热量: 200 kcal | |
| | | |
| | [取消] [添加] | |
| +--------------------------+ |
+----------------------------------+
对话框实现
@Builder
FoodInputDialog() {
Column() {
Column({ space: Constants.PADDING_SMALL }) {
Text(this.selectedFood!.name)
.fontSize(Constants.FONT_SIZE_SUBTITLE)
.fontWeight(FontWeight.Bold)
Row() {
Text('重量(g):')
TextInput({ text: this.foodGrams })
.width(100)
.height(40)
.type(InputType.Number)
.onChange((value: string) => { this.foodGrams = value; })
}
Text(`热量: ${this.selectedFood!.getCalories(parseInt(this.foodGrams))} kcal`)
.fontColor(Constants.COLOR_ORANGE)
Row({ space: Constants.PADDING_SMALL }) {
Button('取消')
.backgroundColor(Constants.COLOR_BG)
.fontColor(Constants.COLOR_TEXT_PRIMARY)
.onClick(() => { this.showFoodDialog = false; })
Button('添加')
.backgroundColor(Constants.COLOR_PRIMARY)
.fontColor(Constants.COLOR_TEXT_WHITE)
.onClick(() => { this.addDietRecord(); })
}
}
.width('80%')
.padding(Constants.PADDING_CARD)
.backgroundColor(Constants.COLOR_CARD)
.borderRadius(Constants.BORDER_RADIUS_CARD)
}
.width('100%')
.height('100%')
.backgroundColor('rgba(0,0,0,0.4)')
.justifyContent(FlexAlign.Center)
.position({ x: 0, y: 0 })
.zIndex(999)
}
关键设计
- 使用 position + zIndex 实现全屏遮罩覆盖
- 半透明黑色背景 (rgba(0,0,0,0.4)) 突出对话框
- 对话框宽度 80%,居中显示
- 重量输入框限定为数字类型 (InputType.Number)
- 热量实时计算,随重量输入动态更新
添加饮食记录流程
private addDietRecord(): void {
if (this.selectedFood === undefined) return;
const grams: number = parseInt(this.foodGrams);
if (isNaN(grams) || grams <= 0) return;
const record: DietRecord = DietRecord.create(this.selectedFood!, grams);
const prefs: PreferencesService = PreferencesService.getInstance();
const records: DietRecord[] = prefs.getDietRecords();
records.push(record);
prefs.saveDietRecords(records);
this.todayCalories = prefs.getTodayCalories();
this.showFoodDialog = false;
}
数据流
用户点击 "+"
|
v
设置 selectedFood + foodGrams + showFoodDialog = true
|
v
弹出 FoodInputDialog
|
v
用户修改重量 -> 实时计算热量
|
v
点击 "添加"
|-- DietRecord.create(food, grams) // 自动计算热量
|-- PreferencesService.getDietRecords() // 读取已有记录
|-- records.push(newRecord) // 追加新记录
|-- PreferencesService.saveDietRecords(records) // 持久化
|-- 更新 todayCalories // 刷新首页热量数据
|-- showFoodDialog = false // 关闭对话框
CalorieService 食物数据库
数据规模
内置 90 种常见食物,分为 5 大类别,每类 18-20 种:
谷物类 (20种)
| ID | 名称 | 热量(kcal/100g) | 默认量(g) |
|---|---|---|---|
| g1 | 米饭 | 116 | 200 |
| g2 | 馒头 | 221 | 100 |
| g3 | 面条(熟) | 110 | 200 |
| g4 | 面包 | 312 | 60 |
| g5 | 燕麦片 | 377 | 40 |
| g6 | 包子 | 227 | 100 |
| g7 | 饺子 | 196 | 100 |
| g8 | 油条 | 386 | 50 |
| g9 | 小米粥 | 46 | 300 |
| g10 | 玉米 | 112 | 200 |
| g11 | 红薯 | 99 | 200 |
| g12 | 饼干 | 433 | 30 |
| g13 | 蛋糕 | 348 | 80 |
| g14 | 年糕 | 154 | 100 |
| g15 | 煎饼 | 258 | 100 |
| g16 | 烧饼 | 326 | 80 |
| g17 | 花卷 | 211 | 100 |
| g18 | 粥(白) | 33 | 300 |
| g19 | 烙饼 | 259 | 100 |
| g20 | 杂粮饭 | 126 | 200 |
肉类 (20种)
| ID | 名称 | 热量(kcal/100g) | 默认量(g) |
|---|---|---|---|
| m1 | 鸡胸肉 | 133 | 150 |
| m2 | 猪瘦肉 | 143 | 100 |
| m3 | 牛肉 | 125 | 100 |
| m4 | 羊肉 | 203 | 100 |
| m5 | 猪排骨 | 264 | 100 |
| m6 | 鸡翅 | 194 | 100 |
| m7 | 鸡腿 | 181 | 100 |
| m8 | 鱼肉(草鱼) | 113 | 150 |
| m9 | 鱼肉(鲈鱼) | 105 | 150 |
| m10 | 虾仁 | 87 | 100 |
| m11 | 鱼肉(三文鱼) | 139 | 100 |
| m12 | 猪肉(肥瘦) | 395 | 80 |
| m13 | 培根 | 541 | 30 |
| m14 | 火腿肠 | 212 | 50 |
| m15 | 鸭肉 | 240 | 100 |
| m16 | 鹅肉 | 245 | 100 |
| m17 | 驴肉 | 116 | 100 |
| m18 | 兔肉 | 102 | 100 |
| m19 | 鱼肉(带鱼) | 127 | 100 |
| m20 | 鱼肉(鲫鱼) | 108 | 150 |
蔬菜类 (20种)
| ID | 名称 | 热量(kcal/100g) | 默认量(g) |
|---|---|---|---|
| v1 | 西红柿 | 19 | 200 |
| v2 | 黄瓜 | 15 | 200 |
| v3 | 胡萝卜 | 37 | 150 |
| v4 | 菠菜 | 24 | 150 |
| v5 | 白菜 | 20 | 200 |
| v6 | 土豆 | 76 | 200 |
| v7 | 茄子 | 21 | 200 |
| v8 | 青椒 | 22 | 100 |
| v9 | 西兰花 | 34 | 150 |
| v10 | 生菜 | 13 | 150 |
| v15 | 豆腐 | 82 | 150 |
| v20 | 紫甘蓝 | 25 | 150 |
水果类 (20种)
| ID | 名称 | 热量(kcal/100g) | 默认量(g) |
|---|---|---|---|
| f1 | 苹果 | 53 | 200 |
| f2 | 香蕉 | 93 | 120 |
| f3 | 橙子 | 48 | 200 |
| f5 | 西瓜 | 25 | 300 |
| f9 | 猕猴桃 | 56 | 150 |
| f16 | 火龙果 | 51 | 200 |
| f17 | 蓝莓 | 57 | 100 |
乳制品类 (10种)
| ID | 名称 | 热量(kcal/100g) | 默认量(g) |
|---|---|---|---|
| d1 | 牛奶 | 54 | 250 |
| d2 | 酸奶 | 72 | 200 |
| d3 | 奶酪 | 328 | 30 |
| d4 | 鸡蛋(煮) | 144 | 60 |
| d5 | 豆浆 | 31 | 300 |
| d6 | 黄油 | 888 | 10 |
热量计算示例
用户添加 150g 鸡胸肉的记录:
FoodItem: 鸡胸肉, 133 kcal/100g
用户输入: 150g
计算: 133 * 150 / 100 = 199.5 ≈ 200 kcal
DietRecord:
foodId: 'm1'
foodName: '鸡胸肉'
grams: 150
calories: 200
timestamp: 1720797600000
category: '肉类'
状态变量总结
| 状态变量 | 类型 | 用途 |
|---|---|---|
| dietSearchKeyword | string | 搜索关键词 |
| dietRecords | DietRecord[] | 饮食记录列表 |
| selectedCategory | string | 当前选中分类 |
| showFoodDialog | boolean | 是否显示食物对话框 |
| selectedFood | FoodItem | undefined | 当前选中食物 |
| foodGrams | string | 用户输入的重量 |
| todayCalories | number | 今日总热量 |
饮食页面 Stack 布局设计
布局层级
DietPage 采用 Stack 叠加布局实现对话框覆盖效果。整体层级从底到顶为:
Stack
|-- Column (主内容区)
| |-- DietHeader
| |-- DietRecordList
| |-- DietAddButton
|
|-- FoodInputDialog (条件渲染, showFoodDialog = true 时显示)
|-- 半透明遮罩
|-- 对话框卡片
Stack 的优势在于对话框可以自然覆盖在主内容之上,无需额外的弹窗组件。当 showFoodDialog 为 false 时,FoodInputDialog 不参与渲染,不影响主内容的布局计算。
今日记录区域
饮食页面包含"今日记录"区域,展示用户今日已添加的饮食记录列表。每条记录显示食物名称、摄入重量和热量值:
+----------------------------------+
| 今日记录 |
| +------------------------------+ |
| | 鸡胸肉 150g 200 kcal | |
| | 米饭 200g 232 kcal | |
| | 苹果 200g 106 kcal | |
| +------------------------------+ |
+----------------------------------+
今日记录通过筛选 DietRecord 中 timestamp 属于当日的条目生成:
private getTodayRecords(): DietRecord[] {
const records: DietRecord[] = PreferencesService.getInstance().getDietRecords();
const todayStart: number = new Date(
new Date().getFullYear(),
new Date().getMonth(),
new Date().getDate()
).getTime();
const result: DietRecord[] = [];
for (let i = 0; i < records.length; i++) {
if (records[i].timestamp >= todayStart) {
result.push(records[i]);
}
}
return result;
}
Stack 对话框实现细节
对话框使用条件渲染 if 语句控制显示,而非 AlertDialog 或 CustomDialog。原因如下:
- AlertDialog 样式定制能力有限,无法实现自定义布局(如重量输入框和实时热量计算)
- CustomDialog 需要单独的 @Component 定义,在单文件架构下增加跨组件通信成本
- Stack + 条件渲染方案简单直接,@State showFoodDialog 变化即触发 UI 更新
if (this.showFoodDialog && this.selectedFood !== undefined) {
this.FoodInputDialog()
}
遮罩层使用 position({ x: 0, y: 0 }) 实现全屏覆盖,zIndex(999) 确保在其他元素之上。点击遮罩区域(对话框外部)可关闭对话框,通过在遮罩 Column 上设置 onClick 实现:
Column() {
// 遮罩区域
}
.width('100%')
.height('100%')
.backgroundColor('rgba(0,0,0,0.4)')
.onClick(() => { this.showFoodDialog = false; })
搜索功能深度解析
实时搜索机制
搜索通过 @State dietSearchKeyword 驱动,TextInput 的 onChange 回调实时更新关键词:
TextInput({ placeholder: '搜索食物...', text: this.dietSearchKeyword })
.onChange((value: string) => {
this.dietSearchKeyword = value;
})
每次键入都会触发 @State 更新,导致依赖 dietSearchKeyword 的 getDietFoods() 重新计算,列表自动刷新。这种"即时搜索"体验在 100 条数据规模下性能无忧。
搜索与分类的优先级
getDietFoods() 方法中,分类筛选优先于搜索:
private getDietFoods(): FoodItem[] {
const cs: CalorieService = CalorieService.getInstance();
if (this.selectedCategory !== '') {
return cs.getFoodsByCategory(this.selectedCategory);
}
if (this.dietSearchKeyword !== '') {
return cs.searchFoods(this.dietSearchKeyword);
}
return cs.getAllFoods();
}
设计理由:用户选择分类时,通常是在浏览特定类别的食物,搜索框内容可能是上一次搜索的残留,不应干扰分类浏览。只有未选择分类时,搜索才生效。
搜索场景示例
| 用户操作 | selectedCategory | dietSearchKeyword | 结果 |
|---|---|---|---|
| 初始状态 | ‘’ | ‘’ | 全部 100 种食物 |
| 选择"肉类" | ‘肉类’ | ‘’ | 20 种肉类 |
| 选择"肉类" + 输入"鸡" | ‘肉类’ | ‘鸡’ | 20 种肉类(搜索被忽略) |
| 取消分类 + 输入"鸡" | ‘’ | ‘鸡’ | 鸡胸肉、鸡翅、鸡腿等 |
| 输入"鱼" | ‘’ | ‘鱼’ | 草鱼、鲈鱼、三文鱼、带鱼、鲫鱼 |
分类标签交互设计
标签切换逻辑
分类标签支持选中/取消切换,再次点击已选中的标签即取消选择:
.onClick(() => {
this.selectedCategory =
this.selectedCategory === cat ? '' : cat;
})
这种 toggle 行为符合用户预期:点击"肉类"筛选肉类,再次点击"肉类"恢复全部。
标签样式
| 状态 | 背景色 | 文字色 |
|---|---|---|
| 选中 | COLOR_PRIMARY (#4A90D9) | COLOR_TEXT_WHITE (#FFFFFF) |
| 未选中 | COLOR_BG (#F5F5F5) | COLOR_TEXT_PRIMARY (#333333) |
标签使用 borderRadius(16) 实现胶囊形状,padding 水平 12vp、垂直 6vp,文字大小与正文一致 (14fp)。
食物列表交互详解
点击食物条目
点击食物条目时,设置三个状态变量并弹出对话框:
.onClick(() => {
this.selectedFood = food;
this.foodGrams = String(food.defaultGrams);
this.showFoodDialog = true;
})
- selectedFood: 记录用户选择的食物,对话框中显示食物名称和单位热量
- foodGrams: 初始化为食物的默认摄入量(如米饭 200g、鸡蛋 60g),减少用户输入
- showFoodDialog: 设为 true 触发对话框渲染
默认摄入量设计
每种食物的 defaultGrams 根据常见食用份量预设:
| 食物 | defaultGrams | 依据 |
|---|---|---|
| 米饭 | 200g | 一碗米饭约 200g |
| 馒头 | 100g | 一个馒头约 100g |
| 鸡蛋 | 60g | 一个鸡蛋约 60g |
| 牛奶 | 250g | 一杯牛奶约 250ml |
| 黄油 | 10g | 涂抹量 |
| 西瓜 | 300g | 两片西瓜 |
默认值让大多数场景下用户只需点击"添加"即可,无需手动输入重量。
热量实时计算
对话框中的热量值随重量输入动态更新:
Text(`热量: ${this.selectedFood!.getCalories(parseInt(this.foodGrams))} kcal`)
每次 foodGrams 变化(通过 TextInput.onChange)都会触发 @State 更新,UI 重新渲染显示最新的热量值。这让用户在调整重量时直观看到热量变化,帮助做出合理的摄入决策。
食物数据库设计思路
为什么硬编码而非外部数据
100 种食物数据直接写在 CalorieService 的构造器中,而非从 JSON 文件或数据库加载。原因如下:
- 数据量小(约 100 条),硬编码不会显著增加应用体积
- 避免文件 I/O 延迟,食物数据在首次调用时立即可用
- ArkTS 严格模式下,读取 rawfile 需要异步操作,增加代码复杂度
- 数据更新频率低(食物热量值基本不变),不需要动态加载
热量数据准确性
食物热量值参考中国食物成分表,但需注意以下限制:
- 同种食物因品种、产地、成熟度不同,热量值存在浮动
- 烹饪方式影响显著(油炸 vs 水煮热量差异可达 3-5 倍)
- 数据为每 100g 原料热量,成品(如饺子)已包含综合成分
- 用户应将热量值视为参考估计,而非精确值
分类体系
五分类体系基于中国居民膳食指南的食物分类:
| 类别 | 覆盖范围 | 主要营养素 |
|---|---|---|
| 谷物 | 主食类 | 碳水化合物 |
| 肉类 | 禽畜鱼虾 | 蛋白质、脂肪 |
| 蔬菜 | 菜蔬豆制品 | 膳食纤维、维生素 |
| 水果 | 鲜果干果 | 果糖、维生素 |
| 乳制品 | 奶蛋豆奶 | 钙、蛋白质 |
添加饮食记录的边界情况
输入验证
addDietRecord() 方法对用户输入进行基本验证:
private addDietRecord(): void {
if (this.selectedFood === undefined) return;
const grams: number = parseInt(this.foodGrams);
if (isNaN(grams) || grams <= 0) return;
// ...
}
验证项:
- selectedFood 不能为 undefined(理论上不会发生,因为对话框仅在选中食物后显示)
- 重量必须为有效数字且大于 0
未验证的情况(当前版本):
- 重量上限(用户可输入 99999g,产生不合理热量值)
- 非整数输入(parseInt 会截断小数部分,“150.5” -> 150)
记录持久化时机
const records: DietRecord[] = prefs.getDietRecords();
records.push(record);
prefs.saveDietRecords(records);
this.todayCalories = prefs.getTodayCalories();
每次添加记录都会完整读取、追加、写回整个记录数组。这种"读取-修改-写入"模式在小数据量下简单可靠,但存在并发风险(理论上两个快速连续的添加操作可能丢失其中一个)。在单用户单线程的移动应用场景下,此风险可忽略。
首页热量同步
添加记录后更新 todayCalories:
this.todayCalories = prefs.getTodayCalories();
由于 todayCalories 是 @State 变量,更新后首页的 CalorieSummaryCard 会自动刷新。这是单文件架构下 @State 共享的天然优势——无需事件总线或观察者模式,状态变更自动传播到所有绑定组件。
食物对话框用户体验设计
对话框宽度与位置
对话框宽度设为屏幕宽度的 80%,居中显示。这一比例确保对话框在手机屏幕上有足够的操作区域,同时两侧留白让用户感知到下方有主内容(暗示对话框可关闭)。
重量输入限定
TextInput({ text: this.foodGrams })
.type(InputType.Number)
InputType.Number 限定输入键盘为数字键盘,减少误输入。但需要注意,InputType.Number 在不同设备上的行为可能略有差异(如是否允许小数点)。
取消与添加按钮
两个按钮的视觉权重不同:
- "取消"按钮:灰色背景(COLOR_BG),黑色文字,视觉弱化
- "添加"按钮:蓝色背景(COLOR_PRIMARY),白色文字,视觉强调
这种设计引导用户优先点击"添加",同时保留"取消"的可达性。按钮间距使用 PADDING_SMALL (8vp) 分隔。
饮食页面状态变量生命周期
| 状态变量 | 初始值 | 更新时机 | 持久化 |
|---|---|---|---|
| dietSearchKeyword | ‘’ | 用户输入 | 否(不持久化,重启清空) |
| selectedCategory | ‘’ | 用户点击分类标签 | 否 |
| showFoodDialog | false | 点击食物/点击取消 | 否 |
| selectedFood | undefined | 点击食物条目 | 否 |
| foodGrams | ‘’ | 对话框中输入 | 否 |
| todayCalories | 0 | aboutToAppear / 添加记录 | 是(通过Preferences) |
| dietRecords | [] | aboutToAppear / 添加记录 | 是 |
dietSearchKeyword 和 selectedCategory 不持久化,意味着用户每次进入饮食页面时都从默认状态开始。这是合理的设计——搜索和筛选是临时操作,无需跨会话保留。
饮食记录数据流完整图
用户进入饮食页面
|
+-- aboutToAppear() 加载 todayCalories (从Preferences)
|
+-- 渲染头部 (显示今日总热量)
|
+-- 渲染分类标签 (默认无选中)
|
+-- 渲染食物列表 (显示全部100种)
|
+-- 用户操作
|
+-- 选择分类 -> 列表刷新为该分类食物
| |
| +-- 再次点击 -> 取消选择, 恢复全部
|
+-- 输入搜索词 -> 列表实时刷新
| |
| +-- 清空搜索词 -> 恢复全部/分类结果
|
+-- 点击食物 "+" -> 弹出对话框
|
+-- 修改重量 -> 实时显示热量
| |
| +-- 点击"取消" -> 关闭对话框
| +-- 点击"添加" -> 创建记录, 持久化, 刷新热量
|
+-- 点击遮罩 -> 关闭对话框
更多推荐

所有评论(0)