首页与天气

首页布局

首页 (HomePage) 是应用的核心信息汇总页面,采用 Scroll + Column 垂直滚动布局,包含四个功能卡片:

+----------------------------------+
| [天气状况]            城市名称   |  <-- WeatherCard
| 22°C  晴                         |
| 体感 20°C  湿度 55%  风速 3m/s  |
| 天气适宜跑步,享受运动吧!      |
+----------------------------------+
| [今日步数]        6543 / 10000  |  <-- StepCard
| ████████████░░░░░░░░░░░░░░░░░  |
| 距离: 5.10 km    消耗: 327 kcal |
+----------------------------------+
| [今日热量]      1580 / 2200 kcal|  <-- CalorieSummaryCard
| ████████████████░░░░░░░░░░░░░░  |
+----------------------------------+
| [今日建议]                       |  <-- QuickAdviceCard
| 轻松跑   3km | 7'30"/km         |
|          心率 114-123  强度: 低  |
| 标准跑   5km | 6'00"/km         |
|          心率 123-133  强度: 中  |
| 耐力跑  10km | 5'30"/km         |
|          心率 133-152  强度: 高  |
+----------------------------------+

首页截图

homepage

首页代码结构

@Builder
HomePage() {
  Scroll() {
    Column({ space: Constants.MARGIN_CARD }) {
      this.WeatherCard()
      this.StepCard()
      this.CalorieSummaryCard()
      this.QuickAdviceCard()
    }
    .width('100%')
    .padding(Constants.PADDING_PAGE)
  }
  .width('100%')
  .height('100%')
  .backgroundColor(Constants.COLOR_BG)
}

数据加载时机

首页数据在 Index.aboutToAppear() 生命周期中初始化:

aboutToAppear(): void {
  const prefs: PreferencesService = PreferencesService.getInstance();
  this.profile = prefs.getProfile();
  this.isFirstLaunch = !prefs.isProfileSet();
  this.stepCount = prefs.getStepCount();
  this.todayCalories = prefs.getTodayCalories();
  this.loadWeather();
  this.loadSteps();
  if (this.isFirstLaunch) {
    setTimeout(() => {
      this.navStack.pushPath({ name: 'ProfileSetup' });
    }, 800);
  }
}

天气卡片 (WeatherCard)

布局结构

+----------------------------------+
| 天气状况               北京     |
|                                  |
| 22°C        体感 20°C          |
| 晴          湿度 55%           |
|             风速 3m/s          |
|                                  |
| 天气适宜跑步,享受运动吧!     |  <-- 颜色随适宜性变化
+----------------------------------+

状态管理

天气数据通过 @State weatherInfo 驱动 UI 更新:

@State weatherInfo: WeatherInfo | undefined = undefined;

初始值为 undefined,此时显示加载状态 “正在获取天气信息…”。异步加载完成后自动刷新。

天气数据获取流程

private async loadWeather(): Promise<void> {
  const loc: LocationService = LocationService.getInstance();
  const coords: number[] = await loc.getCurrentLocation();
  const ws: WeatherService = WeatherService.getInstance();
  this.weatherInfo = await ws.getWeather(coords[0], coords[1]);
}

流程图:

loadWeather()
  |
  +-- LocationService.getCurrentLocation()
  |     |-- 优先: GPS 精确定位
  |     +-- 降级: 北京坐标 (39.9042, 116.4074)
  |
  +-- WeatherService.getWeather(lat, lng)
        |-- 优先: 检查本地缓存
        |-- 次选: HTTP 请求 OpenWeatherMap API
        +-- 降级: 默认天气 (22°C, 晴, 北京)

天气适宜性颜色

天气建议文字的颜色根据 isGoodForRunning() 动态变化:

Text(this.weatherInfo.getWeatherAdvice())
  .fontColor(this.weatherInfo.isGoodForRunning()
    ? Constants.COLOR_GREEN    // #4CAF50 适宜跑步
    : Constants.COLOR_ORANGE)  // #FF9800 不建议跑步

