每个页面展示的步数、训练计划、动作列表、饮食记录,背后都指向同一个文件:WorkoutData.ets。这个文件是整个App的数据层,所有页面的数据都从这里导出。
拆解一个App不能只看UI,还要看数据是怎么组织的。数据层的设计决定了页面能不能高效渲染、后续能不能方便地接入真实后端。这一篇把WorkoutData.ets从头到尾讲一遍。
完整效果
三个数据模型
Exercise——单个训练动作
export interface Exercise { id: number; name: string; icon: string; category: string; difficulty: string duration: number; calories: number; muscles: string; steps: string[] }九个字段,描述一个完整的训练动作:
| 字段 | 类型 | 含义 | 示例 |
|---|---|---|---|
| id | number | 唯一标识 | 1 |
| name | string | 动作名称 | ‘俯卧撑’ |
| icon | string | emoji图标 | ‘💪’ |
| category | string | 分类 | ‘力量’ |
| difficulty | string | 难度 | ‘初级’ |
| duration | number | 时长(分钟) | 10 |
| calories | number | 消耗卡路里 | 80 |
| muscles | string | 目标肌群 | ‘胸肌·肱三头肌·核心’ |
| steps | string[] | 动作步骤 | [‘双手撑地与肩同宽’, …] |
steps是字符串数组——每个步骤是一条文字说明。ExerciseDetail页面用ForEach渲染这个数组,用户可以一步步跟着做。这种"步骤数组"的设计比把所有步骤拼成一个长字符串更好:每个步骤独立,可以逐条高亮、逐条展示。
WorkoutPlan——训练计划
export interface WorkoutPlan { id: number; name: string; icon: string; desc: string; duration: number exercises: number[]; level: string; color: string }训练计划的核心是exercises: number[]——一个动作ID数组。不是直接引用Exercise对象,而是存ID。比如"7分钟晨练"计划的exercises是[7,4,3,1,6],对应开合跳、波比跳、平板支撑、俯卧撑、仰卧起坐。
这种"ID引用"模式在数据层设计中很常见——计划只存动作的ID,不存动作的完整数据。好处是:修改一个动作的名称或步骤,所有引用它的计划自动生效。如果直接存对象,改一个动作需要同步修改多个计划。
ActivityRecord——每日活动记录
export interface ActivityRecord { date: string; steps: number; calories: number; minutes: number; workouts: number }五个字段记录一天的运动数据:日期、步数、卡路里消耗、运动分钟数、训练次数。这个模型被首页的三个数据环(步数/卡路里/运动时间)和本周活动柱状图共用。
三个数据数组
EXERCISES——10个训练动作
export const EXERCISES: Exercise[] = [ { id: 1, name: '俯卧撑', icon: '💪', category: '力量', difficulty: '初级', ... }, { id: 2, name: '深蹲', icon: '🦵', category: '力量', difficulty: '初级', ... }, { id: 3, name: '平板支撑', icon: '🧘', category: '核心', difficulty: '中级', ... }, { id: 4, name: '波比跳', icon: '🔥', category: '有氧', difficulty: '高级', ... }, { id: 5, name: '哑铃弯举', icon: '🏋️', category: '力量', difficulty: '初级', ... }, { id: 6, name: '仰卧起坐', icon: '🔄', category: '核心', difficulty: '初级', ... }, { id: 7, name: '开合跳', icon: '⭐', category: '有氧', difficulty: '初级', ... }, { id: 8, name: '箭步蹲', icon: '🦿', category: '力量', difficulty: '中级', ... }, { id: 9, name: '俄罗斯转体', icon: '💫', category: '核心', difficulty: '中级', ... }, { id: 10, name: '登山者', icon: '⛰️', category: '有氧', difficulty: '中级', ... }, ]10个动作覆盖三个分类:力量(俯卧撑、深蹲、哑铃弯举、箭步蹲)、核心(平板支撑、仰卧起坐、俄罗斯转体)、有氧(波比跳、开合跳、登山者)。难度从初级到高级都有。
每个动作的steps数组有3-6个步骤,写得像"教练在旁边指导"——“双手撑地与肩同宽”“身体保持直线”“屈肘下降至胸部接近地面”。这些文字不是从数据库导入的,是手动写的。真实项目中这些内容应该从后端API获取,或者由运动科学团队审核后发布。
WORKOUT_PLANS——5个训练计划
export const WORKOUT_PLANS: WorkoutPlan[] = [ { id: 1, name: '7分钟晨练', icon: '🌅', desc: '高效燃脂·唤醒身体', duration: 7, exercises: [7,4,3,1,6], level: '初级', color: '#FF6B6B' }, { id: 2, name: '核心强化', icon: '🎯', desc: '腹肌撕裂·核心稳定', duration: 15, exercises: [3,6,9,10,1], level: '中级', color: '#8A5DFA' }, { id: 3, name: '力量突破', icon: '⚡', desc: '增肌塑形·力量提升', duration: 20, exercises: [1,2,5,8,4], level: '高级', color: '#FECA57' }, { id: 4, name: '燃脂HIIT', icon: '🔥', desc: '高强度间歇·高效燃脂', duration: 12, exercises: [4,7,10,9,5], level: '高级', color: '#E8734A' }, { id: 5, name: '全身拉伸', icon: '🧘', desc: '放松身心·提升柔韧性', duration: 10, exercises: [3,2,8,7,1], level: '初级', color: '#00B894' }, ]5个计划,每个包含5个动作的ID。计划之间有重叠——俯卧撑(id:1)出现在4个计划中,平板支撑(id:3)出现在3个计划中。这就是"ID引用"的好处:同一个动作可以被多个计划复用,不需要重复定义。
每个计划有自己的颜色(color字段),首页的推荐训练卡片用这个颜色做标签背景。不同计划用不同颜色区分——红色=晨练、紫色=核心、黄色=力量、橙色=HIIT、绿色=拉伸。
WEEKLY_DATA——7天活动记录
export const WEEKLY_DATA: ActivityRecord[] = [ { date: '周一', steps: 8432, calories: 420, minutes: 35, workouts: 2 }, { date: '周二', steps: 12500, calories: 580, minutes: 50, workouts: 3 }, { date: '周三', steps: 6200, calories: 310, minutes: 20, workouts: 1 }, { date: '周四', steps: 15600, calories: 720, minutes: 60, workouts: 3 }, { date: '周五', steps: 9800, calories: 490, minutes: 40, workouts: 2 }, { date: '周六', steps: 18900, calories: 890, minutes: 75, workouts: 4 }, { date: '周日', steps: 5100, calories: 250, minutes: 15, workouts: 1 }, ]7天的数据,周六最高(18900步、890千卡、75分钟、4次训练),周日最低(5100步、250千卡、15分钟、1次训练)。这种分布符合真实场景——工作日运动量中等,周末有时间多运动,周日休息。
首页用WEEKLY_DATA[3](周四)作为"今天"的数据。这是一个硬编码的问题——真实项目应该根据当前日期动态获取,而不是固定取第四条。
两个工具函数
getExerciseById——按ID查找动作
export function getExerciseById(id: number): Exercise | undefined { for (let i: number = 0; i < EXERCISES.length; i++) { if (EXERCISES[i].id === id) return EXERCISES[i] } return undefined }遍历EXERCISES数组,找到id匹配的动作返回。找不到返回undefined。ExerciseDetail页面用这个函数——路由参数传入动作ID,页面根据ID获取完整动作数据。
getExercisesForPlan——获取计划的所有动作
export function getExercisesForPlan(plan: WorkoutPlan): Exercise[] { const result: Exercise[] = [] for (let i: number = 0; i < plan.exercises.length; i++) { const ex: Exercise | undefined = getExerciseById(plan.exercises[i]) if (ex) result.push(ex) } return result }输入一个训练计划,返回该计划包含的所有动作对象。内部调用getExerciseById逐个查找,找到的push到结果数组。WorkoutPage用这个函数——用户选择一个计划后,页面调用getExercisesForPlan获取该计划的动作列表。
这两个函数实现了"ID引用→实际对象"的转换。数据层存ID(轻量),页面层用对象(完整)。中间由工具函数桥接。
数据流向
整个App的数据流是单向的:
WorkoutData.ets(数据源) ↓ export Index.ets → 读取WEEKLY_DATA、WORKOUT_PLANS WorkoutPage.ets → 读取WORKOUT_PLANS、调用getExercisesForPlan ExerciseDetail.ets → 调用getExerciseById ProgressPage.ets → 读取WEEKLY_DATA CommunityPage.ets → 独立数据(不依赖WorkoutData) MealPage.ets → 独立数据(不依赖WorkoutData) ProfilePage.ets → 独立数据(不依赖WorkoutData)首页、训练页、进度页共用WorkoutData的数据。社区页、饮食页、个人中心页各自有独立的硬编码数据,不走WorkoutData。这意味着WorkoutData不是一个"全局数据服务",而是"运动相关数据服务"——饮食和社区的数据应该在各自的service中定义。
和其他App的数据层对比
| 属性 | 运动健康WorkoutData | 购物商城ProductData | 3D拆解 |
|---|---|---|---|
| 文件数 | 1个 | 1个 | 3个(AiAnalysisService/AuthService/NetworkService) |
| 接口数 | 3个 | 1个(Product) | 3个 |
| 数据数组 | 3个 | 2个(getFlashSaleProducts/getHotProducts) | mock占位 |
| 工具函数 | 2个 | 0个 | 0个 |
| ID引用模式 | 有(exercises: number[]) | 无 | 无 |
| 数据持久化 | 无 | 无 | 无 |
运动健康的数据层是三个App中最完整的——有接口定义、有工具函数、有ID引用模式。购物商城更简单(只有Product接口和两个数据函数)。3D拆解最弱(三个service都是mock占位,没有真实数据)。
踩坑记录
首页硬编码WEEKLY_DATA[3]。Index.ets用@State today: ActivityRecord = WEEKLY_DATA[3]固定取周四的数据。不管用户什么时候打开App,看到的都是周四的数据。真实项目应该用new Date().getDay()获取当前星期几,然后映射到WEEKLY_DATA的索引。但getDay()返回0-6(周日=0),而WEEKLY_DATA的索引是0-6(周一=0),需要做转换:(date.getDay() + 6) % 7。
训练计划切换后动作列表不更新。WorkoutPage用@State selectedPlan存储当前选中的计划,但ForEach渲染动作列表时直接用了全局EXERCISES而不是调用getExercisesForPlan。切换计划后,动作列表不会更新——因为ForEach的数据源没变。应该在切换计划时调用getExercisesForPlan并更新@State变量。
没有数据持久化。所有数据都是const数组,运行时不会变化。用户完成一个训练后,WEEKLY_DATA不会自动更新——步数不会增加、卡路里不会累加。真实项目需要AppStorage或数据库来持久化用户数据。
社区和饮食的数据不在WorkoutData中。社区页面的帖子列表、饮食页面的餐食列表各自硬编码在自己的页面文件中。如果以后要做"运动+饮食+社区"的数据联动(比如发帖时自动带上今天的运动数据),需要把所有数据统一到一个数据服务中。
工具函数的线性查找。getExerciseById用for循环遍历数组查找。10个动作没问题,但如果动作数量增长到1000+,线性查找会影响性能。可以用Map建立id→Exercise的索引:const exerciseMap = new Map(EXERCISES.map(e => [e.id, e])),查找变成O(1)。
数据层是App的"后台"——用户看不到它,但所有页面都依赖它。WorkoutData.ets的设计思路是:接口定义数据结构,常量数组存储数据,工具函数提供查询能力。这种"接口+常量+函数"的三层结构是HarmonyOS轻量级应用的典型数据架构。它不需要数据库、不需要网络请求、不需要状态管理框架——用最简单的方式让页面有数据可渲染。当App需要接入真实后端时,只需要把常量数组替换成API调用,接口定义和工具函数可以复用。