🔥 零基础鸿蒙实战项目,今天带大家手写一款轻量化运动记录追踪应用(Exercise-tracker)。基于 HarmonyOS NEXT、ArkTS 声明式 UI 开发,无需额外权限、无需第三方插件,单页面完成全部业务逻辑,非常适合鸿蒙练手、课程作业、期末大作业。

本项目实现了运动数据录入、多类型运动选择、本周数据统计、历史记录展示、单条记录删除、日期智能适配等核心功能,覆盖 ArkTS 高频知识点:状态管理、List 列表渲染、数据聚合统计、表单校验、对象映射、日期格式化等。

一、项目概述

1.1 项目背景

随着全民健康理念普及,日常运动、健身打卡已经成为大众生活常态。但大多数用户缺少轻量化的运动数据管理工具,无法直观统计每周运动次数、运动总时长、卡路里消耗,难以量化自身运动成果。

为此我们开发一款极简运动追踪应用,以数字化方式记录每一次运动数据,自动汇总本周运动统计数据,可视化展示运动成果,帮助用户养成规律运动的习惯。

1.2 核心功能

  • ✅ 多类型运动选择:支持跑步、游泳、骑行、健身、瑜伽、篮球、羽毛球等十余种运动

  • ✅ 自定义数据录入:可填写运动时长、消耗卡路里、运动备注

  • ✅ 智能本周统计:自动筛选本周数据,统计运动次数、总时长、总卡路里

  • ✅ 历史记录管理:列表展示全部运动记录,支持单条删除

  • ✅ 图标可视化:不同运动匹配专属Emoji图标,界面直观美观

  • ✅ 智能日期适配:自动识别今天/昨天/具体日期,展示更人性化

  • ✅ 输入合法性校验:拦截负数、空值、非法字符,保证数据规范

1.3 技术栈与开发环境

  • 开发框架:HarmonyOS NEXT(API 20+)

  • 开发语言:ArkTS

  • UI范式:ArkTS 声明式UI

  • 核心组件:Column、Row、List、ListItem、Select、TextInput、Button

  • 核心语法:@State响应式、数组过滤filter、数据累加reduce、日期处理、表单校验

二、整体设计思路

2.1 页面结构分层

项目采用分层模块化设计,页面结构清晰,低耦合高可读:

  1. 顶部导航栏:页面标题 + 添加记录按钮

  2. 统计卡片层:本周运动核心数据可视化展示

  3. 弹窗表单层:新增运动记录弹窗(类型选择+数据输入)

  4. 列表展示层:全部运动历史记录,支持删除操作

2.2 数据设计

自定义运动记录实体类,统一规范数据格式,所有运动数据统一管理,方便统计、筛选、删除。

三、核心知识点精讲

3.1 响应式状态管理

通过 @State 定义表单数据、记录列表、弹窗状态,实现数据驱动视图,数据变更页面自动刷新,无需手动更新UI。

3.2 数组高阶方法统计数据

利用 filter 筛选本周数据 + reduce 累加统计,高效实现数据聚合,是鸿蒙数据类项目高频用法。

3.3 对象映射图标匹配

通过键值对映射,根据运动类型自动匹配对应图标,代码简洁、扩展性极强,新增运动类型只需新增映射配置。

3.4 日期格式化与智能适配

封装日期工具方法,实现标准日期格式化、今天/昨天智能识别,提升用户体验。

3.5 表单输入校验

对运动时长、卡路里数值做范围校验,拦截非法输入,保证后台数据合规有效。

四、完整可运行源码(Index.ets)

直接复制替换项目 pages/Index.ets,API20+ 零报错、直接运行。

/**
 * 项目名称:Exercise-tracker 运动记录追踪应用
 * 适配版本:HarmonyOS NEXT API20+
 * 功能:运动记录添加、本周数据统计、历史记录删除、日期适配
 */

// 运动记录数据实体
interface ExerciseRecord {
  id: number;
  type: string;
  duration: number;
  calories: number;
  date: string;
  notes: string;
}

@Entry
@Component
struct Index {
  // 弹窗显示状态
  @State showAddRecord: boolean = false;

