前言

健康类页面最常见的问题,不是界面不够花,而是数据虽然很多,用户却很难一眼看明白。吃了多少、还剩多少、哪一餐超了、蛋白质够不够,这些问题如果只是把原始记录堆在列表里,页面就只能算“记账”,不能算“分析”。

这个案例的价值,在于它已经不只是展示食物清单,而是开始把原始饮食记录往“结果视图”上做转换。也就是说,它在练的不是单纯布局,而是 如何用 HarmonyOS7 的状态和派生函数,把饮食数据组织成一个真正能读、能分析、能比较的健康看板

适用场景:减脂打卡、健身饮食记录、运动营养管理、健康日报
本篇重点:聚合计算、营养素汇总、列表与分析双视图联动
阅读建议:边看边思考“哪些值应该存,哪些值应该算”

这类页面为什么难写

A hand-drawn doodle illustration on pure white pap

卡路里页面看上去像信息展示页,实际很依赖数据关系。

因为用户关心的从来不是一条条食物本身,而是这些记录最后汇总出了什么结论:

  • 今天总共摄入了多少热量。
  • 运动消耗抵掉以后,还剩多少空间。
  • 早餐、午餐、晚餐、加餐分别占了多少。
  • 三大营养素有没有偏科。

所以这类页面最核心的能力,是把原始记录持续“加工”为不同层级的摘要结果。这个案例正好把这件事演示得比较完整。

先从用户使用路径看这页在解决什么问题

建议你先把页面按下面顺序体验一遍:

  1. 打开页面,第一眼先看环形卡路里摘要,确认总摄入、目标值、运动消耗、剩余值是否清楚。
  2. 看三大营养素的进度条,判断页面有没有把“热量”之外的信息补全。
  3. 点击 饮食记录营养分析 两个 Tab,感受同一批数据是怎么被切成两种阅读方式的。
  4. 在饮食记录里观察每一餐的卡路里汇总和具体食物明细。
  5. 切到分析页,看餐次热量分布和营养素详情是否与前面的汇总保持一致。

这一轮操作下来,你会发现它的设计重点非常明确:同一份 foodEntries 数据,既要服务明细展示,也要服务统计分析。

一切计算的起点,都是这份原始饮食记录

A hand-drawn doodle illustration on pure white pap

先看状态数据:

@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 推导出总热量、餐次小计和营养素结果。对健康类页面来说,这种“单一数据源 + 多层派生结果”的结构,比任何单独的界面技巧都更重要。

Logo

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

更多推荐