HarmonyOS7卡路里追踪看板实战:用派生计算把饮食数据变成可读结果
文章目录
前言
健康类页面最常见的问题,不是界面不够花,而是数据虽然很多,用户却很难一眼看明白。吃了多少、还剩多少、哪一餐超了、蛋白质够不够,这些问题如果只是把原始记录堆在列表里,页面就只能算“记账”,不能算“分析”。
这个案例的价值,在于它已经不只是展示食物清单,而是开始把原始饮食记录往“结果视图”上做转换。也就是说,它在练的不是单纯布局,而是 如何用 HarmonyOS7 的状态和派生函数,把饮食数据组织成一个真正能读、能分析、能比较的健康看板。
适用场景:减脂打卡、健身饮食记录、运动营养管理、健康日报
本篇重点:聚合计算、营养素汇总、列表与分析双视图联动
阅读建议:边看边思考“哪些值应该存,哪些值应该算”
这类页面为什么难写

卡路里页面看上去像信息展示页,实际很依赖数据关系。
因为用户关心的从来不是一条条食物本身,而是这些记录最后汇总出了什么结论:
- 今天总共摄入了多少热量。
- 运动消耗抵掉以后,还剩多少空间。
- 早餐、午餐、晚餐、加餐分别占了多少。
- 三大营养素有没有偏科。
所以这类页面最核心的能力,是把原始记录持续“加工”为不同层级的摘要结果。这个案例正好把这件事演示得比较完整。
先从用户使用路径看这页在解决什么问题
建议你先把页面按下面顺序体验一遍:
- 打开页面,第一眼先看环形卡路里摘要,确认总摄入、目标值、运动消耗、剩余值是否清楚。
- 看三大营养素的进度条,判断页面有没有把“热量”之外的信息补全。
- 点击
饮食记录和营养分析两个 Tab,感受同一批数据是怎么被切成两种阅读方式的。 - 在饮食记录里观察每一餐的卡路里汇总和具体食物明细。
- 切到分析页,看餐次热量分布和营养素详情是否与前面的汇总保持一致。
这一轮操作下来,你会发现它的设计重点非常明确:同一份 foodEntries 数据,既要服务明细展示,也要服务统计分析。
一切计算的起点,都是这份原始饮食记录