  // 表单数据
  @State newType: string = "跑步";
  @State newDuration: string = "30";
  @State newCalories: string = "200";
  @State newNotes: string = "";

  // 运动记录列表
  @State records: ExerciseRecord[] = [];

  // 支持的运动类型
  private readonly exerciseTypes: string[] = [
    "跑步", "游泳", "骑行", "健身", "瑜伽",
    "跳绳", "篮球", "羽毛球", "足球", "网球"
  ];

  // 运动类型图标映射
  private readonly typeIcons: Record<string, string> = {
    "跑步": "🏃",
    "游泳": "🏊",
    "骑行": "🚴",
    "健身": "💪",
    "瑜伽": "🧘",
    "跳绳": "⚡",
    "篮球": "🏀",
    "羽毛球": "🏸",
    "足球": "⚽",
    "网球": "🎾"
  };

  // 获取对应运动图标
  private getTypeIcon(type: string): string {
    return this.typeIcons[type] || "🏃";
  }

  // 获取今日日期字符串
  private getTodayString(): string {
    const today = new Date();
    const year = today.getFullYear();
    const month = (today.getMonth() + 1).toString().padStart(2, "0");
    const day = today.getDate().toString().padStart(2, "0");
    return `${year}-${month}-${day}`;
  }

  // 日期格式化
  private formatDate(dateStr: string): string {
    const date = new Date(dateStr);
    const month = (date.getMonth() + 1).toString().padStart(2, "0");
    const day = date.getDate().toString().padStart(2, "0");
    return `${month}月${day}日`;
  }

  // 日期转标准字符串
  private formatDateToString(date: Date): string {
    const year = date.getFullYear();
    const month = (date.getMonth() + 1).toString().padStart(2, "0");
    const day = date.getDate().toString().padStart(2, "0");
    return `${year}-${month}-${day}`;
  }

  // 智能相对日期
  private getRelativeDate(dateStr: string): string {
    const today = this.getTodayString();
    if (dateStr === today) return "今天";

    const yesterday = new Date();
    yesterday.setDate(yesterday.getDate() - 1);
    if (dateStr === this.formatDateToString(yesterday)) return "昨天";

    return this.formatDate(dateStr);
  }

  // 输入校验-时长
  private validateDuration(input: string): boolean {
    const duration = parseInt(input);
    return !isNaN(duration) && duration > 0 && duration <= 480;
  }

  // 输入校验-卡路里
  private validateCalories(input: string): boolean {
    const calories = parseInt(input);
    return !isNaN(calories) && calories > 0 && calories <= 5000;
  }

  // 获取本周统计数据
  private getWeeklyStats() {
    const today = new Date();
    const weekAgo = new Date(today.getTime() - 7 * 24 * 60 * 60 * 1000);

    const weeklyRecords = this.records.filter(item => {
      const d = new Date(item.date);
      return d >= weekAgo && d <= today;
    });

    return {
      count: weeklyRecords.length,
      totalDuration: weeklyRecords.reduce((sum, item) => sum + item.duration, 0),
      totalCalories: weeklyRecords.reduce((sum, item) => sum + item.calories, 0)
    };
  }

  // 清空表单
  private clearForm() {
    this.newType = "跑步";
    this.newDuration = "30";
    this.newCalories = "200";
    this.newNotes = "";
  }

  // 添加运动记录
  private addRecord() {
    if (!this.validateDuration(this.newDuration) || !this.validateCalories(this.newCalories)) {
      return;
    }
    const newItem: ExerciseRecord = {
      id: Date.now(),
      type: this.newType,
      duration: parseInt(this.newDuration),
      calories: parseInt(this.newCalories),
      date: this.getTodayString(),
      notes: this.newNotes.trim()
    };
    this.records.unshift(newItem);
    this.clearForm();
    this.showAddRecord = false;
  }

  // 删除单条记录
  private deleteRecord(id: number) {
    this.records = this.records.filter(item => item.id !== id);
  }

