基于鸿蒙os开发跑步管理系统(五)-首页与天气
首页与天气
首页布局
首页 (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 强度: 高 |
+----------------------------------+
首页截图

首页代码结构
@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
+----------------------------------+
热量数据来源于两个渠道:
- 饮食摄入 - 通过 DietPage 添加的食物记录
- 运动消耗 - 步数消耗(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);
}
}
加载顺序的设计考量:
- Preferences 数据(profile、stepCount、todayCalories)优先加载,因为它们是同步操作,结果立即可用,确保 UI 有初始数据渲染。
- loadWeather() 和 loadSteps() 是异步操作,启动后不阻塞 UI 渲染。数据返回后通过 @State 自动触发界面刷新。
- 首次启动检测在同步数据之后,确保 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() 计算。该值在以下时机更新:
- 应用启动时,aboutToAppear() 中调用 prefs.getTodayCalories() 初始化
- 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、最大心率、步长等计算参数随之更新,直接影响跑步建议的生成结果。
更多推荐

所有评论(0)