前言

学了鸿蒙一段时间,做了掷骰子、天气App、音乐播放器、电影信息App。这些项目让我熟悉了ArkUI基础语法、单页面开发、多页面路由和数据持久化。

但所有项目都有一个共同点:功能单一

我想要做一个功能更丰富的App,覆盖多个使用场景。想来想去,健身类App是最佳选择——它天然包含训练记录、饮食追踪、数据统计、动作库等多种功能模块,而且每个模块都可以深入实现。

于是,FitProApp诞生了。

这是一个功能齐全的健身助手App,包含以下模块:

模块 功能 核心技术
🏠 首页 步数追踪、饮水记录、饮食概览、训练量柱状图 @State、进度条、ForEach
📊 进度 身体数据统计、训练记录管理、训练建议 getter计算属性、@StorageLink
📚 训练 动作库(11个肌群50+动作)、快速开始训练 二维数组、条件渲染、router
🍽️ 饮食 饮食日记、热量追踪、营养素分布、饮水时间线 ForEach嵌套、进度条
💪 记录 单个动作的组数/次数/重量记录、快捷添加 router传参、数组操作、JSON持久化

最终效果:

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


一、项目概述

1.1 项目定义

FitProApp是一款全能健身助手应用,基于HarmonyOS Next平台、ArkUI声明式开发范式实现。采用双页面架构,首页通过条件渲染实现4个Tab切换,详情页用于单个动作的训练记录。

1.2 功能全景图

FitProApp
├── 🏠 首页 (Tab 0)
│   ├── 🚶 步数追踪(目标10000步,进度条)
│   ├── 🔥 摄入热量(自动计算饮食总量)
│   ├── 💧 饮水量(小杯250ml / 中杯500ml)
│   ├── ⏱️ 运动时长(目标60分钟)
│   ├── 😴 睡眠时间 / ⚖️ 体重 / 🏋️ 训练次数
│   ├── 📊 本周训练量柱状图(7天)
│   ├── 🍽️ 今日饮食列表
│   └── ➕ 记录新训练(跳转到训练Tab)
│
├── 📊 进度 (Tab 1)
│   ├── 身体数据卡片(体重/总训练/总容量/消耗/总组数/平均每组)
│   ├── 📝 训练记录列表(可删除)
│   └── 🏆 训练建议(6条专业建议)
│
├── 📚 训练 (Tab 2)
│   ├── 11个肌群标签(全部/胸/腿/背/有氧/肩/二头/三头/核心/臀/全身/拉伸)
│   ├── 50+个动作列表(按肌群分类)
│   ├── 全部视图 → 按肌群分组展示(每组显示前2个动作)
│   └── 单肌群视图 → 完整动作列表 → 点击开始记录
│
├── 🍽️ 饮食 (Tab 3)
│   ├── 今日摄入进度(目标2200千卡)
│   ├── 营养素分布(蛋白质/碳水/脂肪)
│   ├── 今日餐食(按早/午/晚/加餐分组)
│   └── 💧 饮水记录时间线
│
├── ExerciseLog(训练记录页,独立页面)
│   ├── 动作名称 + 肌群信息
│   ├── 训练时长输入
│   ├── 已记录组数列表(可删除)
│   ├── 容量实时计算
│   ├── 快捷添加组(4个预设方案)
│   ├── 自定义添加组(次数+重量输入)
│   ├── 训练备注
│   └── 保存训练 → @StorageLink持久化
│
└── 底部导航栏(首页/进度/训练/饮食)

1.3 技术栈

技术点 说明 使用场景
多页面路由 router.pushUrl / router.back 训练Tab → ExerciseLog
参数传递 router.getParams() 传递动作名称
数据持久化 @StorageLink + JSON.stringify/parse 训练记录保存与恢复
getter计算属性 7个getter 总容量、总热量、平均每组、周训练量等
条件渲染 if-else if (4个Tab) 首页内容切换
接口定义 4个interface WorkoutSet、ExerciseLog、WaterRecord、MealRecord
@Builder 3个组件内构建函数 statCard、tipRow、showMuscleGroup
二维数组 动作数据、肌群标签 EXERCISES、MUSCLES、MEAL_TYPES
进度条 Column百分比宽度 步数/饮水/热量进度

二、项目创建与配置

2.1 创建项目

  1. 打开 DevEco Studio
  2. Create Project → 选择 Empty Ability 模板
  3. 填写项目信息:
配置项
Project name FitProApp
Bundle name com.example.fitproapp
Save location E:\HMproject\Project\FitProApp
Language ArkTS
Model Stage
  1. 点击 Finish

2.2 注册路由

entry/src/main/resources/base/profile/main_pages.json

{
  "src": [
    "pages/Index",
    "pages/ExerciseLog"
  ]
}

2.3 项目结构

FitProApp/
├── AppScope/
│   └── app.json5
├── entry/src/main/ets/
│   ├── entryability/
│   │   └── EntryAbility.ets
│   ├── entrybackupability/
│   │   └── EntryBackupAbility.ets
│   └── pages/
│       ├── Index.ets              ← 首页(约250行,4个Tab)
│       └── ExerciseLog.ets        ← 训练记录页(约120行)
├── entry/src/main/resources/
│   └── base/profile/
│       └── main_pages.json
└── entry/module.json5

三、数据模型设计

3.1 接口定义——4个数据结构

本项目定义了4个接口,覆盖训练记录、组数、饮水和饮食数据:

WorkoutSet——训练组

interface WorkoutSet {
  setNum: number    // 组号(第1组、第2组...)
  reps: number      // 次数
  weight: number    // 重量(kg)
}

ExerciseLog——训练记录