  // 构建新增弹窗
  @Builder
  AddDialog() {
    Column() {
      Text("新增运动记录")
        .fontSize(20)
        .fontWeight(FontWeight.Bold)
        .margin({ bottom: 20 })

      // 运动类型选择
      Text("运动类型")
        .fontSize(14)
        .fontColor("#333")
        .width("100%")
      Select(this.exerciseTypes.map(item => ({ value: item })))
        .value(this.newType)
        .width("100%")
        .height(44)
        .margin({ bottom: 12 })
        .onSelect((index: number) => {
          this.newType = this.exerciseTypes[index];
        })

      // 运动时长
      Text("运动时长(分钟)")
        .fontSize(14)
        .fontColor("#333")
        .width("100%")
      TextInput({ text: this.newDuration, placeholder: "请输入有效时长" })
        .width("100%")
        .height(44)
        .inputType(InputType.Number)
        .onChange(val => this.newDuration = val)
        .margin({ bottom: 12 })

      // 卡路里
      Text("消耗卡路里")
        .fontSize(14)
        .fontColor("#333")
        .width("100%")
      TextInput({ text: this.newCalories, placeholder: "请输入卡路里" })
        .width("100%")
        .height(44)
        .inputType(InputType.Number)
        .onChange(val => this.newCalories = val)
        .margin({ bottom: 12 })

      // 备注
      Text("运动备注")
        .fontSize(14)
        .fontColor("#333")
        .width("100%")
      TextInput({ text: this.newNotes, placeholder: "简单描述本次运动情况" })
        .width("100%")
        .height(44)
        .onChange(val => this.newNotes = val)
        .margin({ bottom: 20 })

      // 按钮组
      Row({ space: 15 }) {
        Button("取消")
          .layoutWeight(1)
          .backgroundColor("#f5f5f5")
          .fontColor("#666")
          .onClick(() => {
            this.showAddRecord = false;
            this.clearForm();
          })
        Button("保存")
          .layoutWeight(1)
          .backgroundColor("#10b981")
          .onClick(() => this.addRecord())
      }
    }
    .padding(20)
    .width("90%")
    .backgroundColor("#fff")
    .borderRadius(16)
  }

  build() {
    Column() {
      // 顶部导航
      Row() {
        Text("运动记录追踪")
          .fontSize(22)
          .fontWeight(FontWeight.Bold)
        Blank()
        Button("+ 添加记录")
          .backgroundColor("#10b981")
          .fontSize(14)
          .onClick(() => this.showAddRecord = true)
      }
      .width("100%")
      .padding({ left: 16, right: 16, top: 20, bottom: 16 })

      // 本周统计卡片
      Column() {
        Text("本周统计")
          .fontSize(16)
          .fontColor("#64748b")
          .width("100%")
          .margin({ bottom: 12 })

        Row() {
          Column() {
            Text(`${this.getWeeklyStats().count}`)
              .fontSize(28)
              .fontWeight(FontWeight.Bold)
              .fontColor("#10b981")
            Text("次运动")
              .fontSize(12)
              .fontColor("#666")
          }
          .layoutWeight(1)
          .alignItems(HorizontalAlign.Center)

          Column() {
            Text(`${this.getWeeklyStats().totalDuration}`)
              .fontSize(28)
              .fontWeight(FontWeight.Bold)
              .fontColor("#10b981")
            Text("总分钟")
              .fontSize(12)
              .fontColor("#666")
          }
          .layoutWeight(1)
          .alignItems(HorizontalAlign.Center)

          Column() {
            Text(`${this.getWeeklyStats().totalCalories}`)
              .fontSize(28)
              .fontWeight(FontWeight.Bold)
              .fontColor("#10b981")
            Text("千卡")
              .fontSize(12)
              .fontColor("#666")
          }
          .layoutWeight(1)
          .alignItems(HorizontalAlign.Center)
        }
        .width("100%")
      }
      .width("92%")
      .padding(20)
      .backgroundColor("#fff")
      .borderRadius(16)
      .margin({ bottom: 16 })

      // 记录列表
      if (this.records.length > 0) {
        List() {
          ForEach(this.records, (item: ExerciseRecord) => {
            ListItem() {
              Row() {
                Text(this.getTypeIcon(item.type))
                  .fontSize(32)
                  .margin({ right: 12 })

                Column() {
                  Text(item.type)
                    .fontSize(16)
                    .fontWeight(FontWeight.Medium)
                  Text(`${item.duration}分钟 · ${item.calories}千卡`)
                    .fontSize(12)
                    .fontColor("#64748b")
                    .margin({ top: 2 })
                  if (item.notes) {
                    Text(item.notes)
                      .fontSize(12)
                      .fontColor("#999")
                      .maxLines(1)
                      .textOverflow({ overflow: TextOverflow.Ellipsis })
                      .margin({ top: 2 })
                  }
                }
                .layoutWeight(1)

                Column() {
                  Text(this.getRelativeDate(item.date))
                    .fontSize(12)
                    .fontColor("#64748b")
                  Button("删除")
                    .fontSize(12)
                    .height(24)
                    .backgroundColor("#f43f5e")
                    .margin({ top: 6 })
                    .onClick(() => this.deleteRecord(item.id))
                }
                .alignItems(HorizontalAlign.End)
              }
              .width("100%")
              .padding(16)
              .backgroundColor("#fff")
              .borderRadius(12)
              .margin({ bottom: 8 })
            }
          })
        }
        .width("92%")
        .layoutWeight(1)
      } else {
        Column() {
          Text("暂无运动记录,点击上方按钮添加")
            .fontSize(14)
            .fontColor("#999")
        }
        .layoutWeight(1)
        .width("100%")
        .justifyContent(FlexAlign.Center)
      }

      // 新增弹窗
      if (this.showAddRecord) {
        Stack() {
          ColorBlock().width("100%").height("100%").color(0x77000000)
          this.AddDialog()
        }
      }
    }
    .width("100%")
    .height("100%")
    .backgroundColor("#f6f7f9")
  }
}

