
一、数据模型:构建运动记录的数字骨架
代码通过RunRecord
接口定义了跑步数据的核心结构:
interface RunRecord {
id: string; // 记录唯一标识
date: Date; // 跑步日期
distance: number; // 距离(公里)
duration: number; // 时长(分钟)
pace: number; // 配速(分钟/公里)
}
这一模型以"距离-时长-配速"为核心维度,通过id
和date
建立时间轴索引。在实际跑步场景中,当用户点击"结束跑步"时,系统会基于实时数据生成RunRecord
对象并添加到runRecords
数组中:
const newRecord: RunRecord = {
id: Date.now().toString(),
date: new Date(),
distance: this.currentDistance,
duration: this.currentDuration,
pace: this.currentPace
};
this.runRecords = [newRecord, ...this.runRecords];
这种设计遵循了"最小必要数据"原则,既满足基础运动分析需求,又降低了数据存储与计算的复杂度。
二、状态管理:实现运动数据的实时响应
应用通过@State
装饰器管理五大核心状态,构建起数据流转的中枢系统:
isRunning
:标记跑步状态(进行中/已结束),控制界面按钮与数据展示逻辑currentDistance/duration/pace
:实时跑步数据,通过定时器模拟GPS更新showHistory
:控制历史记录列表的展开/收起状态
核心状态更新逻辑体现在startRun
与endRun
方法中。当用户点击"开始跑步"时,系统启动定时器以100ms为周期更新数据:
this.intervalId = setInterval(() => {
this.currentDistance += 0.01; // 模拟每100ms增加10米
this.currentDuration += 0.1; // 模拟每100ms增加0.1秒
if (this.currentDistance > 0) {
this.currentPace = this.currentDuration / this.currentDistance / 60;
}
}, 100);
而endRun
方法则负责清除定时器、保存记录并重置状态,形成"数据采集-存储-重置"的闭环。
三、UI组件:打造直观的运动数据可视化体验
应用采用"统计头部-数据卡片-历史列表"的三层布局,通过ArkTS的声明式UI特性实现动态渲染:
-
统计头部组件(StatsHeader)
采用三栏式布局展示总跑步次数、总距离与平均配速,通过reduce
方法对历史记录进行聚合计算:Text(
${this.runRecords.reduce((sum, r) => sum + r.distance, 0).toFixed(1)}km
)
数据展示遵循"数值+单位"的层级结构,大号字体突出核心数字,小号字体标注单位与说明,符合用户"先看结果再辨含义"的认知习惯。
- 跑步数据卡片(RunDataCard)
通过isRunning
状态动态切换显示逻辑:跑步中时突出距离与时长数据,搭配配速与时长的分栏展示;跑步结束后则展示今日汇总数据。按钮设计采用绿色(开始)与红色(结束)的高对比度配色,强化操作反馈。 - 历史记录列表(HistoryList)
通过showHistory
状态控制显示/隐藏,使用ForEach
循环渲染历史记录,每条记录包含日期、时长、距离与配速信息。当记录为空时显示空状态提示,提升用户体验的完整性。
四、核心算法:从原始数据到运动洞察
代码中包含三个关键数据处理函数,将原始运动数据转化为可解读的运动指标:
formatTime
:将秒数转换为"分:秒"格式(如123
秒转为2:03
),便于用户快速理解运动时长getTodayDistance/duration/pace
:通过日期筛选与数组聚合,计算今日运动数据,支持用户查看短期运动趋势formatDate
:将Date对象转换为"月/日"格式(如6/15
),简化历史记录的时间展示
以getTodayPace
为例,其核心逻辑是通过筛选今日记录并计算平均配速:
const totalDistance = this.getTodayDistance();
const totalDuration = this.getTodayDuration();
if (totalDistance === 0) return 0;
return totalDuration / totalDistance / 60;
五、附:代码
import promptAction from '@ohos.promptAction';
// 跑步记录接口
interface RunRecord {
id: string;
date: Date;
distance: number; // 公里
duration: number; // 分钟
pace: number; // 配速(分钟/公里)
}
@Entry
@Component
struct Index {
@State runRecords: RunRecord[] = []; // 跑步记录列表
@State isRunning: boolean = false; // 是否正在跑步
@State currentDistance: number = 0; // 当前跑步距离
@State currentDuration: number = 0; // 当前跑步时长
@State currentPace: number = 0; // 当前配速
@State showHistory: boolean = false; // 是否显示历史记录
private intervalId: number = -1; // 定时器ID
// 开始跑步
private startRun() {
this.isRunning = true;
this.currentDistance = 0;
this.currentDuration = 0;
this.currentPace = 0;
// 模拟GPS定位更新
this.intervalId = setInterval(() => {
this.currentDistance += 0.01; // 模拟每100ms增加10米
this.currentDuration += 0.1; // 模拟每100ms增加0.1秒
// 更新配速
if (this.currentDistance > 0) {
this.currentPace = this.currentDuration / this.currentDistance / 60;
}
}, 100);
}
// 结束跑步
private endRun() {
clearInterval(this.intervalId);
// 创建新记录
const newRecord: RunRecord = {
id: Date.now().toString(),
date: new Date(),
distance: this.currentDistance,
duration: this.currentDuration,
pace: this.currentPace
};
// 添加到记录列表
this.runRecords = [newRecord, ...this.runRecords];
// 重置状态
this.isRunning = false;
this.currentDistance = 0;
this.currentDuration = 0;
this.currentPace = 0;
promptAction.showToast({ message: '跑步记录已保存' });
}
// 格式化时间为分:秒
private formatTime(seconds: number): string {
const minutes = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${minutes}:${secs < 10 ? '0' : ''}${secs}`;
}
// 获取今日跑步距离
private getTodayDistance(): number {
const today = new Date();
today.setHours(0, 0, 0, 0);
const todayRuns = this.runRecords.filter(record => {
const recordDate = new Date(record.date);
recordDate.setHours(0, 0, 0, 0);
return recordDate.getTime() === today.getTime();
});
return todayRuns.reduce((sum, record) => sum + record.distance, 0);
}
// 获取今日跑步时长
private getTodayDuration(): number {
const today = new Date();
today.setHours(0, 0, 0, 0);
const todayRuns = this.runRecords.filter(record => {
const recordDate = new Date(record.date);
recordDate.setHours(0, 0, 0, 0);
return recordDate.getTime() === today.getTime();
});
return todayRuns.reduce((sum, record) => sum + record.duration, 0);
}
// 获取今日平均配速
private getTodayPace(): number {
const totalDistance = this.getTodayDistance();
const totalDuration = this.getTodayDuration();
if (totalDistance === 0) return 0;
return totalDuration / totalDistance / 60;
}
// 格式化日期
private formatDate(date: Date): string {
return `${date.getMonth() + 1}/${date.getDate()}`;
}
// 头部统计组件
@Builder
StatsHeader() {
Column() {
Text('跑步统计')
.fontSize(18)
.fontWeight(FontWeight.Bold)
.margin({ bottom: 15 })
Row() {
Column() {
Text(`${this.runRecords.length}`)
.fontSize(24)
.fontWeight(FontWeight.Bold)
Text('总次数')
.fontSize(12)
.fontColor('#888')
.margin({top: 5})
}
.width('33%')
Column() {
Text(`${this.runRecords.reduce((sum, r) => sum + r.distance, 0).toFixed(1)}km`)
.fontSize(24)
.fontWeight(FontWeight.Bold)
Text('总距离')
.fontSize(12)
.fontColor('#888')
.margin({top: 5})
}
.width('33%')
Column() {
Text(`${(this.runRecords.reduce((sum, r) => sum + r.pace, 0) / this.runRecords.length || 0).toFixed(2)}min/km`)
.fontSize(24)
.fontWeight(FontWeight.Bold)
Text('平均配速')
.fontSize(12)
.fontColor('#888')
.margin({top: 5})
}
.width('33%')
}
.width('100%')
}
.width('100%')
.padding(15)
.backgroundColor('#F8F9FC')
.borderRadius(12)
}
// 跑步数据卡片
@Builder
RunDataCard() {
Column() {
Text(this.isRunning ? '跑步中' : '今日跑步数据')
.fontSize(18)
.fontWeight(FontWeight.Bold)
.margin({ bottom: 25 })
if (this.isRunning) {
// 跑步中数据显示
Column() {
Text(`${this.currentDistance.toFixed(2)}km`)
.fontSize(42)
.fontWeight(FontWeight.Bold)
.margin({ bottom: 15 })
Text(`${this.formatTime(this.currentDuration)}`)
.fontSize(24)
.margin({ bottom: 25 })
Row() {
Column() {
Text(`${this.currentPace.toFixed(2)}min/km`)
.fontSize(16)
.fontWeight(FontWeight.Bold)
Text('配速')
.fontSize(12)
.fontColor('#888')
.margin({top: 5})
}
.width('50%')
Column() {
Text(`${this.formatTime(this.currentDuration)}`)
.fontSize(16)
.fontWeight(FontWeight.Bold)
Text('时长')
.fontSize(12)
.fontColor('#888')
.margin({top: 5})
}
.width('50%')
}
.width('100%')
}
.width('100%')
.alignItems(HorizontalAlign.Center)
.margin({ bottom: 25 })
} else {
// 跑步后数据显示
Row() {
Column() {
Text(`${this.getTodayDistance().toFixed(2)}km`)
.fontSize(24)
.fontWeight(FontWeight.Bold)
Text('距离')
.fontSize(12)
.fontColor('#888')
.margin({top: 5})
}
.width('33%')
Column() {
Text(`${this.formatTime(this.getTodayDuration())}`)
.fontSize(24)
.fontWeight(FontWeight.Bold)
Text('时长')
.fontSize(12)
.fontColor('#888')
.margin({top: 5})
}
.width('33%')
Column() {
Text(`${this.getTodayPace().toFixed(2)}min/km`)
.fontSize(24)
.fontWeight(FontWeight.Bold)
Text('配速')
.fontSize(12)
.fontColor('#888')
.margin({top: 5})
}
.width('33%')
}
.width('100%')
.margin({ bottom: 25 })
}
if (this.isRunning) {
Button('结束跑步')
.width('100%')
.height(45)
.backgroundColor('#E53935')
.fontColor(Color.White)
.fontSize(16)
.borderRadius(8)
.onClick(() => this.endRun())
} else {
Button('开始跑步')
.width('100%')
.height(45)
.backgroundColor('#2E7D32')
.fontColor(Color.White)
.fontSize(16)
.borderRadius(8)
.onClick(() => this.startRun())
}
}
.width('100%')
.padding(15)
.backgroundColor(Color.White)
.borderRadius(12)
.shadow({ radius: 3, color: '#0000001A' })
}
// 历史记录列表
@Builder
HistoryList() {
if (this.showHistory) {
Column() {
Text('跑步历史')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.margin({ bottom: 15 })
if (this.runRecords.length === 0) {
Text('暂无跑步记录')
.fontSize(14)
.fontColor('#AAA')
.margin({ top: 40 })
} else {
List() {
ForEach(this.runRecords, (record: RunRecord) => {
ListItem() {
Row() {
Column() {
Text(this.formatDate(record.date))
.fontSize(14)
Text(`${this.formatTime(record.duration)}`)
.fontSize(12)
.fontColor('#888')
.margin({top: 4})
}
.width('40%')
Column() {
Text(`${record.distance}km`)
.fontSize(14)
.fontWeight(FontWeight.Bold)
Text(`${record.pace.toFixed(2)}min/km`)
.fontSize(12)
.fontColor('#888')
.margin({top: 4})
}
.width('60%')
}
.width('100%')
.padding(8)
}
})
}
}
}
.width('100%')
.padding(15)
.backgroundColor('#F8F9FC')
.borderRadius(12)
.layoutWeight(1)
}
}
build() {
Column() {
// 统计头部
this.StatsHeader()
// 跑步数据卡片
this.RunDataCard()
// 历史记录
this.HistoryList()
// 底部按钮
Button(this.showHistory ? '隐藏历史' : '显示历史')
.width('100%')
.margin({ top: 15 })
.height(40)
.fontSize(14)
.borderRadius(8)
.backgroundColor('#E0E0E0')
.fontColor('#333')
.onClick(() => {
this.showHistory = !this.showHistory;
})
}
.width('100%')
.height('100%')
.padding(12)
.backgroundColor('#FCFDFF')
}
}