基于鸿蒙os开发跑步管理系统(三)-数据模型设计
数据模型设计
模型总览
RunAdvisor 定义了 8 个数据模型类,分布在 5 个源文件中,构成应用的核心数据结构:
| 模型 | 文件 | 用途 |
|---|---|---|
| UserProfile | model/UserProfile.ets | 用户个人档案 |
| WeatherInfo | model/WeatherInfo.ets | 天气信息 |
| FoodItem | model/FoodItem.ets | 食物条目 |
| DietRecord | model/FoodItem.ets | 饮食摄入记录 |
| RunAdvice | model/RunAdvice.ets | 跑步建议 |
| RouteInfo | model/RouteInfo.ets | 路线信息 |
| RoutePoint | model/RouteInfo.ets | 路线途经点 |
| Landmark | model/RouteInfo.ets | 地标信息 |
模型关系图
UserProfile WeatherInfo
|-- weight |-- temperature
|-- height |-- feelsLike
|-- age |-- humidity
|-- gender |-- windSpeed
|-- dailyStepGoal |-- description
|-- dailyCalorieGoal |-- icon
|-- weeklyRunGoal |-- cityName
|-- isProfileSet |
| |
+----+ +----+
| | |
v v v
RunAdvice <---- RunAdvisorService ----> WeatherInfo
|-- runType
|-- runTypeName FoodItem
|-- targetDistance |-- id
|-- targetPaceMin/Sec |-- name
|-- targetHRMin/Max |-- category
|-- durationMin |-- caloriesPer100g
|-- warmupMin |-- defaultGrams
|-- cooldownMin |
|-- tips v
|-- intensity DietRecord
|-- color |-- foodId
|-- description |-- foodName
| |-- grams
v |-- calories
RouteInfo |-- timestamp
|-- id |-- category
|-- name
|-- type
|-- color
|-- distanceKm
|-- durationMin
|-- difficulty
|-- description
|-- points[] ----> RoutePoint
|-- lat
|-- lng
|-- name
Landmark
|-- name
|-- lat
|-- lng
UserProfile - 用户档案模型
存储用户基本信息和运动目标,是整个应用的个性化基础。
字段定义
export class UserProfile {
weight: number = 70; // 体重 (kg)
height: number = 170; // 身高 (cm)
age: number = 30; // 年龄
gender: string = 'male'; // 性别 ('male' | 'female')
dailyStepGoal: number = 10000; // 每日步数目标
dailyCalorieGoal: number = 2200; // 每日热量目标 (kcal)
weeklyRunGoal: number = 5; // 每周跑步目标 (km)
isProfileSet: boolean = false; // 是否已完成资料设置
}
计算方法
// BMI 指数计算
getBMI(): number {
const heightM: number = this.height / 100;
return this.weight / (heightM * heightM);
}
// BMI 分类判断
getBMICategory(): string {
const bmi: number = this.getBMI();
if (bmi < 18.5) return '偏瘦';
if (bmi < 24) return '正常';
if (bmi < 28) return '偏胖';
return '肥胖';
}
// 最大心率 (220 - 年龄公式)
getMaxHR(): number {
return 220 - this.age;
}
// 步长估算 (男性 0.78m, 女性 0.66m)
getStepLength(): number {
return this.gender === 'male' ? 0.78 : 0.66;
}
// 每步热量消耗 (男性 0.05kcal, 女性 0.04kcal)
getCaloriePerStep(): number {
return this.gender === 'male' ? 0.05 : 0.04;
}
持久化键
UserProfile 通过 PreferencesService 序列化为 JSON 存储,对应的常量键为:
Constants.PROFILE_KEY= ‘user_profile’Constants.PROFILE_SET_KEY= ‘is_profile_set’
WeatherInfo - 天气信息模型
存储从 OpenWeatherMap API 获取的天气数据,并提供运动适宜性判断。
字段定义
export class WeatherInfo {
temperature: number = 0; // 实际温度 (Celsius)
feelsLike: number = 0; // 体感温度 (Celsius)
humidity: number = 0; // 湿度 (%)
windSpeed: number = 0; // 风速 (m/s)
description: string = ''; // 天气描述 (中文)
icon: string = ''; // 天气图标代码
cityName: string = ''; // 城市名称
}
运动适宜性判断逻辑
isGoodForRunning(): boolean {
if (this.temperature < -10 || this.temperature > 35) return false;
if (this.windSpeed > 15) return false;
if (this.humidity > 90) return false;
return true;
}
getWeatherAdvice(): string {
if (this.temperature < 0) return '气温过低,注意保暖防冻,建议室内运动';
if (this.temperature > 35) return '高温预警,避免户外跑步,注意防暑';
if (this.windSpeed > 10) return '风力较大,跑步时逆风会较吃力';
if (this.humidity > 80) return '湿度较高,注意补水和降温';
return '天气适宜跑步,享受运动吧!';
}
判断阈值
| 指标 | 不适宜阈值 | 建议调整阈值 |
|---|---|---|
| 温度 (低) | < -10 C | < 0 C |
| 温度 (高) | > 35 C | > 35 C |
| 风速 | > 15 m/s | > 10 m/s |
| 湿度 | > 90% | > 80% |
FoodItem - 食物条目模型
定义食物的基础热量信息,支持按重量计算热量。
字段定义
export class FoodItem {
id: string = ''; // 唯一标识 (如 'g1', 'm3', 'v5')
name: string = ''; // 食物名称
category: string = ''; // 类别 (谷物/肉类/蔬菜/水果/乳制品)
caloriesPer100g: number = 0; // 每100g热量 (kcal)
defaultGrams: number = 100; // 默认摄入量 (g)
}
热量计算方法
getCalories(grams: number): number {
return Math.round(this.caloriesPer100g * grams / 100);
}
ID 命名规则
| 前缀 | 类别 | 示例 |
|---|---|---|
| g | 谷物 | g1=米饭, g2=馒头, g3=面条 |
| m | 肉类 | m1=鸡胸肉, m3=牛肉, m10=虾仁 |
| v | 蔬菜 | v1=西红柿, v6=土豆, v15=豆腐 |
| f | 水果 | f1=苹果, f2=香蕉, f9=猕猴桃 |
| d | 乳制品 | d1=牛奶, d2=酸奶, d4=鸡蛋 |
DietRecord - 饮食摄入记录模型
记录用户每次食物摄入的详细信息,用于每日热量统计。
字段定义
export class DietRecord {
foodId: string = ''; // 关联的 FoodItem.id
foodName: string = ''; // 食物名称 (冗余存储)
grams: number = 0; // 实际摄入量 (g)
calories: number = 0; // 本次摄入热量 (kcal)
timestamp: number = 0; // 记录时间 (Date.now())
category: string = ''; // 食物类别 (冗余存储)
}
创建方式
DietRecord 通过关联 FoodItem 创建,自动计算热量:
static create(food: FoodItem, grams: number): DietRecord {
const d = new DietRecord();
d.foodId = food.id;
d.foodName = food.name;
d.grams = grams;
d.calories = food.getCalories(grams);
d.timestamp = Date.now();
d.category = food.category;
return d;
}
每日热量计算
PreferencesService 中通过时间戳过滤当日记录:
getTodayCalories(): number {
const records: DietRecord[] = this.getDietRecords();
const todayStart: number = new Date(
new Date().getFullYear(),
new Date().getMonth(),
new Date().getDate()
).getTime();
let total: number = 0;
for (let i = 0; i < records.length; i++) {
if (records[i].timestamp >= todayStart) {
total += records[i].calories;
}
}
return total;
}
RunAdvice - 跑步建议模型
存储由 RunAdvisorService 算法生成的跑步方案数据。
字段定义
export class RunAdvice {
runType: string = ''; // 跑步类型 (easy/standard/endurance)
runTypeName: string = ''; // 类型中文名 (轻松跑/标准跑/耐力跑)
targetDistance: number = 0; // 目标距离 (km)
targetPaceMin: number = 0; // 配速分钟部分
targetPaceSec: number = 0; // 配速秒部分
targetHRMin: number = 0; // 目标心率下限 (bpm)
targetHRMax: number = 0; // 目标心率上限 (bpm)
durationMin: number = 0; // 预计时长 (分钟)
warmupMin: number = 0; // 热身时长 (分钟)
cooldownMin: number = 0; // 放松时长 (分钟)
tips: string = ''; // 专业建议文本
intensity: string = ''; // 强度等级 (低/中/高)
color: string = ''; // 主题色
description: string = ''; // 方案描述
}
显示辅助方法
// 配速格式化: "7'30""
getPaceDisplay(): string {
return `${this.targetPaceMin}'${this.targetPaceSec < 10 ? '0' : ''}${this.targetPaceSec}"`;
}
// 心率区间格式化: "114-123"
getHRDisplay(): string {
return `${this.targetHRMin}-${this.targetHRMax}`;
}
RouteInfo / RoutePoint / Landmark - 路线模型
RoutePoint - 途经点
export class RoutePoint {
lat: number = 0; // 纬度
lng: number = 0; // 经度
name: string = ''; // 途经点名称
}
RouteInfo - 路线信息
export class RouteInfo {
id: string = ''; // 路线ID
name: string = ''; // 路线名称
type: string = ''; // 路线类型 (easy/standard/endurance)
color: string = ''; // 主题色
distanceKm: number = 0; // 总距离 (km)
durationMin: number = 0; // 预计时长 (分钟)
difficulty: string = ''; // 难度等级
description: string = ''; // 路线描述
points: RoutePoint[] = []; // 途经点列表
}
Landmark - 地标
export class Landmark {
name: string = ''; // 地标名称
lat: number = 0; // 纬度
lng: number = 0; // 经度
}
三条路线数据
| 路线 | ID | 距离 | 时长 | 难度 | 途经点数 | 颜色 |
|---|---|---|---|---|---|---|
| 轻松跑 | route_easy | 3.5km | 25min | 入门 | 7 | #4CAF50 (绿) |
| 标准跑 | route_standard | 5.8km | 40min | 中等 | 12 | #2196F3 (蓝) |
| 耐力跑 | route_endurance | 10.2km | 65min | 挑战 | 14 | #FF9800 (橙) |
模型设计原则
静态工厂方法模式
所有模型类遵循统一的对象创建模式:无参构造器 + 静态工厂方法。这一设计直接源于 ArkTS 严格模式的约束。在 ArkTS 中,@Sendable 修饰的类不支持带参数的构造器,而所有需要跨上下文传递的模型类都应遵循此规范。
工厂方法的优势不仅在于合规性,还体现在默认值隔离。每个字段在类声明时初始化默认值(如 weight: number = 70),构造器创建的对象处于合法状态,工厂方法仅在需要时覆盖特定字段。这避免了 “半初始化对象” 问题——即使某些字段未被工厂方法赋值,对象仍持有合理默认值。
static create(weight: number, height: number, age: number, ...): UserProfile {
const p = new UserProfile();
p.weight = weight;
p.height = height;
p.age = age;
return p;
}
冗余字段策略
DietRecord 中的 foodName 和 category 字段看似冗余(可通过 foodId 关联 FoodItem 获取),但这是有意的设计决策。
首先,饮食记录的时间跨度可能跨越食物数据库的更新周期。如果仅存储 foodId 引用,当 FoodItem 数据变更(如修正热量值或重命名)后,历史记录的显示将产生不一致。冗余存储确保历史记录始终反映添加时刻的准确信息。
其次,查询效率。今日热量统计需要遍历所有记录求和,如果每条记录都需通过 foodId 查询 FoodItem 再获取热量,时间复杂度从 O(n) 升至 O(n*m)(n 为记录数,m 为食物数)。冗余存储 calories 字段使统计查询保持 O(n)。
类型安全的 JSON 解析
ArkTS 严格模式禁止 any 类型,JSON.parse() 的返回值必须通过 Record<string, Object> 中转。所有模型的反序列化遵循以下模式:
const obj: Record<string, Object> = JSON.parse(json) as Record<string, Object>;
const p: UserProfile = new UserProfile();
p.weight = (obj['weight'] as number) ?? Constants.DEFAULT_WEIGHT;
p.height = (obj['height'] as number) ?? Constants.DEFAULT_HEIGHT;
p.age = (obj['age'] as number) ?? Constants.DEFAULT_AGE;
每个字段使用 as 进行类型断言,配合 ?? 运算符提供降级默认值。这确保了即使持久化数据损坏(如版本升级导致字段缺失),应用也不会崩溃,而是回退到默认值。
UserProfile 字段详解
体重 (weight)
单位:千克 (kg)。默认值 70,取值范围建议 30-200。体重是 BMI 计算和热量消耗估算的核心输入。BMI 公式为 weight / (height/100)^2,偏瘦/正常/偏胖/肥胖的判定阈值分别为 18.5、24、28。每步热量消耗系数因性别而异(男性 0.05 kcal/步,女性 0.04 kcal/步),体重也会影响实际消耗,当前采用简化估算。
身高 (height)
单位:厘米 (cm)。默认值 170。身高参与 BMI 计算和步长估算。步长估算公式为:男性 0.78m,女性 0.66m,这是基于统计平均值的简化模型。精确的步长应通过加速度传感器实时计算,但本应用采用固定值以简化实现。
年龄 (age)
默认值 30。年龄影响两个核心计算:最大心率(220 - age)和跑步建议的年龄调整。年龄超过 50 岁时,配速自动减慢 1 min/km,距离缩短 1-2km,确保运动安全。25 岁以下时配速加快 1 min/km,反映年轻群体的较高体能水平。
性别 (gender)
取值:‘male’ 或 ‘female’。性别影响步长估算和每步热量消耗系数。男性步长 0.78m(每 1000 步约 780m),女性步长 0.66m(每 1000 步约 660m)。热量消耗差异约为 20%,男性每步 0.05 kcal,女性 0.04 kcal。
每日步数目标 (dailyStepGoal)
默认值 10000 步。这一数值源自世界卫生组织的推荐运动量,约等于 7-8 公里的步行距离。Progress 组件以 dailyStepGoal 为 total 值,展示完成百分比。
每日热量目标 (dailyCalorieGoal)
默认值 2200 kcal。这是中国成年女性日均推荐摄入量的近似值(男性约 2400 kcal)。用户可根据自身需求调整。热量进度条使用橙色 (#FF9800) 区分于步数的蓝色 (#4A90D9)。
每周跑步目标 (weeklyRunGoal)
单位:千米 (km)。默认值 5km。这一字段当前仅作为展示用途,未来可扩展为周训练计划的基础参数。
是否已设置档案 (isProfileSet)
布尔值,默认 false。这是首次启动引导流程的关键判断依据。当 Preferences 中 PROFILE_SET_KEY 对应值为 false 或不存在时,应用判定为首次启动,自动跳转至 ProfileSetupPage。设置完成后写入 true,此后不再触发引导。
WeatherInfo 运动适宜性判断详解
温度阈值分析
| 温度范围 | 判断 | 生理依据 |
|---|---|---|
| < -10 C | 不适宜 | 低温冻伤风险,肌肉僵硬易拉伤 |
| -10 ~ 0 C | 适宜但需注意 | 保暖防冻,建议室内热身后外出 |
| 0 ~ 35 C | 适宜 | 正常跑步温度区间 |
| > 35 C | 不适宜 | 热射病风险,核心体温过高 |
体感温度 (feelsLike) 综合了风速和湿度的影响,比实际温度更贴近人体感受。当前 isGoodForRunning() 使用实际温度判断,未来可改用 feelsLike 提高判断准确性。
风速阈值分析
| 风速范围 | 判断 | 影响 |
|---|---|---|
| < 5 m/s | 适宜 | 微风有助散热 |
| 5 ~ 10 m/s | 适宜但吃力 | 逆风增加跑步负荷约 5-8% |
| 10 ~ 15 m/s | 需注意 | 建议顺风出发逆风返回 |
| > 15 m/s | 不适宜 | 跑步姿态受影响,安全风险 |
湿度阈值分析
| 湿度范围 | 判断 | 影响 |
|---|---|---|
| < 60% | 适宜 | 汗液蒸发正常,散热有效 |
| 60 ~ 80% | 稍高 | 建议增加补水频率 |
| 80 ~ 90% | 较高 | 体感闷热,散热效率下降 |
| > 90% | 不适宜 | 汗液难以蒸发,核心体温快速上升 |
FoodItem 热量数据来源
食物热量数据参考中国食物成分表(中国疾病预防控制中心营养与健康所编),单位为千卡每 100 克 (kcal/100g)。需要注意:
- 热量值为食物原料的均值,实际值受品种、产地、烹饪方式影响
- 默认摄入量 (defaultGrams) 根据常见食用份量设定(如米饭 200g、馒头 100g、鸡蛋 60g)
- 搜索功能支持中文模糊匹配,用户输入 “鸡” 可匹配 “鸡胸肉”、“鸡翅”、“鸡腿” 等
DietRecord 每日统计实现
每日热量统计通过时间戳过滤实现。获取今日零点的时间戳,遍历所有记录,仅统计 timestamp >= 今日零点的记录:
getTodayCalories(): number {
const records: DietRecord[] = this.getDietRecords();
const todayStart: number = new Date(
new Date().getFullYear(),
new Date().getMonth(),
new Date().getDate()
).getTime();
let total: number = 0;
for (let i = 0; i < records.length; i++) {
if (records[i].timestamp >= todayStart) {
total += records[i].calories;
}
}
return total;
}
这种实现简单可靠,但有一个潜在问题:当记录数量累积过多时,遍历效率会下降。当前设计中没有记录清理机制,长期使用后 DietRecord 数组可能增长到数百甚至数千条。未来优化方向包括:按日期分桶存储、定期归档历史记录、或引入 RDB 数据库替代。
RunAdvice 配速与心率关系
配速和心率是跑步训练的两个核心指标,它们之间存在正相关关系。配速越快(数值越小),心率越高。RunAdvice 中设定的三个心率区间对应三种训练效果:
| 方案 | 心率区间 | 训练效果 |
|---|---|---|
| 轻松跑 | 60-65% HRmax | 有氧基础、脂肪燃烧、恢复 |
| 标准跑 | 65-70% HRmax | 有氧耐力、心肺功能提升 |
| 耐力跑 | 70-80% HRmax | 乳酸阈值训练、速度耐力 |
心率区间基于最大心率百分比划分,这是运动科学中广泛使用的分区方法。实际训练中还需考虑静息心率和心率储备(Karvonen 公式),但本应用采用简化公式以降低用户输入负担。
RoutePoint 与 Canvas 映射
RoutePoint 中的 GPS 坐标 (lat, lng) 在 Canvas 渲染时需要映射为屏幕像素坐标。映射过程:
- 遍历所有途经点,找出经纬度的最小值和最大值(边界框)
- 计算缩放比例:scaleX = canvasWidth / (maxLng - minLng),scaleY = canvasHeight / (maxLat - minLat),取较小值保持等比例
- 计算偏移量:使路线居中显示
- 每个途经点:screenX = (lng - minLng) * scale + offsetX,screenY = (maxLat - lat) * scale + offsetY(纬度方向取反,因为屏幕 Y 轴向下)
Landmark 使用相同的映射逻辑渲染为圆形标记,确保地标与路线的相对位置正确。
模型序列化与反序列化全流程
以 UserProfile 为例,完整的数据生命周期:
1. 创建: UserProfile.create(65, 175, 25, 'male', ...)
2. 使用: profile.getBMI(), profile.getStepLength()
3. 序列化: JSON.stringify(profile) -> '{"weight":65,"height":175,...}'
4. 存储: preferences.putSync(PROFILE_KEY, jsonString)
5. 持久化: preferences.flushSync()
6. 读取: preferences.getSync(PROFILE_KEY, '') as string
7. 反序列化: JSON.parse(json) as Record<string, Object>
8. 重建: new UserProfile() + 逐字段赋值
flushSync() 确保数据立即写入磁盘,虽然性能开销略高于异步 flush,但保证了应用异常退出时数据不丢失。在用户保存档案和添加饮食记录等关键操作中,同步持久化是必要的。
更多推荐




所有评论(0)