interface ExerciseLog {
  id: number        // 记录唯一ID
  name: string      // 动作名称(如"杠铃卧推")
  muscle: string    // 所属肌群(如"胸")
  sets: WorkoutSet[] // 训练组列表
  date: string      // 训练日期
  note: string      // 训练备注
  duration: number   // 训练时长(分钟)
}

WaterRecord——饮水记录

interface WaterRecord {
  time: string      // 饮水时间(如"08:30")
  ml: number        // 饮水量(ml)
}

MealRecord——饮食记录

interface MealRecord {
  name: string      // 食物名称
  cal: number       // 热量(千卡)
  time: string      // 用餐时间
  type: string      // 餐类(早餐/午餐/晚餐/加餐)
}

3.2 动作库数据——11个肌群、50+个动作

private readonly EXERCISES: string[][] = [
  ['胸', '杠铃卧推'], ['胸', '哑铃飞鸟'], ['胸', '上斜卧推'], ['胸', '俯卧撑'], ['胸', '绳索夹胸'],
  ['腿', '杠铃深蹲'], ['腿', '腿举'], ['腿', '弓箭步'], ['腿', '腿弯举'], ['腿', '罗马尼亚硬拉'],
  ['背', '引体向上'], ['背', '杠铃划船'], ['背', '高位下拉'], ['背', '坐姿划船'], ['背', '哑铃单臂划船'],
  ['肩', '哑铃推举'], ['肩', '侧平举'], ['肩', '前平举'], ['肩', '面拉'], ['肩', '杠铃推举'],
  ['二头', '杠铃弯举'], ['二头', '哑铃弯举'], ['二头', '锤式弯举'], ['二头', '集中弯举'],
  ['三头', '窄距卧推'], ['三头', '绳索下压'], ['三头', '臂屈伸'], ['三头', '法式弯举'],
  ['核心', '卷腹'], ['核心', '平板支撑'], ['核心', '举腿'], ['核心', '俄罗斯转体'], ['核心', '自行车卷腹'],
  ['臀', '臀桥'], ['臀', '深蹲'], ['臀', '髋推'], ['臀', '跪姿后踢'],
  ['全身', '波比跳'], ['全身', '跳绳'], ['全身', '开合跳'], ['全身', 'burpee'],
  ['有氧', '跑步'], ['有氧', '骑行'], ['有氧', '游泳'], ['有氧', '椭圆机'], ['有氧', '划船机'],
  ['拉伸', '全身拉伸'], ['拉伸', '腿部拉伸'], ['拉伸', '背部拉伸'], ['拉伸', '肩颈拉伸'],
]

用二维数组存储,每个元素 [肌群名, 动作名]。共50+个动作,覆盖11个肌群。

肌群分布:

肌群 动作数量 代表动作
💪 胸 5 杠铃卧推、哑铃飞鸟
🦵 腿 5 杠铃深蹲、腿举
🔙 背 5 引体向上、杠铃划船
🔄 肩 5 哑铃推举、侧平举
💪 二头 4 杠铃弯举、锤式弯举
💪 三头 4 窄距卧推、绳索下压
🧠 核心 5 卷腹、平板支撑
🦶 臀 4 臀桥、髋推
⚡ 全身 4 波比跳、跳绳
🏃 有氧 5 跑步、骑行
📏 拉伸 4 全身拉伸、肩颈拉伸

3.3 其他数据

肌群标签:

private readonly MUSCLES: string[][] = [
  ['🏋️', '全部'], ['💪', '胸'], ['🦵', '腿'], ['🔙', '背'], ['🏃', '有氧'],
  ['🔄', '肩'], ['💪', '二头'], ['💪', '三头'], ['🧠', '核心'], ['🦶', '臀'],
  ['📏', '拉伸'], ['⚡', '全身'],
]

餐类标签:

private readonly MEAL_TYPES: string[][] = [
  ['🌅', '早餐'], ['🌞', '午餐'], ['🌆', '晚餐'], ['🍪', '加餐'],
]

动作-肌群映射(详情页):

private readonly EXERCISE_MUSCLE: string[][] = [
  ['杠铃卧推', '胸'], ['哑铃飞鸟', '胸'], ...
  // 同Index中的EXERCISES数据,用于根据动作名反查肌群
]

四、状态管理与数据持久化

4.1 状态变量一览

Index页面有大量状态变量,管理整个App的数据:

变量 装饰器 类型 初始值 作用
tab @State number 0 当前Tab(0-3)
steps @State number 8432 今日步数
stepGoal @State number 10000 步数目标
water @State number 1400 饮水量(ml)
waterGoal @State number 2000 饮水目标(ml)
calories @State number 1650 热量摄入(千卡)
calGoal @State number 2200 热量目标
activeMin @State number 45 运动时长(分钟)
activeGoal @State number 60 运动目标
sleep @State number 7.5 睡眠时间(小时)
bodyWeight @State number 72.5 体重(kg)
selectedMuscle @State string ‘全部’ 动作库当前选中肌群
waterHistory @State WaterRecord[] [] 饮水时间线
mealList @State MealRecord[] [] 饮食列表
savedLogs @StorageLink string ‘’ 训练记录持久化

4.2 @StorageLink + JSON——训练记录持久化

这是本项目最核心的数据持久化方案。

之前的电影App中,@StorageLink存的是简单的逗号分隔ID字符串。但训练记录是复杂的数据结构(包含嵌套数组),不能简单用字符串拼接。

解决方案:JSON序列化。

@StorageLink('fit_logs') savedLogs: string = ''
private logs: ExerciseLog[] = []
  • savedLogs 是@StorageLink绑定的字符串,存的是JSON格式的训练记录
  • logs 是普通的数组,用于日常操作
  • 两者通过 JSON.stringifyJSON.parse 互相转换

