数据模型设计

模型总览

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)。需要注意:

  1. 热量值为食物原料的均值,实际值受品种、产地、烹饪方式影响
  2. 默认摄入量 (defaultGrams) 根据常见食用份量设定(如米饭 200g、馒头 100g、鸡蛋 60g)
  3. 搜索功能支持中文模糊匹配,用户输入 “鸡” 可匹配 “鸡胸肉”、“鸡翅”、“鸡腿” 等

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 渲染时需要映射为屏幕像素坐标。映射过程:

  1. 遍历所有途经点,找出经纬度的最小值和最大值(边界框)
  2. 计算缩放比例:scaleX = canvasWidth / (maxLng - minLng),scaleY = canvasHeight / (maxLat - minLat),取较小值保持等比例
  3. 计算偏移量:使路线居中显示
  4. 每个途经点: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,但保证了应用异常退出时数据不丢失。在用户保存档案和添加饮食记录等关键操作中,同步持久化是必要的。

Logo

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

更多推荐