周五晚上:立项——为什么做健身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数全面超越之前的所有项目。


后续计划

先让它跑起来再说。后面慢慢加功能:

  1. 训练计时器 — setInterval倒计时
  2. 用户添加饮食 — 输入食物名+热量
  3. 身体数据图表 — 体重变化曲线
  4. 训练模板 — 预设整套训练方案
  5. 深色/浅色切换 — 资源目录主题
  6. 数据库迁移 — 从JSON迁移到relationalStore

一步一步来,不急。


一个周末,一个功能齐全的健身App。虽然累,但学到了很多。

如果这篇日记对你有帮助,欢迎点赞支持! 💪🏋️

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