保存训练记录:

private saveLogs(): void {
  this.savedLogs = JSON.stringify(this.logs)
}

logs 数组序列化为JSON字符串 → 赋值给 savedLogs → @StorageLink自动持久化。

加载训练记录:

aboutToAppear(): void {
  if (this.savedLogs && this.savedLogs !== '') {
    this.logs = JSON.parse(this.savedLogs)
  }
  // 初始化示例数据...
}

savedLogs 反序列化为 logs 数组 → 可以正常操作了。

JSON序列化示例:

logs 数组:
[
  {
    id: 1,
    name: '杠铃卧推',
    muscle: '胸',
    sets: [{ setNum: 1, reps: 12, weight: 20 }, { setNum: 2, reps: 10, weight: 30 }],
    date: '今天',
    note: '感觉不错',
    duration: 30
  }
]
      ↓ JSON.stringify
savedLogs 字符串:
'[{"id":1,"name":"杠铃卧推","muscle":"胸","sets":[{"setNum":1,"reps":12,"weight":20},{"setNum":2,"reps":10,"weight":30}],"date":"今天","note":"感觉不错","duration":30}]'
      ↓ 赋值给 @StorageLink → 自动保存到磁盘

4.3 初始数据

aboutToAppear(): void {
  // 加载持久化数据
  if (this.savedLogs && this.savedLogs !== '') {
    this.logs = JSON.parse(this.savedLogs)
  }
  
  // 示例饮水记录
  this.waterHistory = [
    { time: '08:30', ml: 300 },
    { time: '10:00', ml: 250 },
    { time: '12:00', ml: 300 },
    { time: '14:30', ml: 250 },
  ]
  
  // 示例饮食记录
  this.mealList = [
    { name: '全麦面包+鸡蛋+牛奶', cal: 420, time: '08:00', type: '早餐' },
    { name: '鸡胸肉沙拉+米饭', cal: 580, time: '12:30', type: '午餐' },
    { name: '苹果+坚果', cal: 200, time: '15:00', type: '加餐' },
  ]
}

五、计算属性——7个getter

本项目使用了7个getter计算属性,是之前所有项目中最多的。这体现了FitProApp数据计算的复杂性。

5.1 getter一览

# 名称 返回类型 依赖数据 用途
1 filteredExercises string[][] selectedMuscle, EXERCISES 动作库按肌群过滤
2 totalVolume number logs 总训练容量(kg)
3 totalCal number mealList 总摄入热量
4 avgVolume string totalVolume, totalSets 平均每组容量
5 weekVolumes number[] totalVolume 本周7天训练量
6 maxVol number weekVolumes 周训练量最大值(柱状图用)

5.2 逐个解析

filteredExercises——动作库过滤

private get filteredExercises(): string[][] {
  if (this.selectedMuscle === '全部') return this.EXERCISES
  const r: string[][] = []
  for (let i = 0; i < this.EXERCISES.length; i++) {
    if (this.EXERCISES[i][0] === this.selectedMuscle) r.push(this.EXERCISES[i])
  }
  return r
}

当用户在动作库中选择某个肌群(如"胸")时,只显示该肌群的动作。选择"全部"时显示所有50+个动作。

totalVolume——总训练容量

private get totalVolume(): number {
  let v = 0
  for (let i = 0; i < this.logs.length; i++) {
    for (let j = 0; j < this.logs[i].sets.length; j++) {
      v += this.logs[i].sets[j].reps * this.logs[i].sets[j].weight
    }
  }
  return v
}

容量 = 次数 × 重量,遍历所有训练记录的所有组数累加。

例如:杠铃卧推 12×20kg + 10×30kg = 240 + 300 = 540kg。

totalCal——总摄入热量

private get totalCal(): number {
  let c = 0
  for (let i = 0; i < this.mealList.length; i++) {
    c += this.mealList[i].cal
  }
  return c
}

累加所有饮食记录的热量。

avgVolume——平均每组容量

private get avgVolume(): string {
  const s = this.totalSets()
  return String(Math.floor(this.totalVolume / (s > 0 ? s : 1)))
}

总容量 ÷ 总组数。除以 (s > 0 ? s : 1) 防止除零错误。

weekVolumes——本周7天训练量

private get weekVolumes(): number[] {
  return [1200, 1800, 0, 2100, 950, 2200, this.totalVolume]
}

前6天是模拟的历史数据,第7天是当天的实时总容量。用于绘制柱状图。

maxVol——柱状图最大值

private get maxVol(): number {
  let m = 0
  const v = this.weekVolumes
  for (let i = 0; i < v.length; i++) {
    if (v[i] > m) m = v[i]
  }
  return m || 1
}

取7天中的最大值,用于计算柱状图每根柱子的高度比例。|| 1 防止所有天都是0时除零。

5.3 普通计算方法

除了getter,还有一些辅助方法:

private totalSets(): number {
  let s = 0
  for (let i = 0; i < this.logs.length; i++) {
    s += this.logs[i].sets.length
  }
  return s
}

private totalSetsToday(): number {
  let s = 0
  for (let i = 0; i < this.logs.length; i++) {
    if (this.logs[i].date === '今天') s += this.logs[i].sets.length
  }
  return s
}

private exercisesByMuscle(m: string): string[][] {
  const r: string[][] = []
  for (let i = 0; i < this.EXERCISES.length; i++) {
    if (this.EXERCISES[i][0] === m) r.push(this.EXERCISES[i])
  }
  return r
}