五、项目运行流程

  1. 初始化状态:应用启动后页面无记录,展示空白提示,本周统计数据默认为0。

  2. 新增运动记录:点击右上角添加按钮,弹出表单,选择运动类型、填写时长、卡路里和备注,保存即可新增记录。

  3. 自动统计数据:新增记录后,页面自动重新计算本周运动次数、总时长、总卡路里,实时刷新统计卡片。

  4. 智能日期展示:当天记录显示「今天」、昨日显示「昨天」、更早记录显示具体月日。

  5. 删除记录:点击单条记录删除按钮,即刻移除数据,统计数据同步更新。

六、开发踩坑与问题解决

问题1:输入非法数据导致统计异常

解决方案:增加数值范围校验,限制时长0-480分钟、卡路里0-5000千卡,拦截空值、负数、非数字字符。

问题2:新增/删除数据后统计不刷新

解决方案:统计方法不做缓存,页面渲染时每次重新执行计算,保证数据实时同步。

问题3:日期筛选范围不准确

解决方案:通过时间戳精准计算7天时间范围,使用Date对象对比筛选本周数据,避免字符串匹配误差。

问题4:弹窗重复数据残留

解决方案:关闭弹窗时自动清空表单数据,保证下次打开为默认初始值。

七、项目扩展方向(进阶优化)

  • 💾 本地持久化:集成Preferences,退出重启保留所有运动记录

  • 📊 图表可视化:引入图表组件,展示近7天运动趋势

  • 🎯 运动目标:新增每日/每周运动目标打卡功能

  • 🔍 搜索筛选:按运动类型、日期筛选历史记录

  • ✏️ 编辑功能:支持修改已保存的运动记录

八、项目总结

本项目是一款高性价比鸿蒙入门实战项目,麻雀虽小五脏俱全。完整覆盖鸿蒙开发核心技能:响应式状态管理、表单开发、弹窗自定义、数组数据筛选与统计、列表渲染、日期工具封装、数据校验等。

代码结构规范、注释清晰、无冗余逻辑,非常适合新手练手、课程实训、期末作业提交,也可作为健康类App的基础模板二次开发。


项目名称:Exercise-tracker 运动记录追踪应用

适配SDK:HarmonyOS NEXT API20+

运行方式:新建空白项目,替换Index.ets直接运行

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