步数卡片 (StepCard)

布局结构

+----------------------------------+
| 今日步数        6543 / 10000    |
| ████████████░░░░░░░░░░░░░░░░░  |  <-- Progress 组件
| 距离: 5.10 km    消耗: 327 kcal |
+----------------------------------+

步数相关计算

// 步行距离 = 步数 * 步长 / 1000
private getStepDistance(): string {
  const km: number = this.stepCount * this.profile.getStepLength() / 1000;
  return km.toFixed(2);
}

// 步行热量消耗 = 步数 * 每步热量系数
private getStepCalories(): number {
  return Math.round(this.stepCount * this.profile.getCaloriePerStep());
}

计算参数因性别而异:

性别 步长 每步热量
男性 0.78m 0.05 kcal
女性 0.66m 0.04 kcal

Progress 组件

使用 HarmonyOS 内置 Progress 组件展示步数目标完成度:

Progress({
  value: this.stepCount,
  total: this.profile.dailyStepGoal,
  type: ProgressType.Linear
})
.width('100%')
.color(Constants.COLOR_PRIMARY)

热量摘要卡片 (CalorieSummaryCard)

布局结构

+----------------------------------+
| 今日热量      1580 / 2200 kcal  |
| ████████████████░░░░░░░░░░░░░░  |  <-- 橙色 Progress
+----------------------------------+

热量数据来源于两个渠道:

  1. 饮食摄入 - 通过 DietPage 添加的食物记录
  2. 运动消耗 - 步数消耗(StepCard 中独立展示)

