鸿蒙开发日记:我用一个周末做了一款全能健身App,累并快乐着
周五晚上:立项——为什么做健身App?
做完了电影App之后,我对鸿蒙的多页面路由、@StorageLink持久化、@Builder复用都已经比较熟悉了。
但每次做完一个项目,总有种"不过瘾"的感觉——功能太单一。
所以下一个项目,我想做一个功能密集、模块多的综合型App。
选什么主题呢?
想了几个方向:记账App(之前FinanceApp做过了)、音乐App(做过了)、新闻App(没什么交互)、健身App(功能多,覆盖面广)。
健身App天然包含多个模块:训练记录、饮食追踪、数据统计、动作百科……每个模块都有独特的数据结构和交互方式。
就是它了。
当晚列了个功能清单:
✅ 4个Tab(首页/进度/训练/饮食)
✅ 步数追踪(进度条)
✅ 饮水记录(添加+重置)
✅ 饮食列表(按餐类分组)
✅ 训练记录(组数/次数/重量)
✅ 动作库(50+动作,11个肌群)
✅ 数据统计(6个数据卡片)
✅ 柱状图(本周训练量)
✅ 训练建议(6条)
✅ JSON持久化
✅ 多页面路由
10个功能点,比之前任何项目都多。
周六上午:数据建模——最难的部分
接口定义
第一个问题:数据怎么组织?
健身App有4种数据:训练记录、训练组、饮水记录、饮食记录。每种数据的结构不同。
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 }
4个interface,1对多关系:一条ExerciseLog包含多个WorkoutSet。
动作库
最头疼的部分:50多个动作怎么存?
一开始想用interface对象数组,每个动作一个对象。但太多了,代码会很长。
后来决定用二维数组:
private readonly EXERCISES: string[][] = [
['胸', '杠铃卧推'], ['胸', '哑铃飞鸟'], ...
]
每个元素 [肌群, 动作名]。好处:
- 数据紧凑,一行一个
- 按肌群过滤只需
if (EXERCISES[i][0] === '胸') - 容易增删
填了50+个动作,覆盖11个肌群:
| 肌群 | 动作 |
|---|---|
| 胸 | 杠铃卧推、哑铃飞鸟、上斜卧推、俯卧撑、绳索夹胸 |
| 腿 | 杠铃深蹲、腿举、弓箭步、腿弯举、罗马尼亚硬拉 |
| 背 | 引体向上、杠铃划船、高位下拉、坐姿划船、哑铃单臂划船 |
| 肩 | 哑铃推举、侧平举、前平举、面拉、杠铃推举 |
| 二头 | 杠铃弯举、哑铃弯举、锤式弯举、集中弯举 |
| 三头 | 窄距卧推、绳索下压、臂屈伸、法式弯举 |
| 核心 | 卷腹、平板支撑、举腿、俄罗斯转体、自行车卷腹 |
| 臀 | 臀桥、深蹲、髋推、跪姿后踢 |
| 全身 | 波比跳、跳绳、开合跳、burpee |
| 有氧 | 跑步、骑行、游泳、椭圆机、划船机 |
| 拉伸 | 全身拉伸、腿部拉伸、背部拉伸、肩颈拉伸 |
肌群标签和餐类标签也用二维数组:
private readonly MUSCLES: string[][] = [
['🏋️', '全部'], ['💪', '胸'], ['🦵', '腿'], ...
]
private readonly MEAL_TYPES: string[][] = [
['🌅', '早餐'], ['🌞', '午餐'], ['🌆', '晚餐'], ['🍪', '加餐'],
]
路由配置
两个页面:Index(首页)+ ExerciseLog(训练记录页)。
{ "src": ["pages/Index", "pages/ExerciseLog"] }
周六下午:首页UI——4个Tab一个页面搞定
状态变量
先列出所有需要的状态变量:
@State tab: number = 0 // 当前Tab
@State steps: number = 8432 // 步数
@State stepGoal: number = 10000 // 步数目标
@State water: number = 1400 // 饮水量
@State waterGoal: number = 2000 // 饮水目标
@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 = '' // 训练记录持久化
15个状态变量!是之前项目的好几倍。
Tab 0:首页
首页是最复杂的Tab,要显示很多东西:
步数卡片:
这个比较简单,一个进度条+两个按钮。进度条用嵌套Column实现:
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)
外层灰色Column是轨道,内层绿色Column按百分比填充。步数变→百分比变→进度条变。
三列统计卡片:
摄入、饮水、运动三个卡片结构完全一样,只是图标、数值、颜色不同。
一开始写三遍重复代码。写到第二遍就受不了了。
→ 封装成@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)
}
}
调用3次就搞定:
this.statCard('🔥', '摄入', '1200/2200', '54%', '#FF9F0A')
this.statCard('💧', '饮水', '1400/2000ml', '70%', '#5AC8FA')
this.statCard('⏱️', '运动', '45/60分', '75%', '#FF3B30')
柱状图:
这个花了我不少时间。
用ForEach遍历7天的训练量数据,每天一个Column。柱子高度 = 训练量/最大值 × 100%。
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')
Text(this.days[i]).fontSize(11)
}
.layoutWeight(1).height(100).justifyContent(FlexAlign.End)
})
FlexAlign.End 让柱子从底部向上长——这就是图表的感觉。
weekVolumes返回7个值:前6个是硬编码的历史数据,第7个是当天的实时总容量。
maxVol取7天中的最大值,用于计算柱子高度比例。
踩坑: 一开始所有天都没训练时,maxVol为0,除以0导致柱子高度是Infinity。加了 || 1 后解决。
Tab 1:进度
6个数据统计卡片(2行3列),然后训练记录列表,最后6条训练建议。
训练建议又用@Builder封装了一次:
@Builder tipRow(icon: string, title: string, desc: string) {
Row() {
Text(icon).fontSize(20).width(32)
Column() {
Text(title).fontWeight(FontWeight.Bold)
Text(desc).fontColor('#8E8E93')
}
}
}
6条建议,调用6次。
Tab 2:训练(动作库)
这个Tab有两种显示模式:
选择"全部":
按肌群分组显示,每个肌群显示标题和前2个动作。
又是一个重复结构!11个肌群,每个肌群都要显示标题+动作卡片。
→ 又封装了一个@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) {
Column() {
Text('💪')
Text(ex[1]).maxLines(1).width(56)
}.onClick(() => { this.openLog(ex[1]) })
}
})
}
}
}
调用11次。
选择具体肌群:
显示该肌群的所有动作(比如选"胸"就显示5个胸部动作),用List包裹。
过滤逻辑用getter:
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
}
Tab 3:饮食
最有趣的部分是嵌套ForEach——按餐类分组显示食物。
ForEach(this.MEAL_TYPES, (mt: string[]) => { // 外层:早/午/晚/加餐
Column() {
ForEach(this.mealList, (m: MealRecord) => { // 内层:遍历所有食物
if (m.type === mt[1]) { // 只显示匹配的
Row() { ... }
}
})
}
Divider()
})
外层遍历4种餐类,内层遍历所有食物,用type字段匹配。
这样不同餐类的食物会自动归类到对应的标题下面。
周六晚上:持久化——JSON方案的摸索
为什么JSON?
之前的电影App中,收藏数据用简单的逗号分隔字符串存ID。比如 "1,5,8," 表示收藏了3部电影。
但训练记录不是简单的ID列表——它有嵌套的组数数据:
训练记录
├── id: 1
├── name: "杠铃卧推"
├── sets: [{ setNum: 1, reps: 12, weight: 20 }, { setNum: 2, reps: 10, weight: 30 }]
├── date: "今天"
└── note: "感觉不错"
用字符串拼接来存这种数据太复杂。
解决方案:JSON序列化。
@StorageLink只能存字符串。但我们可以把复杂数据转成JSON字符串:
@StorageLink('fit_logs') savedLogs: string = '' // 持久化的字符串
private logs: ExerciseLog[] = [] // 工作用的数组
保存(数组 → 字符串):
private saveLogs(): void {
this.savedLogs = JSON.stringify(this.logs)
}
加载(字符串 → 数组):
aboutToAppear(): void {
if (this.savedLogs && this.savedLogs !== '') {
this.logs = JSON.parse(this.savedLogs)
}
}
踩坑:空字符串!
首次运行时savedLogs是空字符串 ''。JSON.parse('') 会直接报错崩溃。
解决: 加条件判断:
if (this.savedLogs && this.savedLogs !== '') {
this.logs = JSON.parse(this.savedLogs)
}
只有savedLogs不为空时才解析。
计算属性
训练记录存了之后,需要各种统计:总容量、总组数、平均每组……
这些都需要实时计算——添加或删除训练后,统计值自动更新。
→ 用getter!
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
}
private get totalCal(): number {
let c = 0
for (let i = 0; i < this.mealList.length; i++) {
c += this.mealList[i].cal
}
return c
}
private get avgVolume(): string {
const s = this.totalSets()
return String(Math.floor(this.totalVolume / (s > 0 ? s : 1)))
}
getter的好处:每次访问都重新计算,数据变了值自动变,UI跟着更新。
这个项目用了7个getter,是之前所有项目中最多的:
| getter | 计算内容 |
|---|---|
| filteredExercises | 按肌群过滤动作 |
| totalVolume | 总训练容量 |
| totalCal | 总摄入热量 |
| avgVolume | 平均每组容量 |
| weekVolumes | 本周7天训练量 |
| maxVol | 柱状图最大值 |
| totalWeight | 当前动作容量(详情页) |
周日上午:训练记录页
跳转
从训练Tab点击动作 → 跳转到ExerciseLog:
private openLog(name: string): void {
router.pushUrl({
url: 'pages/ExerciseLog',
params: { name: name }
})
}
只传动作名。肌群信息在详情页通过映射表反查。
参数接收
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
}
}
}
}
“杠铃卧推” → 在映射表里找到 → muscle = “胸”。
添加组
两种方式:
快捷添加: 4个预设按钮(12x20kg、10x30kg、8x40kg、6x50kg),一键添加。
private quickSet(reps: number, weight: number): void {
this.sets = this.sets.concat([{
setNum: this.nextSet, reps: reps, weight: weight
}])
this.nextSet++
}
自定义添加: 输入次数和重量,点击添加。
private addSet(): void {
const r = parseInt(this.repsInput)
const w = parseFloat(this.weightInput)
if (isNaN(r) || r <= 0) return
this.sets = this.sets.concat([{...}])
this.nextSet++
this.repsInput = ''
this.weightInput = ''
}
踩坑:push vs concat
一开始用 this.sets.push(...) 添加组,但UI没更新。
查了资料才知道,@State对数组的push操作可能不会触发深度更新。
改用 concat 创建新数组再赋值就好了:
this.sets = this.sets.concat([{...}])
concat返回一个新数组,赋值给@State变量后UI正常刷新。
踩坑:parseInt空字符串
输入框为空时,parseInt('') 返回NaN。直接用NaN做运算会出问题。
解决: 校验输入
if (isNaN(r) || r <= 0) return // 次数无效不添加
weight: isNaN(w) ? 0 : w // 重量无效默认0
保存
保存是整个App最关键的操作之一。流程:
点击保存
↓
检查 sets.length > 0(没有组不允许保存)
↓
JSON.parse(savedLogs) 加载已有记录
↓
遍历找 maxId + 1
↓
构建 LogData { id, name, muscle, sets, date, note, duration }
↓
插入数组头部(最新的排前面)
↓
JSON.stringify → savedLogs(@StorageLink自动持久化)
↓
router.back() 返回首页
跨页面数据共享
Index和ExerciseLog都使用 @StorageLink('fit_logs'),键名相同。
所以在ExerciseLog中保存的训练记录,回到Index的"进度"Tab中立即可见。
这个特性是@StorageLink最强大的地方——不需要手动传递数据,自动同步。
周日下午:打磨与测试
初始数据
为了让首页不空荡荡的,加了一些示例数据:
aboutToAppear(): void {
// 饮水时间线
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: '加餐' },
]
}
颜色体系
整个App使用暗色主题,配色:
| 元素 | 色值 | 用途 |
|---|---|---|
| 背景 | #000000 | 主背景 |
| 卡片 | #1C1C1E | 标题栏、导航栏、卡片 |
| 输入框 | #2C2C2E | 输入框、列表封面 |
| 橙色 | #FF9F0A | 高亮、评分、Tab选中 |
| 绿色 | #34C759 | 步数、保存 |
| 蓝色 | #5AC8FA | 饮水 |
| 红色 | #FF3B30 | 删除、超标 |
| 紫色 | #AF52DE | 总组数 |
| 灰色 | #8E8E93 | 辅助文字 |
功能测试
逐个测试每个功能:
| 功能 | 测试结果 |
|---|---|
| 步数+1000/重置 | ✅ 进度条同步更新 |
| 饮水小杯/中杯/重置 | ✅ 上限4000ml |
| Tab切换4个 | ✅ 内容正确 |
| 动作库"全部"模式 | ✅ 按肌群分组 |
| 动作库选"胸" | ✅ 只显示胸部动作 |
| 点击动作跳转 | ✅ 参数传递正确 |
| 肌群反查 | ✅ “杠铃卧推"→"胸” |
| 快捷添加组 | ✅ "12x20kg"正确解析 |
| 自定义添加组 | ✅ 输入校验生效 |
| 删除组 | ✅ 组消失 |
| 保存训练 | ✅ JSON持久化成功 |
| 返回首页 | ✅ 进度Tab可见新记录 |
| 删除训练记录 | ✅ 记录消失 |
| 关闭再打开 | ✅ 数据保留 |



