HarmonyOS5 运动健康app(二):健康跑步(附代码)

一、数据模型:构建运动记录的数字骨架

代码通过RunRecord接口定义了跑步数据的核心结构:

复制代码
interface RunRecord {
  id: string;         // 记录唯一标识
  date: Date;         // 跑步日期
  distance: number;   // 距离(公里)
  duration: number;   // 时长(分钟)
  pace: number;       // 配速(分钟/公里)
}

这一模型以"距离-时长-配速"为核心维度,通过iddate建立时间轴索引。在实际跑步场景中,当用户点击"结束跑步"时,系统会基于实时数据生成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:控制历史记录列表的展开/收起状态

核心状态更新逻辑体现在startRunendRun方法中。当用户点击"开始跑步"时,系统启动定时器以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特性实现动态渲染:

  1. 统计头部组件(StatsHeader)
    采用三栏式布局展示总跑步次数、总距离与平均配速,通过reduce方法对历史记录进行聚合计算:

    Text(${this.runRecords.reduce((sum, r) => sum + r.distance, 0).toFixed(1)}km)

数据展示遵循"数值+单位"的层级结构,大号字体突出核心数字,小号字体标注单位与说明,符合用户"先看结果再辨含义"的认知习惯。

  1. 跑步数据卡片(RunDataCard)
    通过isRunning状态动态切换显示逻辑:跑步中时突出距离与时长数据,搭配配速与时长的分栏展示;跑步结束后则展示今日汇总数据。按钮设计采用绿色(开始)与红色(结束)的高对比度配色,强化操作反馈。
  2. 历史记录列表(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')
  }
}
相关推荐
GIS之路9 小时前
OpenLayers 图层叠加控制
前端·信息可视化
90后的晨仔9 小时前
ArkTS 语言中的number和Number区别是什么?
前端·harmonyos
灏瀚星空11 小时前
高频交易技术:订单簿分析与低延迟架构——从Level 2数据挖掘到FPGA硬件加速的全链路解决方案
人工智能·python·算法·信息可视化·fpga开发·架构·数据挖掘
二流小码农12 小时前
鸿蒙开发:CodeGenie万能卡片生成
android·ios·harmonyos
爱笑的眼睛1112 小时前
HarmonyOS 组件复用面试宝典 [特殊字符]
华为·面试·harmonyos·harmonyos next
袋鼠云数栈12 小时前
当空间与数据联动,会展中心如何打造智慧运营新范式?
大数据·人工智能·信息可视化
.生产的驴13 小时前
SpringBoot 服务器监控 监控系统开销 获取服务器系统的信息用户信息 运行信息 保持稳定
服务器·spring boot·分布式·后端·spring·spring cloud·信息可视化
半醉看夕阳13 小时前
HarmonyOS开发 ArkTS 之 var 、let、const 变量声明的剖析
typescript·harmonyos·arkts
Geekwaner14 小时前
鸿蒙TaskPool多线程开发指南
harmonyos