鸿蒙实战记录:开发一款全能健身App(训练记录+饮食追踪+数据统计+动作库完整实现)
前言
学了鸿蒙一段时间,做了掷骰子、天气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 创建项目
- 打开 DevEco Studio
- Create Project → 选择 Empty Ability 模板
- 填写项目信息:
| 配置项 | 值 |
|---|---|
| Project name | FitProApp |
| Bundle name | com.example.fitproapp |
| Save location | E:\HMproject\Project\FitProApp |
| Language | ArkTS |
| Model | Stage |
- 点击 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.stringify和JSON.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 = ''
}
- 用
parseInt和parseFloat将输入字符串转为数字 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+动作+饮食+饮水 |
| 功能模块 | 搜索+收藏+评分 | 训练+饮食+统计+动作库 |
核心技术收获:
-
JSON持久化 — @StorageLink + JSON.stringify/parse,解决了复杂数据结构的持久化问题。比简单的ID字符串方案适用范围更广。
-
getter计算属性 — 本项目使用了6个getter,是最多的。通过getter实现了实时数据计算(总容量、总热量、平均每组、周训练量等),UI自动响应数据变化。
-
@Builder组件内封装 — statCard、tipRow、showMuscleGroup三个构建函数大幅减少了重复代码。和之前的全局@Builder不同,这次全部是组件内的,需要用
this调用。 -
二维数组管理 — EXERCISES(50+动作)、MUSCLES(12个肌群标签)、MEAL_TYPES(4种餐类)都用二维数组管理,数据查找通过遍历匹配。
-
嵌套ForEach — 饮食Tab中外层遍历餐类、内层遍历食物的嵌套渲染模式,实现了按类型分组的列表展示。
-
跨页面数据共享 — Index和ExerciseLog通过同一个@StorageLink键
'fit_logs'共享训练数据,实现了训练记录的创建和读取的无缝衔接。
做完这个项目,我对鸿蒙ArkUI的状态管理、数据持久化、组件复用、条件渲染都有了更深入的理解。代码量约370行,涵盖了训练、饮食、统计等多个领域的功能实现。
如果这篇文章对你有帮助,欢迎点赞、收藏、评论! 💪
更多推荐




所有评论(0)