先看状态数据:
@State foodEntries: FoodEntry[] = [
{ id: 1, name: '牛奶', calories: 130, protein: 6.8, carbs: 9.6, fat: 7.2, time: '07:30', mealType: 'breakfast', quantity: 250, unit: 'ml' },
{ id: 2, name: '全麦面包', calories: 180, protein: 8, carbs: 32, fat: 2.5, time: '07:35', mealType: 'breakfast', quantity: 2, unit: '片' },
{ id: 3, name: '水煮蛋', calories: 72, protein: 6.3, carbs: 0.6, fat: 4.8, time: '07:40', mealType: 'breakfast', quantity: 1, unit: '个' },
{ id: 4, name: '米饭', calories: 232, protein: 4, carbs: 50, fat: 0.5, time: '12:10', mealType: 'lunch', quantity: 150, unit: 'g' },
{ id: 5, name: '红烧肉', calories: 395, protein: 18, carbs: 12, fat: 30, time: '12:15', mealType: 'lunch', quantity: 100, unit: 'g' },
{ id: 6, name: '炒青菜', calories: 68, protein: 2.5, carbs: 8, fat: 3.5, time: '12:20', mealType: 'lunch', quantity: 200, unit: 'g' },
{ id: 7, name: '苹果', calories: 72, protein: 0.3, carbs: 19, fat: 0.2, time: '15:00', mealType: 'snack', quantity: 1, unit: '个' },
{ id: 8, name: '坚果', calories: 160, protein: 5, carbs: 6, fat: 14, time: '15:05', mealType: 'snack', quantity: 30, unit: 'g' },
{ id: 9, name: '烤鸡胸', calories: 165, protein: 31, carbs: 0, fat: 3.6, time: '18:30', mealType: 'dinner', quantity: 150, unit: 'g' },
{ id: 10, name: '沙拉', calories: 95, protein: 3, carbs: 15, fat: 3, time: '18:35', mealType: 'dinner', quantity: 200, unit: 'g' },
]
@State activeTab: number = 0
@State dailyCalorieGoal: number = 2000
@State exerciseCalories: number = 320
这里可以明显看出,作者在建模时已经不是只考虑“食物名字”,而是把一条饮食记录拆成了多个分析维度:
| 字段 | 业务含义 |
|---|---|
calories |
用来算总摄入、剩余热量、餐次占比 |
protein / carbs / fat |
用来做营养素分析 |
mealType |
决定它属于早餐、午餐、加餐还是晚餐 |
time |
支撑明细列表的时间排序感 |
quantity + unit |
让记录更像真实饮食日志,而不是只剩一个菜名 |
这就是健康类页面的关键前提:原始数据必须具备被继续计算的价值。 如果你一开始只存菜名和热量,后面很多分析面板根本做不出来。
这页写得最好的地方,是大量使用派生计算
很多初学者做统计页时,会忍不住把“总热量”“剩余热量”“蛋白质总量”都单独存成状态。这样短期能跑,后面一改数据源就容易错位。
这份代码明显更稳,因为它选择了“主数据只存一份,其他结果全部通过函数推导”。
总热量
getTotalCalories(): number {
return this.foodEntries.reduce((sum: number, e: FoodEntry) => sum + e.calories, 0)
}
这就是最基础的一层汇总。页面上的总摄入数字、环形进度、餐次占比,后面都建立在它之上。
三大营养素汇总
getTotalByNutrient(key: string): number {
return this.foodEntries.reduce((sum: number, e: FoodEntry) => {
if (key === 'protein') return sum + e.protein
if (key === 'carbs') return sum + e.carbs
if (key === 'fat') return sum + e.fat
return sum
}, 0)
}
这个函数很值得学,因为它把一类“字段选择型计算”收拢到了一处。你不需要写三个几乎一样的函数,而是用一个入口去支持蛋白质、碳水和脂肪三种统计。
从教程角度看,这种写法特别适合培养一个习惯:不要为每个统计结果都复制一份逻辑,而是先找它们是不是同一类问题。
剩余热量
getRemainingCalories(): number {
return this.dailyCalorieGoal - this.getTotalCalories() + this.exerciseCalories
}
这一段虽然短,却体现了页面的业务判断。它不是简单比较摄入和目标,而是把运动消耗也计算进去。也就是说,这页不是在做“你今天吃了多少”,而是在做“你的净热量空间还剩多少”。
这一步让页面从普通记录页,往真正的健康管理页面走近了一层。
进度百分比
getProgressPercent(): number {
return Math.min(100, Math.round(this.getTotalCalories() / this.dailyCalorieGoal * 100))
}
这里用了 Math.min(100, ...) 做上限裁剪,很像真实产品会有的处理。因为如果今天吃超了,环形图继续涨到 135% 并不一定是最合适的视觉表达。先限制在 100%,可以让组件表现保持稳定,再通过“剩余值变红”去表达超标状态,这个取舍是合理的。
餐次维度的拆分,让页面有了第二层可读性
只有总热量还不够,因为用户接下来往往会问:“到底是哪一餐吃多了?”
这个问题由两组函数来回答:
getMealCalories(mealType: string): number {
return this.foodEntries
.filter((e: FoodEntry) => e.mealType === mealType)
.reduce((sum: number, e: FoodEntry) => sum + e.calories, 0)
}
getMealEntries(mealType: string): FoodEntry[] {
return this.foodEntries.filter((e: FoodEntry) => e.mealType === mealType)
}
这里能看出作者的思路很清楚:
getMealEntries()负责把明细按餐次切出来。getMealCalories()负责把同一餐的热量小计算出来。
于是饮食记录页里,每一餐都可以同时展示“这一餐吃了什么”和“这一餐总共多少千卡”。这就让页面从一堆平铺记录,变成了更接近日常饮食逻辑的分组视图。
顶部摘要区为什么能一眼抓住重点
页面最醒目的部分,是那个环形摘要卡片。它不是单纯为了好看,而是在很小的空间里放进了四类关键信息:
- 当前总摄入。
- 每日目标值。
- 运动消耗值。
- 剩余热量空间。
再叠加三大营养素的小进度条,用户基本不下滑就能判断今天饮食是不是大方向正常。
从信息架构角度说,这就是“总览优先”。用户第一眼先看结论,再决定要不要下探到明细。
activeTab 做的不是换样式,而是切换阅读模式
很多教程把 Tab 写成简单的样式切换,这篇案例里不是。
@State activeTab: number = 0
配合下面的条件渲染,它实际上在切换两种完全不同的页面阅读逻辑:
饮食记录:强调按餐次阅读原始记录。营养分析:强调按统计结果理解当天摄入结构。
这很像真实产品。因为健康管理类页面通常既要满足“我要记今天吃了什么”,也要满足“我想知道整体结构是否合理”。一个页面里同时承接这两种需求,Tab 就不只是 UI 控件,而是内容组织工具。
这类页面最容易犯的错,这个案例基本避开了
如果让我总结这篇代码做对了什么,主要有三点:
- 没有重复存统计状态:避免了多个数字互相打架。
- 按餐次组织明细:用户读起来更符合生活习惯。
- 把明细视图和分析视图拆开:减少信息拥挤,提高可读性。
这三点叠在一起,才让它从“食物列表”升级成“健康看板”。
如果继续做成真实项目,我会优先扩展哪些能力
这个示例已经具备不错的教学价值,但如果继续深化,我会优先补这些:
- 支持新增、编辑、删除饮食记录,而不是只有静态数据。
- 给每条食物补日期字段,支持按天、按周切换。
- 把
mealType和营养目标抽成配置,方便不同用户自定义。 - 对营养素增加比例计算,例如蛋白质供能占比、碳水占比。
- 接健康数据源时,把运动消耗改成真实同步结果,而不是示例常量。
读这个案例时,最该带走的工程思路
不是记住 reduce() 怎么写,而是记住下面这句话:
统计型页面的核心,不在于组件多少,而在于你能不能把一份原始数据稳定地推导成多层结果。
卡路里追踪看板只是一个主题壳子,真正有复用价值的是这套派生计算思路。你后面做预算看板、学习数据面板、项目统计页,底层方法其实是一样的。
完整代码
下面保留案例完整代码,方便你直接对照学习、复制和重构。
// 卡路里追踪看板: 卡路里追踪 - 每日摄入/消耗对比、营养环形图、饮食记录
interface FoodEntry {
id: number
name: string
calories: number
protein: number
carbs: number
fat: number
time: string
mealType: string // 'breakfast' | 'lunch' | 'dinner' | 'snack'
quantity: number
unit: string
}
interface MealSummary {
type: string
label: string
icon: string
color: string
entries: FoodEntry[]
}
interface ItemData_8jx9 { label: string; value: number; goal: number; color: string }
interface ItemParams { label: string; value: number; goal: number; unit: string; color: string }
@Entry
@Component
struct CalorieTrackerDashboard {
@State foodEntries: FoodEntry[] = [
{ id: 1, name: '牛奶', calories: 130, protein: 6.8, carbs: 9.6, fat: 7.2, time: '07:30', mealType: 'breakfast', quantity: 250, unit: 'ml' },
{ id: 2, name: '全麦面包', calories: 180, protein: 8, carbs: 32, fat: 2.5, time: '07:35', mealType: 'breakfast', quantity: 2, unit: '片' },
{ id: 3, name: '水煮蛋', calories: 72, protein: 6.3, carbs: 0.6, fat: 4.8, time: '07:40', mealType: 'breakfast', quantity: 1, unit: '个' },
{ id: 4, name: '米饭', calories: 232, protein: 4, carbs: 50, fat: 0.5, time: '12:10', mealType: 'lunch', quantity: 150, unit: 'g' },
{ id: 5, name: '红烧肉', calories: 395, protein: 18, carbs: 12, fat: 30, time: '12:15', mealType: 'lunch', quantity: 100, unit: 'g' },
{ id: 6, name: '炒青菜', calories: 68, protein: 2.5, carbs: 8, fat: 3.5, time: '12:20', mealType: 'lunch', quantity: 200, unit: 'g' },
{ id: 7, name: '苹果', calories: 72, protein: 0.3, carbs: 19, fat: 0.2, time: '15:00', mealType: 'snack', quantity: 1, unit: '个' },
{ id: 8, name: '坚果', calories: 160, protein: 5, carbs: 6, fat: 14, time: '15:05', mealType: 'snack', quantity: 30, unit: 'g' },
{ id: 9, name: '烤鸡胸', calories: 165, protein: 31, carbs: 0, fat: 3.6, time: '18:30', mealType: 'dinner', quantity: 150, unit: 'g' },
{ id: 10, name: '沙拉', calories: 95, protein: 3, carbs: 15, fat: 3, time: '18:35', mealType: 'dinner', quantity: 200, unit: 'g' },
]
@State activeTab: number = 0
@State dailyCalorieGoal: number = 2000
@State exerciseCalories: number = 320
private mealTypes: MealSummary[] = [
{ type: 'breakfast', label: '早餐', icon: '🌅', color: '#FF9500', entries: [] },
{ type: 'lunch', label: '午餐', icon: '☀️', color: '#34C759', entries: [] },
{ type: 'snack', label: '加餐', icon: '🍎', color: '#FF6B35', entries: [] },
{ type: 'dinner', label: '晚餐', icon: '🌙', color: '#5856D6', entries: [] },
]
getTotalCalories(): number {
return this.foodEntries.reduce((sum: number, e: FoodEntry) => sum + e.calories, 0)
}
getTotalByNutrient(key: string): number {
return this.foodEntries.reduce((sum: number, e: FoodEntry) => {
if (key === 'protein') return sum + e.protein
if (key === 'carbs') return sum + e.carbs
if (key === 'fat') return sum + e.fat
return sum
}, 0)
}
getMealCalories(mealType: string): number {
return this.foodEntries
.filter((e: FoodEntry) => e.mealType === mealType)
.reduce((sum: number, e: FoodEntry) => sum + e.calories, 0)
}
getMealEntries(mealType: string): FoodEntry[] {
return this.foodEntries.filter((e: FoodEntry) => e.mealType === mealType)
}
getRemainingCalories(): number {
return this.dailyCalorieGoal - this.getTotalCalories() + this.exerciseCalories
}
getProgressPercent(): number {
return Math.min(100, Math.round(this.getTotalCalories() / this.dailyCalorieGoal * 100))
}
build() {
Column() {
// 顶部导航
Row() {
Text('今日饮食').fontSize(20).fontWeight(FontWeight.Bold).fontColor('#1A1A1A')
Blank()
Text('2026-06-06').fontSize(12).fontColor('#888888')
}
.padding({ left: 20, right: 20, top: 20, bottom: 12 })
// 卡路里环形摘要
Row() {
// 环形进度(用Stack模拟)
Stack() {
Progress({ value: this.getProgressPercent(), total: 100, type: ProgressType.Ring })
.width(100).height(100)
.style({ strokeWidth: 10 })
.color('#FF9500')
Column() {
Text(String(this.getTotalCalories())).fontSize(22).fontWeight(FontWeight.Bold).fontColor('#1A1A1A')
Text('千卡').fontSize(11).fontColor('#888888')
}
}
.width(110)
// 数据卡片
Column({ space: 8 }) {
Row({ space: 16 }) {
Column() {
Text(String(this.dailyCalorieGoal)).fontSize(16).fontWeight(FontWeight.Bold).fontColor('#007AFF')
Text('目标').fontSize(11).fontColor('#888888')
}
Column() {
Text(String(this.exerciseCalories)).fontSize(16).fontWeight(FontWeight.Bold).fontColor('#34C759')
Text('运动消耗').fontSize(11).fontColor('#888888')
}
Column() {
Text(String(Math.max(0, this.getRemainingCalories())))
.fontSize(16).fontWeight(FontWeight.Bold)
.fontColor(this.getRemainingCalories() < 0 ? '#FF3B30' : '#FF9500')
Text('剩余').fontSize(11).fontColor('#888888')
}
}
// 三大营养素
Row({ space: 8 }) {
ForEach([
{ label: '蛋白质', value: Math.round(this.getTotalByNutrient('protein')), goal: 75, color: '#007AFF' },
{ label: '碳水', value: Math.round(this.getTotalByNutrient('carbs')), goal: 250, color: '#FF9500' },
{ label: '脂肪', value: Math.round(this.getTotalByNutrient('fat')), goal: 65, color: '#FF3B30' },
], (item:ItemData_8jx9) => {
Column() {
Text(`${item.value}g`).fontSize(13).fontWeight(FontWeight.Bold).fontColor(item.color)
Progress({ value: item.value, total: item.goal, type: ProgressType.Linear })
.width(60).height(4).color(item.color).backgroundColor('#E0E0E0')
Text(item.label).fontSize(10).fontColor('#AAAAAA')
}
})
}
}
.layoutWeight(1)
.margin({ left: 16 })
.alignItems(HorizontalAlign.Start)
}
.width('100%')
.padding(16)
.backgroundColor('#FFFFFF')
.borderRadius(16)
.margin({ left: 20, right: 20, bottom: 16 })
.shadow({ radius: 6, color: '#00000010', offsetX: 0, offsetY: 3 })
// Tab切换
Row() {
ForEach(['饮食记录', '营养分析'], (tab: string, idx: number) => {
Text(tab)
.fontSize(14)
.fontWeight(this.activeTab === idx ? FontWeight.Bold : FontWeight.Normal)
.fontColor(this.activeTab === idx ? '#FF9500' : '#888888')
.layoutWeight(1)
.textAlign(TextAlign.Center)
.padding({ bottom: 10 })
.border({
width: { bottom: this.activeTab === idx ? 2 : 0 },
color: { bottom: '#FF9500' }
})
.onClick(() => { this.activeTab = idx })
})
}
.width('100%')
.padding({ left: 20, right: 20 })
.backgroundColor('#FFFFFF')
.margin({ bottom: 12 })
if (this.activeTab === 0) {
// 饮食记录
Scroll() {
Column({ space: 12 }) {
ForEach(this.mealTypes, (meal: MealSummary) => {
Column() {
Row() {
Text(meal.icon).fontSize(18)
Text(meal.label).fontSize(15).fontWeight(FontWeight.Bold).fontColor('#1A1A1A').margin({ left: 8 })
Blank()
Text(`${this.getMealCalories(meal.type)} 千卡`)
.fontSize(12).fontColor(meal.color)
}
.width('100%')
.padding({ bottom: 8 })
if (this.getMealEntries(meal.type).length === 0) {
Text('+ 添加食物')
.fontSize(13).fontColor('#CCCCCC')
.width('100%').textAlign(TextAlign.Center)
.padding(12)
.border({ width: 1, color: '#EEEEEE', style: BorderStyle.Dashed })
.borderRadius(8)
} else {
ForEach(this.getMealEntries(meal.type), (entry: FoodEntry) => {
Row() {
Column() {
Text(entry.name).fontSize(13).fontColor('#333333')
Text(`${entry.quantity}${entry.unit} · ${entry.time}`)
.fontSize(11).fontColor('#AAAAAA').margin({ top: 2 })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Text(`${entry.calories}千卡`).fontSize(13).fontColor(meal.color)
}
.width('100%')
.padding({ top: 6, bottom: 6 })
.border({ width: { top: 0.5 }, color: { top: '#F5F5F5' } })
})
}
}
.width('100%')
.padding(14)
.backgroundColor('#FFFFFF')
.borderRadius(14)
})
}
.padding({ left: 20, right: 20, bottom: 20 })
}
.layoutWeight(1)
.scrollBar(BarState.Off)
} else {
// 营养分析
Scroll() {
Column({ space: 12 }) {
// 餐次分布
Column({ space: 8 }) {
Text('餐次热量分布').fontSize(14).fontWeight(FontWeight.Bold).fontColor('#1A1A1A').alignSelf(ItemAlign.Start)
ForEach(this.mealTypes, (meal: MealSummary) => {
Row() {
Text(meal.icon).fontSize(16)
Text(meal.label).fontSize(13).fontColor('#555555').width(40).margin({ left: 6 })
Progress({
value: this.getMealCalories(meal.type),
total: Math.max(1, this.getTotalCalories()),
type: ProgressType.Linear
})
.layoutWeight(1)
.height(8)
.color(meal.color)
.backgroundColor('#F0F0F0')
.borderRadius(4)
.margin({ left: 8, right: 8 })
Text(`${this.getMealCalories(meal.type)}`).fontSize(12).fontColor('#888888').width(55).textAlign(TextAlign.End)
}
.width('100%')
})
}
.width('100%')
.padding(14)
.backgroundColor('#FFFFFF')
.borderRadius(14)
// 营养素详情
Column({ space: 10 }) {
Text('营养素分析').fontSize(14).fontWeight(FontWeight.Bold).fontColor('#1A1A1A').alignSelf(ItemAlign.Start)
ForEach([
{ label: '蛋白质', value: this.getTotalByNutrient('protein'), goal: 75, unit: 'g', color: '#007AFF' },
{ label: '碳水化合物', value: this.getTotalByNutrient('carbs'), goal: 250, unit: 'g', color: '#FF9500' },
{ label: '脂肪', value: this.getTotalByNutrient('fat'), goal: 65, unit: 'g', color: '#FF3B30' },
], (item: ItemParams) => {
Column({ space: 4 }) {
Row() {
Text(item.label).fontSize(13).fontColor('#555555')
Blank()
Text(`${Math.round(item.value)}${item.unit} / ${item.goal}${item.unit}`)
.fontSize(12).fontColor('#888888')
}
Progress({ value: item.value, total: item.goal, type: ProgressType.Linear })
.width('100%').height(8).color(item.color).backgroundColor('#F0F0F0').borderRadius(4)
}
})
}
.width('100%')
.padding(14)
.backgroundColor('#FFFFFF')
.borderRadius(14)
}
.padding({ left: 20, right: 20, bottom: 20 })
}
.layoutWeight(1)
.scrollBar(BarState.Off)
}
}
.width('100%').height('100%')
.backgroundColor('#F8F8F8')
}
}
最后总结
这篇案例最值得学的,是它没有把统计值到处乱存,而是老老实实从 foodEntries 推导出总热量、餐次小计和营养素结果。对健康类页面来说,这种“单一数据源 + 多层派生结果”的结构,比任何单独的界面技巧都更重要。
更多推荐

所有评论(0)