热量进度条使用橙色 (#FF9800) 以区别于步数的蓝色 (#4A90D9):

Progress({
  value: this.todayCalories,
  total: this.profile.dailyCalorieGoal,
  type: ProgressType.Linear
})
.width('100%')
.color(Constants.COLOR_ORANGE)

快速建议卡片 (QuickAdviceCard)

布局结构

+----------------------------------+
| 今日建议                         |
| +------------------------------+ |
| | 轻松跑   3km | 7'30"/km    | |
| |         心率 114-123 强度:低| |
| +------------------------------+ |
| | 标准跑   5km | 6'00"/km    | |
| |         心率 123-133 强度:中| |
| +------------------------------+ |
| | 耐力跑  10km | 5'30"/km    | |
| |         心率 133-152 强度:高| |
| +------------------------------+ |
+----------------------------------+

数据来源

建议数据通过 RunAdvisorService 生成,带缓存机制:

private getAdvices(): RunAdvice[] {
  if (this.advices.length === 0) {
    this.advices = RunAdvisorService.getInstance()
      .getAdvice(this.profile, this.weatherInfo);
  }
  return this.advices;
}

首次加载后缓存在 @State advices 中,仅在用户更新资料时清空:

// saveProfileSetup() 中清空缓存
this.advices = [];

列表渲染

使用 ForEach 渲染建议列表,以 runType 作为唯一标识:

ForEach(this.getAdvices(), (advice: RunAdvice) => {
  Row() {
    Column() {
      Text(advice.runTypeName)
      Text(`${advice.targetDistance}km | ${advice.getPaceDisplay()}/km`)
    }
    Blank()
    Column() {
      Text(`心率 ${advice.getHRDisplay()}`)
      Text(`强度: ${advice.intensity}`)
    }
  }
}, (advice: RunAdvice) => advice.runType)

首次启动引导流程

判断逻辑

this.isFirstLaunch = !prefs.isProfileSet();

if (this.isFirstLaunch) {
  setTimeout(() => {
    this.navStack.pushPath({ name: 'ProfileSetup' });
  }, 800);
}

设置页面结构

ProfileSetupPage 以 NavDestination 形式呈现,包含以下输入项:

输入项 类型 默认值
体重 (kg) Number 70
身高 (cm) Number 170
年龄 Number 30
性别 选择 (男/女)
步数目标 Number 10000
热量目标 Number 2200
周跑目标 (km) Number 5

保存逻辑

private saveProfileSetup(): void {
  const weight: number = parseInt(this.setupWeight);
  const height: number = parseInt(this.setupHeight);
  const age: number = parseInt(this.setupAge);
  // ...
  if (isNaN(weight) || isNaN(height) || isNaN(age)) return;

  const p: UserProfile = UserProfile.create(
    weight, height, age, this.setupGender,
    isNaN(stepGoal) ? Constants.DEFAULT_DAILY_GOAL : stepGoal,
    isNaN(calorieGoal) ? Constants.DEFAULT_CALORIE_GOAL : calorieGoal,
    isNaN(runGoal) ? Constants.DEFAULT_RUN_GOAL : runGoal
  );
  PreferencesService.getInstance().saveProfile(p);
  this.profile = p;
  this.isFirstLaunch = false;
  this.advices = [];
  this.navStack.clear();
}

保存成功后清空导航栈,确保用户无法返回设置页面。

卡片样式统一

所有卡片遵循统一的样式规范:

.width('100%')
.padding(Constants.PADDING_CARD)       // 16vp
.backgroundColor(Constants.COLOR_CARD) // #FFFFFF
.borderRadius(Constants.BORDER_RADIUS_CARD) // 12vp

页面背景色为浅灰 (#F5F5F5),卡片间距 12vp (MARGIN_CARD)。

首页数据加载时序详解

aboutToAppear 生命周期

aboutToAppear() 是 Index 组件的生命周期回调,在组件即将出现时调用,适合执行数据初始化。该回调在 UI 首次渲染前触发,确保数据加载后立即展示。

aboutToAppear(): void {
  const prefs: PreferencesService = PreferencesService.getInstance();
  this.profile = prefs.getProfile();
  this.isFirstLaunch = !prefs.isProfileSet();
  this.stepCount = prefs.getStepCount();
  this.todayCalories = prefs.getTodayCalories();
  this.loadWeather();
  this.loadSteps();
  if (this.isFirstLaunch) {
    setTimeout(() => {
      this.navStack.pushPath({ name: 'ProfileSetup' });
    }, 800);
  }
}

加载顺序的设计考量:

  1. Preferences 数据(profile、stepCount、todayCalories)优先加载,因为它们是同步操作,结果立即可用,确保 UI 有初始数据渲染。
  2. loadWeather() 和 loadSteps() 是异步操作,启动后不阻塞 UI 渲染。数据返回后通过 @State 自动触发界面刷新。
  3. 首次启动检测在同步数据之后,确保 isProfileSet 状态准确。

异步数据加载与 UI 更新

@State 装饰器的核心特性是响应式更新。当 @State 变量的值发生变化时,绑定了该变量的 UI 组件会自动重新渲染。这一机制使得异步数据加载无需手动触发 UI 刷新:

aboutToAppear() 执行时:
  weatherInfo = undefined  -> UI 显示 "正在获取天气信息..."
  
loadWeather() 完成后:
  weatherInfo = WeatherInfo(22°C, 晴)  -> UI 自动刷新为天气数据

首次启动的 800 毫秒延迟

延迟设计的意图是让用户先看到首页布局,建立对应用的初步认知,再跳转到设置页面。如果没有延迟,用户打开应用后直接看到设置页面,可能产生困惑(“我是不是下错了应用?”)。

800 毫秒的时长经过权衡:太短(如 200ms)用户来不及感知首页;太长(如 2000ms)会让人感觉应用卡顿。800ms 约等于一次自然眨眼的时长,在展示和流畅之间取得平衡。

天气卡片深度解析

天气数据获取链路

天气数据的获取涉及三个服务的协作:LocationService、WeatherService 和 PreferencesService。

用户打开应用
  |
  v
Index.aboutToAppear()
  |
  +-- loadWeather()
       |
       +-- LocationService.getCurrentLocation()
       |     |
       |     +-- geoLocationManager.getCurrentLocation()
       |     |     |
       |     |     +-- 成功: 返回 [lat, lng], 缓存坐标
       |     |     +-- 失败: 使用缓存坐标或北京默认坐标
       |     |
       |     v
       |   返回坐标 [39.9042, 116.4074]
       |
       +-- WeatherService.getWeather(lat, lng)
             |
             +-- 检查本地缓存
             |     |
             |     +-- 缓存有效(< 30分钟): 直接返回
             |     +-- 缓存无效: 发起HTTP请求
             |
             +-- HTTP 请求 OpenWeatherMap API
             |     |
             |     +-- 成功(200): 解析JSON, 缓存, 返回
             |     +-- 失败: 返回默认天气(22°C, 晴, 北京)
             |
             v
           this.weatherInfo = info  -> UI自动刷新

天气适宜性对建议的影响

天气数据不仅用于首页展示,还直接影响跑步建议算法。在 RunAdvisorService 的第七步中,如果 isGoodForRunning() 返回 false,所有跑步方案的距离都会缩短:

  • 轻松跑距离减少 1km(最低 2km)
  • 标准跑距离减少 1km(最低 3km)
  • 耐力跑距离减少 2km(最低 4km)

此外,getWeatherAdvice() 的文本会拼接到每种方案的建议 tips 前面,如"高温预警,避免户外跑步,注意防暑。轻松跑是恢复和基础训练的好方式…"

天气卡片 UI 状态

天气卡片有三种显示状态:

状态 条件 显示内容
加载中 weatherInfo === undefined “正在获取天气信息…”
加载成功 weatherInfo !== undefined 完整天气数据
无数据 天气请求失败(使用默认值) 默认天气(22°C, 晴)

注意:即使天气请求失败,weatherInfo 也不会是 undefined,因为 WeatherService 总会返回降级数据。因此用户不会看到加载中状态持续过久。

步数卡片深度解析

步数数据来源优先级

getStepCount()
  |
  +-- 1. 读取 Preferences 缓存
  |     |-- 日期匹配: 返回缓存步数
  |     |-- 日期不匹配: 继续
  |
  +-- 2. 读取 PEDOMETER 传感器
  |     |-- 成功: 保存到 Preferences, 返回步数
  |     |-- 失败/超时(3秒): 继续
  |
  +-- 3. 返回 0

步行距离计算

距离计算基于步数和步长的乘积。步长因性别而异,这是基于统计平均值的简化模型:

  • 男性步长 0.78m(来源:中国成年人步态研究平均值)
  • 女性步长 0.66m

更精确的步长计算应考虑身高因素,公式为:步长 = 身高 * 0.45(近似值)。当前版本使用性别二值化,未来可引入身高相关的步长公式。

getStepDistance(): string {
  const km: number = this.stepCount * this.profile.getStepLength() / 1000;
  return km.toFixed(2);
}

示例计算:男性用户,6500 步

  • 距离 = 6500 * 0.78 / 1000 = 5.07 km
  • 热量 = 6500 * 0.05 = 325 kcal

步数进度条

Progress 组件的 value 和 total 属性决定了进度条填充比例:

Progress({
  value: this.stepCount,        // 当前步数
  total: this.profile.dailyStepGoal,  // 目标步数
  type: ProgressType.Linear
})
.width('100%')
.color(Constants.COLOR_PRIMARY)  // #4A90D9 蓝色

当 stepCount > dailyStepGoal 时,Progress 组件会显示满条状态(不会溢出),符合"已完成目标"的语义。

热量摘要卡片深度解析

热量数据来源

今日热量 (todayCalories) 来自饮食记录,通过 PreferencesService.getTodayCalories() 计算。该值在以下时机更新:

  1. 应用启动时,aboutToAppear() 中调用 prefs.getTodayCalories() 初始化
  2. DietPage 添加食物记录后,addDietRecord() 中调用 prefs.getTodayCalories() 刷新

由于 todayCalories 是 @State 变量,DietPage 中的更新会自动反映到首页的热量摘要卡片。这是单文件架构的优势——所有 @State 在同一个 @Component 中,无需事件总线或状态管理框架。

热量与步数的区别展示

卡片 颜色 含义
步数 Progress #4A90D9 (蓝) 运动量指标
热量 Progress #FF9800 (橙) 营养摄入指标

颜色区分帮助用户快速识别两类不同性质的数据。蓝色代表"行动"(步数越多越好),橙色代表"摄入"(需控制在目标范围内,过多或过少都不理想)。

快速建议卡片深度解析

建议缓存机制

private getAdvices(): RunAdvice[] {
  if (this.advices.length === 0) {
    this.advices = RunAdvisorService.getInstance()
      .getAdvice(this.profile, this.weatherInfo);
  }
  return this.advices;
}

缓存策略为"首次计算,后续复用"。advices 初始化为空数组,首次调用时计算并缓存,后续直接返回。缓存失效的唯一时机是用户更新个人资料(saveProfileSetup() 中 this.advices = [])。

这一策略的原因是 RunAdvisorService.getAdvice() 是纯计算函数(无网络请求、无 I/O),计算耗时极短(毫秒级),缓存主要是避免 ForEach 每次渲染时重复计算。

建议卡片与路线页的联动

QuickAdviceCard 中每种建议方案的"查看路线"按钮通过 navStack.pushPath 跳转:

onClick(() => {
  this.navStack.pushPath({
    name: 'MapRoute',
    param: advice.runType  // 'easy' | 'standard' | 'endurance'
  });
})

param 传递的是 runType 字符串,MapRoutePageNav 接收后调用 RouteService.getRouteByType(type) 获取对应路线数据,实现"建议->路线"的功能闭环。

ForEach 键值生成器

ForEach(this.getAdvices(), (advice: RunAdvice) => {
  // 渲染每个建议卡片
}, (advice: RunAdvice) => advice.runType)

第三个参数 (advice: RunAdvice) => advice.runType 是键值生成器,返回 ‘easy’、‘standard’ 或 ‘endurance’。ForEach 通过键值判断列表项是否变化,实现高效 diff 更新。由于 runType 在运行时不变,ForEach 不会产生不必要的重渲染。

卡片样式体系统一性

间距与尺寸常量

常量 用途
MARGIN_CARD 12vp 卡片之间的间距
PADDING_PAGE 16vp 页面内边距
PADDING_CARD 16vp 卡片内边距
BORDER_RADIUS_CARD 12vp 卡片圆角半径
FONT_SIZE_TITLE 24 标题字号
FONT_SIZE_SUBTITLE 18 副标题字号
FONT_SIZE_BODY 14 正文字号

颜色常量

常量 色值 语义
COLOR_BG #F5F5F5 页面背景(浅灰)
COLOR_CARD #FFFFFF 卡片背景(白色)
COLOR_PRIMARY #4A90D9 主色调(蓝色)
COLOR_ORANGE #FF9800 强调色(橙色)
COLOR_GREEN #4CAF50 成功/适宜(绿色)
COLOR_TEXT_PRIMARY #333333 主文字色
COLOR_TEXT_WHITE #FFFFFF 白色文字

卡片通用样式模板

每个 @Builder 卡片方法都遵循以下样式模式:

Column({ space: 8 }) {
  // 卡片内容
}
.width('100%')
.padding(Constants.PADDING_CARD)
.backgroundColor(Constants.COLOR_CARD)
.borderRadius(Constants.BORDER_RADIUS_CARD)

这种统一的样式模板确保了视觉一致性,且修改全局卡片样式时只需调整常量值。

设置完成后的首页

用户完成首次设置后,首页数据全面填充。与初始状态的区别:

数据项 初始状态 设置完成后
天气 加载中 显示实际天气数据
步数 0 显示传感器读数
热量 0 显示今日摄入
建议 空列表 三种跑步方案
步数目标 10000 用户自定义值
热量目标 2200 用户自定义值

设置完成后,profile 对象从默认值变为用户实际数据,BMI、最大心率、步长等计算参数随之更新,直接影响跑步建议的生成结果。

Logo

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

更多推荐