private mealIcon(type: string): string {
  if (type === '早餐') return '🌅'
  if (type === '午餐') return '🌞'
  if (type === '晚餐') return '🌆'
  return '🍪'
}

private getToday(): string {
  const d = new Date()
  const ds: string[] = ['日', '一', '二', '三', '四', '五', '六']
  return String(d.getMonth() + 1) + '月' + String(d.getDate()) + '日 周' + ds[d.getDay()]
}

getter vs 方法的区别:

维度 getter 方法
调用方式 this.totalVolume this.totalSets()
自动追踪 ✅ UI响应式依赖 ❌ 不被UI追踪
适用场景 UI直接引用的数据 工具计算、事件处理

六、首页UI实现(4个Tab)

6.1 整体结构

Scroll
└── Column (#000000)
    ├── Row (标题栏: "💪 FitPro" + 日期)
    ├── [Tab 0] 首页
    │   ├── 步数卡片(进度条+按钮)
    │   ├── 3列统计卡片(摄入/饮水/运动)
    │   ├── 3列信息卡片(睡眠/体重/训练)
    │   ├── 饮水按钮(小杯/中杯/重置)
    │   ├── 今日饮食列表
    │   ├── 本周训练量柱状图
    │   └── "记录新训练"按钮
    ├── [Tab 1] 进度
    │   ├── 6个数据统计卡片
    │   ├── 训练记录列表
    │   └── 训练建议
    ├── [Tab 2] 训练
    │   ├── 肌群标签
    │   ├── 动作列表(全部/按肌群)
    │   └── 点击动作 → 跳转记录页
    ├── [Tab 3] 饮食
    │   ├── 摄入进度(目标2200千卡)
    │   ├── 营养素分布(蛋白质/碳水/脂肪)
    │   ├── 按餐类分组的饮食列表
    │   └── 饮水记录时间线
    └── Row (底部导航栏)

6.2 Tab 0——首页

步数追踪卡片:

Column() {
  // 标题行
  Row() {
    Text('🚶 步数').fontWeight(FontWeight.Bold)
    Blank()
    Text(String(this.steps) + ' / ' + String(this.stepGoal))
  }
  // 进度条
  Row() {
    Column()
      .width(String(Math.floor(this.steps / this.stepGoal * 100)) + '%')
      .height(8).backgroundColor('#34C759').borderRadius(4)
  }.layoutWeight(1).height(8).backgroundColor('#2C2C2E').borderRadius(4)
  
  // 操作按钮
  Row() {
    Button('🚶 +1000').onClick(() => { this.steps = Math.min(this.steps + 1000, this.stepGoal) })
    Button('🔄 重置').onClick(() => { this.steps = 0 })
  }
}

进度条实现原理:

外层Column作为背景轨道(灰色 #2C2C2E),内层Column作为进度填充(绿色 #34C759)。宽度用百分比字符串控制:

.width(String(Math.floor(this.steps / this.stepGoal * 100)) + '%')

比如步数8432、目标10000,计算得84%,进度条填充84%。

3列统计卡片(@Builder封装):

Row() {
  this.statCard('🔥', '摄入', String(this.totalCal) + '/2200', ..., '#FF9F0A')
  this.statCard('💧', '饮水', String(this.water) + '/2000ml', ..., '#5AC8FA')
  this.statCard('⏱️', '运动', String(this.activeMin) + '/60分', ..., '#FF3B30')
}

statCard是一个@Builder构建函数:

@Builder statCard(icon: string, label: string, value: string, percent: string, color: string) {
  Column() {
    Text(icon).fontSize(20)
    Text(label).fontSize(12).fontColor('#8E8E93')
    Text(value).fontSize(13).fontWeight(FontWeight.Bold)
    Row() {
      Column().width(percent).height(4).backgroundColor(color)
    }.height(4).backgroundColor('#2C2C2E').borderRadius(2)
  }.layoutWeight(1).backgroundColor('#1C1C1E').borderRadius(12)
}

每个卡片显示:图标 + 标签 + 数值 + 迷你进度条。

本周训练量柱状图:

Row() {
  ForEach(this.weekVolumes, (v: number, i: number) => {
    Column() {
      Column()
        .width('100%')
        .height(v > 0 ? String(Math.floor(v / this.maxVol * 100)) + '%' : '4%')
        .backgroundColor(v > 0 ? '#FF9F0A' : '#2C2C2E')
        .borderRadius(4)
      Text(this.days[i]).fontSize(11).fontColor('#555555')
    }
    .layoutWeight(1).height(100).justifyContent(FlexAlign.End)
  })
}

每根柱子的高度 = 当天训练量 / 最大训练量 × 100%。使用 FlexAlign.End 让柱子从底部向上生长。

6.3 Tab 1——进度

6个数据统计卡片:

卡片 数值 颜色 计算方式
体重 bodyWeight #FF9F0A 直接取值
总训练 logs.length #34C759 数组长度
总容量 totalVolume #5AC8FA getter
消耗千卡 totalVolume × 0.042 #FF3B30 简化公式
总组数 totalSets() #AF52DE 方法计算
平均每组 avgVolume #FF9F0A getter

训练记录列表:

ForEach(this.logs, (log: ExerciseLog) => {
  ListItem() {
    Column() {
      Row() {
        Text('💪').fontSize(20)
        Column() {
          Text(log.name).fontWeight(FontWeight.Bold)
          Text(String(log.sets.length) + '组 · ' + log.date)
        }
        Text('✕').fontColor('#FF3B30').onClick(() => { this.deleteLog(log.id) })
      }
      Row() {
        ForEach(log.sets, (s: WorkoutSet) => {
          Text(String(s.reps) + 'x' + String(s.weight) + 'kg')
        })
      }
      if (log.note.length > 0) { Text('📝 ' + log.note) }
    }
  }
})

每条记录显示:动作名 + 组数 + 日期 + 每组详情 + 备注 + 删除按钮。

删除记录:

private deleteLog(id: number): void {
  const r: ExerciseLog[] = []
  for (let i = 0; i < this.logs.length; i++) {
    if (this.logs[i].id !== id) r.push(this.logs[i])
  }
  this.logs = r
  this.saveLogs()
}

过滤掉目标ID的记录 → 更新logs数组 → JSON序列化保存。

训练建议:

this.tipRow('📅', '训练频率', '每周3-5次,每次45-60分钟')
this.tipRow('💪', '训练强度', '每组8-12次,选择最大重量')
this.tipRow('🍗', '营养补充', '训练后30分钟内补充20-30g蛋白质')
this.tipRow('💧', '水分摄入', '每日饮水2000-3000ml')
this.tipRow('😴', '休息恢复', '保证7-8小时高质量睡眠')
this.tipRow('📈', '渐进超负荷', '每周尝试增加重量或次数')

tipRow是@Builder函数,封装"图标+标题+描述"的建议条目。

6.4 Tab 2——训练(动作库)

肌群标签:

Row() {
  ForEach(this.MUSCLES, (m: string[]) => {
    Column() {
      Text(m[0]).fontSize(18)       // emoji图标
      Text(m[1]).fontSize(9)        // 肌群名
    }.onClick(() => { this.selectedMuscle = m[1] })
  })
}

点击标签切换 selectedMuscle,通过 filteredExercises getter 自动过滤动作列表。

两种视图模式:

selectedMuscle 显示模式 说明
“全部” 分组视图 按肌群分组,每组显示前2个动作
具体肌群 列表视图 显示该肌群的所有动作

分组视图(全部模式):

this.showMuscleGroup('💪', '胸')
this.showMuscleGroup('🦵', '腿')
// ... 11个肌群

showMuscleGroup是@Builder函数:

@Builder showMuscleGroup(icon: string, muscle: string) {
  Column() {
    Text(icon + ' ' + muscle).fontWeight(FontWeight.Bold).fontColor('#FF9F0A')
    Row() {
      ForEach(this.exercisesByMuscle(muscle), (ex: string[], i: number) => {
        if (i < 2) {  // 只显示前2个
          Column() {
            Text('💪')
            Text(ex[1]).maxLines(1).width(56)
          }.onClick(() => { this.openLog(ex[1]) })
        }
      })
    }
  }
}

每个肌群只显示前2个动作,节省空间。

列表视图(单肌群模式):

List() {
  ForEach(this.filteredExercises, (ex: string[]) => {
    ListItem() {
      Row() {
        Text('💪')
        Column() {
          Text(ex[1]).fontSize(14)        // 动作名
          Text(ex[0]).fontSize(11)        // 肌群名
        }
        Text('▶').fontColor('#FF9F0A')
      }
      .onClick(() => { this.openLog(ex[1]) })
    }
  })
}

点击动作 → 跳转到ExerciseLog页面记录训练。

6.5 Tab 3——饮食

摄入进度:

Column() {
  Row() {
    Text('今日摄入'); Blank(); Text(String(this.totalCal) + '千卡')
  }
  // 进度条(超过2200变红色)
  Row() {
    Column()
      .width(String(Math.floor(this.totalCal / 2200 * 100)) + '%')
      .backgroundColor(this.totalCal > 2200 ? '#FF3B30' : '#FF9F0A')
  }
  // 营养素分布
  Row() {
    Column() { Text('蛋白质'); Text(String(Math.floor(this.totalCal * 0.3 / 4)) + 'g') }
    Column() { Text('碳水'); Text(String(Math.floor(this.totalCal * 0.4 / 4)) + 'g') }
    Column() { Text('脂肪'); Text(String(Math.floor(this.totalCal * 0.3 / 9)) + 'g') }
  }
}

营养素计算公式(简化版):

  • 蛋白质:总热量 × 30% ÷ 4千卡/g
  • 碳水:总热量 × 40% ÷ 4千卡/g
  • 脂肪:总热量 × 30% ÷ 9千卡/g

按餐类分组:

ForEach(this.MEAL_TYPES, (mt: string[]) => {
  Column() {
    Text(mt[0] + ' ' + mt[1])      // "🌅 早餐"
    ForEach(this.mealList, (m: MealRecord) => {
      if (m.type === mt[1]) {        // 只显示该餐类的食物
        Row() {
          Column() { Text(m.name); Text(m.time) }
          Text(String(m.cal) + '千卡').fontColor('#FF9F0A')
        }
      }
    })
  }
  Divider()
})

双层ForEach嵌套:外层遍历餐类,内层遍历所有食物,按type匹配过滤。

饮水时间线:

Row() {
  ForEach(this.waterHistory, (w: WaterRecord) => {
    Column() {
      Text(w.time).fontSize(11)
      Text(String(w.ml) + 'ml').fontColor('#5AC8FA')
    }
  })
}

横向排列的饮水时间节点。

6.6 底部导航栏

Row() {
  ForEach([['🏠', '首页'], ['📊', '进度'], ['📚', '训练'], ['🍽️', '饮食']], 
    (a: string[], i: number) => {
      Column() {
        Text(a[0]).fontSize(20)
        Text(a[1]).fontColor(i === this.tab ? '#FF9F0A' : '#8E8E93')
      }.layoutWeight(1).onClick(() => { this.tab = i })
    })
}
.height(58).backgroundColor('#1C1C1E')

七、训练记录页(ExerciseLog)

7.1 页面功能

这是从训练Tab点击动作后跳入的独立页面,用于记录单个动作的训练数据:

  • 显示动作名称和所属肌群
  • 输入训练时长
  • 添加训练组(次数+重量)
  • 快捷添加(预设方案)
  • 查看已记录组数
  • 实时计算容量
  • 删除单组
  • 添加训练备注
  • 保存训练(JSON持久化)

7.2 接收参数

interface RouteArgs { name?: string }

aboutToAppear(): void {
  const p = router.getParams() as RouteArgs
  if (p && p.name !== undefined) {
    this.exName = p.name
    // 根据动作名反查肌群
    for (let i = 0; i < this.EXERCISE_MUSCLE.length; i++) {
      if (this.EXERCISE_MUSCLE[i][0] === this.exName) {
        this.muscle = this.EXERCISE_MUSCLE[i][1]
        break
      }
    }
  }
}

首页传递动作名(如"杠铃卧推"),详情页用动作名在EXERCISE_MUSCLE映射表中查找对应肌群。

7.3 状态变量

变量 装饰器 类型 作用
exName @State string 动作名称
muscle @State string 所属肌群
sets @State WorkoutSet[] 已记录的组数
nextSet @State number 下一组的组号
repsInput @State string 次数输入框
weightInput @State string 重量输入框
note @State string 训练备注
duration @State string 训练时长
savedLogs @StorageLink string 训练记录持久化(和首页共享同一个键)

关键点: Index和ExerciseLog中的 @StorageLink('fit_logs') 使用同一个键 'fit_logs',所以两个页面共享同一份持久化数据。在ExerciseLog中保存的训练记录,在Index的"进度"Tab中可以立即看到。

7.4 添加训练组

自定义添加:

private addSet(): void {
  const r = parseInt(this.repsInput)
  const w = parseFloat(this.weightInput)
  if (isNaN(r) || r <= 0) return
  this.sets = this.sets.concat([{
    setNum: this.nextSet, reps: r, weight: isNaN(w) ? 0 : w
  }])
  this.nextSet++
  this.repsInput = ''
  this.weightInput = ''
}
  • parseIntparseFloat 将输入字符串转为数字
  • isNaN(r) || r <= 0 做输入校验
  • this.sets.concat([...]) 创建新数组(触发@State更新)
  • 添加成功后清空输入框

快捷添加:

private quickSet(reps: number, weight: number): void {
  this.sets = this.sets.concat([{
    setNum: this.nextSet, reps: reps, weight: weight
  }])
  this.nextSet++
}

// UI: 4个预设按钮
ForEach([['12x20kg'], ['10x30kg'], ['8x40kg'], ['6x50kg']], (q: string[]) => {
  Button(q[0]).onClick(() => {
    const p = q[0].split('x')
    this.quickSet(parseInt(p[0]), parseInt(p[1].replace('kg', '')))
  })
})

解析字符串"12x20kg" → reps=12, weight=20 → 快速添加一组。

删除单组:

private deleteSet(num: number): void {
  const r: WorkoutSet[] = []
  for (let i = 0; i < this.sets.length; i++) {
    if (this.sets[i].setNum !== num) r.push(this.sets[i])
  }
  this.sets = r
}

7.5 容量计算

private get totalWeight(): number {
  let v = 0
  for (let i = 0; i < this.sets.length; i++) {
    v += this.sets[i].reps * this.sets[i].weight
  }
  return v
}

实时显示已记录的容量(次数×重量之和)。

7.6 保存训练

private saveLog(): void {
  if (this.sets.length === 0) return
  
  // 1. 加载已有记录
  let list: LogData[] = []
  if (this.savedLogs && this.savedLogs !== '') {
    list = JSON.parse(this.savedLogs)
  }
  
  // 2. 生成新ID
  let maxId = 0
  for (let i = 0; i < list.length; i++) {
    if (list[i].id > maxId) maxId = list[i].id
  }
  
  // 3. 创建新记录
  const dur = parseInt(this.duration) || 0
  const newLog: LogData = {
    id: maxId + 1,
    name: this.exName,
    muscle: this.muscle,
    sets: this.sets,
    date: '今天',
    note: this.note,
    duration: dur
  }
  
  // 4. 新记录插入到数组头部(最新的排前面)
  const newList: LogData[] = [newLog]
  for (let i = 0; i < list.length; i++) {
    newList.push(list[i])
  }
  
  // 5. 保存到@StorageLink → 返回上一页
  this.savedLogs = JSON.stringify(newList)
  router.back()
}

保存流程:

点击保存
    ↓
检查是否有组数(0组不允许保存)
    ↓
加载已有记录(JSON.parse)
    ↓
找到最大ID + 1 作为新记录ID
    ↓
构建LogData对象
    ↓
新记录插入数组头部(最新的在前)
    ↓
JSON.stringify → 赋值给savedLogs → 自动持久化
    ↓
router.back() 返回首页

八、@Builder构建函数——UI复用

本项目使用了3个@Builder构建函数,封装了重复的UI结构。

8.1 statCard——统计卡片

@Builder statCard(icon: string, label: string, value: string, percent: string, color: string) {
  Column() {
    Text(icon).fontSize(20)
    Text(label).fontSize(12).fontColor('#8E8E93')
    Text(value).fontSize(13).fontWeight(FontWeight.Bold)
    Row() {
      Column().width(percent).height(4).backgroundColor(color)
    }.height(4).backgroundColor('#2C2C2E').borderRadius(2)
  }
}

被调用3次(摄入/饮水/运动),参数不同即可复用。

8.2 tipRow——建议条目

@Builder tipRow(icon: string, title: string, desc: string) {
  Row() {
    Text(icon).fontSize(20).width(32)
    Column() {
      Text(title).fontWeight(FontWeight.Bold)
      Text(desc).fontSize(12).fontColor('#8E8E93')
    }
  }
}

被调用6次(6条训练建议)。

8.3 showMuscleGroup——肌群动作组

@Builder showMuscleGroup(icon: string, muscle: string) {
  Column() {
    Text(icon + ' ' + muscle).fontWeight(FontWeight.Bold).fontColor('#FF9F0A')
    Row() {
      ForEach(this.exercisesByMuscle(muscle), (ex: string[], i: number) => {
        if (i < 2) {
          Column() {
            Text('💪')
            Text(ex[1]).maxLines(1).width(56)
          }.onClick(() => { this.openLog(ex[1]) })
        }
      })
    }
  }
}

被调用11次(每个肌群一次),在动作库"全部"视图下按肌群分组显示动作。

注意: 这3个@Builder都定义在@Component内部,调用时需要用 this.statCard()this.tipRow()this.showMuscleGroup() 的方式。


九、色彩体系

元素 色值 用途
页面背景 #000000 主背景
卡片/栏背景 #1C1C1E 卡片、标题栏、导航栏
输入框/封面 #2C2C2E 输入框背景、列表封面
主文字 Color.White 标题、数值
辅助文字 #8E8E93 副标题、标签
弱化文字 #555555 时间、"全部"标记
评分/高亮 #FF9F0A 评分、进度条、Tab高亮
成功/步数 #34C759 步数进度、保存按钮
饮水蓝 #5AC8FA 饮水相关
危险/警告 #FF3B30 删除、重置、超标
蓝色链接 #007AFF 中杯饮水按钮
紫色 #AF52DE 总组数卡片

十、完整代码

10.1 Index.ets(首页)

import router from '@ohos.router';

interface WorkoutSet { setNum: number; reps: number; weight: number }
interface ExerciseLog { id: number; name: string; muscle: string; sets: WorkoutSet[]; date: string; note: string; duration: number }
interface WaterRecord { time: string; ml: number }
interface MealRecord { name: string; cal: number; time: string; type: string }

@Entry
@Component
struct Index {
  @State tab: number = 0
  @State steps: number = 8432
  @State stepGoal: number = 10000
  @State water: number = 1400
  @State waterGoal: number = 2000
  @State calories: number = 1650
  @State calGoal: number = 2200
  @State activeMin: number = 45
  @State activeGoal: number = 60
  @State sleep: number = 7.5
  @State bodyWeight: number = 72.5
  @State selectedMuscle: string = '全部'
  @State waterHistory: WaterRecord[] = []
  @State mealList: MealRecord[] = []
  @StorageLink('fit_logs') savedLogs: string = ''

  private logs: ExerciseLog[] = []
  private readonly days: string[] = ['一', '二', '三', '四', '五', '六', '日']
  private readonly MEAL_TYPES: string[][] = [
    ['🌅', '早餐'], ['🌞', '午餐'], ['🌆', '晚餐'], ['🍪', '加餐']
  ]
  private readonly MUSCLES: string[][] = [
    ['🏋️', '全部'], ['💪', '胸'], ['🦵', '腿'], ['🔙', '背'], ['🏃', '有氧'],
    ['🔄', '肩'], ['💪', '二头'], ['💪', '三头'], ['🧠', '核心'], ['🦶', '臀'],
    ['📏', '拉伸'], ['⚡', '全身'],
  ]
  private readonly EXERCISES: string[][] = [
    // 50+个动作数据...
  ]

  aboutToAppear(): void {
    if (this.savedLogs && this.savedLogs !== '') { this.logs = JSON.parse(this.savedLogs) }
    this.waterHistory = [{ time: '08:30', ml: 300 }, { time: '10:00', ml: 250 }, { time: '12:00', ml: 300 }, { time: '14:30', ml: 250 }]
    this.mealList = [
      { name: '全麦面包+鸡蛋+牛奶', cal: 420, time: '08:00', type: '早餐' },
      { name: '鸡胸肉沙拉+米饭', cal: 580, time: '12:30', type: '午餐' },
      { name: '苹果+坚果', cal: 200, time: '15:00', type: '加餐' },
    ]
  }

  private saveLogs(): void { this.savedLogs = JSON.stringify(this.logs) }
  private openLog(name: string): void { router.pushUrl({ url: 'pages/ExerciseLog', params: { name: name } }) }
  private deleteLog(id: number): void { /* 过滤+保存 */ }

  // 7个getter计算属性...
  // build() { ... }
}

10.2 ExerciseLog.ets(训练记录页)

import router from '@ohos.router';

interface WorkoutSet { setNum: number; reps: number; weight: number }
interface LogData { id: number; name: string; muscle: string; sets: WorkoutSet[]; date: string; note: string; duration: number }
interface RouteArgs { name?: string }

@Entry
@Component
struct ExerciseLog {
  @State exName: string = ''
  @State muscle: string = ''
  @State sets: WorkoutSet[] = []
  @State nextSet: number = 1
  @State repsInput: string = ''
  @State weightInput: string = ''
  @State note: string = ''
  @State duration: string = ''
  @StorageLink('fit_logs') savedLogs: string = ''

  private readonly EXERCISE_MUSCLE: string[][] = [
    // 50+个动作-肌群映射...
  ]

  aboutToAppear(): void { /* 获取参数+反查肌群 */ }
  private addSet(): void { /* 自定义添加组 */ }
  private deleteSet(num: number): void { /* 删除组 */ }
  private quickSet(reps: number, weight: number): void { /* 快捷添加组 */ }
  private saveLog(): void { /* JSON持久化+返回 */ }
  private goBack(): void { router.back() }

  build() { /* 完整UI */ }
}

十一、运行效果

在DevEco Studio中运行项目。
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述


十二、踩坑记录

坑1:@StorageLink跨页面数据共享

现象: 在ExerciseLog页保存训练后,回到Index页面的"进度"Tab中看不到新记录。

原因: 一开始两个页面使用了不同的@StorageLink键名。

解决: 统一使用 'fit_logs' 作为键名,两个页面共享同一份持久化数据。

坑2:JSON.parse解析失败

现象: 首次运行App时,JSON.parse('') 报错。

原因: @StorageLink初始值为空字符串,空字符串不能被JSON.parse解析。

解决: 加条件判断:

if (this.savedLogs && this.savedLogs !== '') {
  this.logs = JSON.parse(this.savedLogs)
}

坑3:concat vs push不触发更新

现象:this.sets.push(...) 添加组后,UI没有更新。

原因: @State对数组的push操作可能不触发深度更新。

解决: 使用 this.sets = this.sets.concat([...]) 创建新数组再赋值,确保触发@State更新。

坑4:parseInt输入空字符串

现象: 次数或重量输入框为空时,parseInt返回NaN。

解决: 做输入校验:

if (isNaN(r) || r <= 0) return

重量NaN时默认为0:

weight: isNaN(w) ? 0 : w

坑5:进度条百分比超过100%

现象: 饮水量或步数超过目标后,进度条超出容器。

原因: 百分比计算没有限制上限。

解决: 饮水量设置了上限 Math.min(this.water + 250, 4000),步数设置了上限 Math.min(this.steps + 1000, this.stepGoal)

坑6:柱状图全0时除零

现象: 一周7天都没有训练数据时,柱状图计算 v / maxVol 除以0。

解决: maxVol getter返回 m || 1,确保最小值为1。


十三、技术要点总结

知识点 实现方式 重要性 本项目特色
JSON持久化 @StorageLink + JSON.stringify/parse ⭐⭐⭐ 复杂数据结构序列化
getter计算属性 7个getter实时计算 ⭐⭐⭐ 多维数据统计
@Builder 3个组件内构建函数 ⭐⭐ 复用统计卡片、建议条目
二维数组 动作/肌群/餐类数据 ⭐⭐ 50+动作的高效存储
router传参 pushUrl + getParams ⭐⭐⭐ 动作名传递+肌群反查
进度条 Column百分比宽度 ⭐⭐ 步数/饮水/热量进度
条件渲染 if-else if (4Tab) ⭐⭐ 单页面多功能切换
数组操作 concat/filter/push ⭐⭐ 训练组增删
输入校验 parseInt/parseFloat/isNaN ⭐⭐ 防止无效输入
嵌套ForEach 餐类+食物双层循环 按餐类分组显示

十四、后续可以做的

14.1 功能扩展

扩展项 方案 说明
训练计时器 setInterval实现 训练过程中的倒计时
历史日期选择 DatePicker组件 查看历史训练记录
身体数据图表 Canvas绑定图表 体重变化曲线
训练模板 预设训练方案 一键加载整套训练
成就系统 解锁条件判断 达成目标后显示成就
数据导出 JSON转Excel/CSV 分享训练数据
深色/浅色切换 资源目录主题配置 支持两种主题
网络同步 HTTP + 云存储 多设备同步数据

14.2 架构优化

方向 方案
MVVM分层 数据层/逻辑层/视图层分离
组件拆分 WorkoutCard、MealItem等独立组件
数据库 @ohos.data.relationalStore 替代JSON
状态管理 AppStorage集中管理全局状态
动画效果 transition/animation API

十五、总结

FitProApp是我在鸿蒙开发中做过的功能最丰富、代码量最大的一个项目。

与之前项目的对比:

维度 MovieApp FitProApp
页面数 2 2
Tab数 4 4
数据接口 2个 4个
getter数 2个 6个
@Builder数 1个(全局) 3个(组件内)
持久化 简单ID字符串 JSON序列化
数据量 20部电影 50+动作+饮食+饮水
功能模块 搜索+收藏+评分 训练+饮食+统计+动作库

核心技术收获:

  1. JSON持久化 — @StorageLink + JSON.stringify/parse,解决了复杂数据结构的持久化问题。比简单的ID字符串方案适用范围更广。

  2. getter计算属性 — 本项目使用了6个getter,是最多的。通过getter实现了实时数据计算(总容量、总热量、平均每组、周训练量等),UI自动响应数据变化。

  3. @Builder组件内封装 — statCard、tipRow、showMuscleGroup三个构建函数大幅减少了重复代码。和之前的全局@Builder不同,这次全部是组件内的,需要用 this 调用。

  4. 二维数组管理 — EXERCISES(50+动作)、MUSCLES(12个肌群标签)、MEAL_TYPES(4种餐类)都用二维数组管理,数据查找通过遍历匹配。

  5. 嵌套ForEach — 饮食Tab中外层遍历餐类、内层遍历食物的嵌套渲染模式,实现了按类型分组的列表展示。

  6. 跨页面数据共享 — Index和ExerciseLog通过同一个@StorageLink键 'fit_logs' 共享训练数据,实现了训练记录的创建和读取的无缝衔接。

做完这个项目,我对鸿蒙ArkUI的状态管理、数据持久化、组件复用、条件渲染都有了更深入的理解。代码量约370行,涵盖了训练、饮食、统计等多个领域的功能实现。

如果这篇文章对你有帮助,欢迎点赞、收藏、评论! 💪

Logo

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

更多推荐