踩坑总结
| # | 坑 | 问题 | 解决 |
|---|---|---|---|
| 1 | JSON.parse空串 | 首次运行崩溃 | 加 !== '' 判断 |
| 2 | push不更新UI | 添加组后不刷新 | 改用 concat |
| 3 | parseInt空值 | NaN参与计算 | isNaN校验 |
| 4 | 柱状图除零 | 全0天高度Infinity | maxVol || 1 |
| 5 | @StorageLink键名 | 数据不共享 | 统一键名 |
| 6 | 饮水超过100% | 进度条溢出 | Math.min上限 |
学到了什么?
技术收获
| 技能 | 来源 | 重要程度 |
|---|---|---|
| JSON序列化持久化 | 训练记录嵌套数据 | ⭐⭐⭐ |
| 7个getter | 复杂统计需求 | ⭐⭐⭐ |
| 3个@Builder | 重复UI结构 | ⭐⭐ |
| 二维数组管理 | 50+动作数据 | ⭐⭐ |
| 嵌套ForEach | 饮食分组 | ⭐ |
| 进度条 | Column百分比 | ⭐⭐ |
| 柱状图 | Column高度+FlexAlign | ⭐⭐ |
| 路由传参+反查 | 动作名→肌群 | ⭐⭐ |
| 输入校验 | parseInt/isNaN | ⭐⭐ |
| concat替代push | @State数组更新 | ⭐⭐⭐ |
设计感悟
1. 先建模,再写UI
这次先花时间定义好4个interface和50+动作数据,后面写UI的时候得心应手。如果数据结构没想清楚就写UI,会反复修改。
2. 能封装就封装
3个@Builder省了大量重复代码。如果不用@Builder,3个statCard就要写60多行重复代码,6个tipRow又是60多行,11个showMuscleGroup更夸张。
3. getter是你的朋友
7个getter让统计数据"活"起来——用户添加训练或修改饮食后,所有统计值自动更新,不需要手动刷新。
4. @StorageLink的JSON方案很实用
简单数据用字符串拼接,复杂数据用JSON序列化。这个模式可以复用到任何需要持久化复杂数据的项目中。
和之前项目的对比
| 项目 | 代码量 | 接口数 | getter数 | @Builder数 | 页面数 | 持久化复杂度 |
|---|---|---|---|---|---|---|
| 掷骰子 | ~60行 | 0 | 0 | 0 | 1 | 无 |
| 天气App | ~200行 | 1 | 0 | 1 | 1 | 无 |
| 音乐App | ~100行 | 1 | 0 | 1 | 1 | 无 |
| 电影App | ~300行 | 2 | 2 | 1(全局) | 2 | ID字符串 |
| FitProApp | ~370行 | 4 | 7 | 3(组件内) | 2 | JSON序列化 |
代码量、接口数、getter数全面超越之前的所有项目。
后续计划
先让它跑起来再说。后面慢慢加功能:
- 训练计时器 — setInterval倒计时
- 用户添加饮食 — 输入食物名+热量
- 身体数据图表 — 体重变化曲线
- 训练模板 — 预设整套训练方案
- 深色/浅色切换 — 资源目录主题
- 数据库迁移 — 从JSON迁移到relationalStore
一步一步来,不急。
一个周末,一个功能齐全的健身App。虽然累,但学到了很多。
如果这篇日记对你有帮助,欢迎点赞支持! 💪🏋️
更多推荐




所有评论(0)