原创不易,码字不易!本篇为鸿蒙新手高质量实战项目,适配课程作业、期末实训、练手入门,代码完整可直接运行,无第三方依赖、无报错,适配HarmonyOS NEXT最新版本。
文章标签:#鸿蒙开发 #HarmonyOS NEXT #ArkTS #移动端实战 #课程作业
🔥 适合人群:鸿蒙零基础初学者、高校移动端开发实训学生、需要期末作业/练手项目的开发者
🔥 项目优势:纯原生ArkTS开发、单页面轻量化实现、功能完整、代码规范、注释详细、可直接提交作业
一、项目前言
随着智能穿戴、全民运动的普及,轻量化运动数据记录工具成为日常高频应用场景。相比于复杂的大型健康App,极简的本地运动记录工具更加轻便、无广告、无需联网,能够快速记录用户每一次运动信息,量化运动成果。
本文基于HarmonyOS NEXT、API20+,使用原生ArkTS声明式UI开发一款运动数据记录与统计小程序。项目摒弃冗余复杂逻辑,聚焦核心业务,完整实现运动录入、数据统计、历史管理、表单校验、智能日期适配等功能。
全程零第三方插件、零额外权限,所有逻辑单页面完成,完美覆盖鸿蒙入门核心考点:状态管理、列表渲染、数组高阶运算、自定义弹窗、表单校验、工具方法封装,是非常优质的期末作业与实战练手项目。
二、项目整体介绍
2.1 项目开发背景
日常健身、跑步、球类运动已成为大众主流生活方式,但多数用户缺乏轻量化的数据统计工具,无法直观查看每周运动次数、运动总时长、热量消耗,难以量化自身健身效果,无法针对性制定运动计划。
针对以上痛点,本项目开发一款本地运动记录工具,专注个人运动数据轻量化管理,自动统计近一周运动数据,可视化展示运动成果,帮助用户清晰掌握运动状态,培养规律健身习惯。
2.2 核心功能清单
本项目功能完整、贴合教学需求,全部为自主原生实现:
•多品类运动选择:内置跑步、游泳、健身、球类等十余种主流运动类型,适配日常运动场景
•个性化数据录入:支持自定义运动时长、消耗卡路里、运动备注信息录入
•一周数据智能统计:自动筛选近7日运动数据,统计运动次数、总时长、总消耗热量
•可视化图标适配:不同运动类型自动匹配专属Emoji图标,界面简洁美观、辨识度高
•人性化日期展示:智能区分今天、昨天、历史日期,告别生硬时间戳展示
•完整历史记录管理:实时渲染全部运动记录,支持单条记录一键删除
•精细化数据校验:拦截负数、空值、超限数值、非法字符,保障数据规范性
2.3 开发环境与技术栈
•开发工具:DevEco Studio 最新稳定版
•适配系统:HarmonyOS NEXT
•最低API版本:API 20及以上
•开发语言:ArkTS
•核心技术:声明式UI、@State响应式状态管理、数组filter/reduce高阶运算、自定义Builder弹窗、日期格式化封装、表单合法性校验
三、项目架构与设计思路
3.1 页面分层设计
项目采用高内聚、低耦合的分层模块化设计,页面结构清晰,可读性极强,符合工业级开发规范:
1.顶部导航层:展示项目标题、新增记录功能入口,界面简洁整洁
2.数据统计层:卡片式展示本周核心运动数据,实现数据可视化
3.弹窗表单层:自定义弹窗实现数据录入,包含类型选择、数值输入、备注填写
4.历史列表层:循环渲染所有运动记录,支持删除交互,空数据友好提示
3.2 数据结构设计
自定义标准化运动记录实体结构体,统一所有数据字段规范,方便后续筛选、统计、删除、渲染操作,从根源保证代码整洁、易于拓展。
四、核心技术知识点精讲
4.1 响应式状态管理机制
项目全程采用 @State 装饰器管理页面所有动态状态,包含弹窗显示状态、表单输入数据、运动记录列表等。依托ArkTS数据驱动视图的核心特性,数据发生变更时,页面自动精准刷新,无需手动操作DOM,极大简化开发逻辑,也是鸿蒙开发最核心的基础知识点。
4.2 数组高阶方法实现数据统计
本项目核心亮点即为原生数组运算统计数据,通过 filter 方法精准筛选近7日的有效运动记录,再通过 reduce 方法累加运动时长与卡路里数据。相比于传统for循环遍历,高阶方法代码更精简、逻辑更清晰、执行效率更高,是鸿蒙数据类项目的高频考点。
4.3 键值对映射实现图标自动匹配
通过自定义键值对映射对象,建立运动类型与Emoji图标的一一对应关系。页面渲染时根据记录的运动类型,自动匹配展示对应图标,代码扩展性极强。后续需要新增运动类型,仅需在映射表中添加配置即可,无需修改核心业务逻辑。
4.4 封装工具方法统一处理日期逻辑
统一封装日期格式化、日期比对、相对日期转换工具函数,实现日期标准化存储、人性化展示。解决了新手开发中常见的日期筛选不准、时间格式混乱、展示生硬等问题,代码复用性高。
4.5 表单输入合法性校验
针对运动时长、卡路里两大核心输入项设置合理数值区间,自动拦截空值、负数、超大超限数值、非数字字符,有效规避非法数据导致的统计错乱、页面异常问题,保障项目运行稳定性。
五、完整可运行源码(Index.ets)
✅ 使用说明:新建鸿蒙空白项目,项目名 ExerciseRecordTracker,替换 pages/Index.ets 全部代码,直接编译运行,零报错、零兼容问题。

在这里插入代码片

/**
 * 项目名称:ExerciseRecordTracker 运动数据记录统计小程序
 * 适配版本:HarmonyOS NEXT API20+
 * 功能:运动数据录入、本周智能统计、历史记录管理、表单校验、智能日期适配
 * 适用场景:鸿蒙课程作业、期末实训、新手实战练手
 */

// 自定义运动记录数据实体类
interface ExerciseItem {
  id: number;         // 唯一标识ID
  sportType: string;  // 运动类型
  duration: number;   // 运动时长(分钟)
  calorie: number;    // 消耗卡路里
  recordDate: string; // 记录日期
  remark: string;     // 运动备注
}

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

  // 表单录入数据
  @State selectSportType: string = "跑步";
  @State inputDuration: string = "30";
  @State inputCalorie: string = "200";
  @State inputRemark: string = "";

  // 运动记录全局列表
  @State exerciseList: ExerciseItem[] = [];

  // 全部可选运动类型
  private sportTypeArray: string[] = [
    "跑步", "游泳", "骑行", "健身", "瑜伽",
    "跳绳", "篮球", "羽毛球", "足球", "网球"
  ];

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

  // 根据运动类型获取对应图标
  private getSportIcon(type: string): string {
    return this.sportIconMap[type] || "🏃";
  }

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

  // 日期格式化 转为月日格式
  private formatMonthDay(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}`;
  }

  // Date对象转标准日期字符串
  private dateToStandardStr(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 getSmartDateText(dateStr: string): string {
    const today = this.getStandardTodayDate();
    if (dateStr === today) return "今天";

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

    return this.formatMonthDay(dateStr);
  }

  // 校验运动时长合法性
  private verifyDuration(val: string): boolean {
    const num = parseInt(val);
    return !isNaN(num) && num > 0 && num <= 480;
  }

  // 校验卡路里合法性
  private verifyCalorie(val: string): boolean {
    const num = parseInt(val);
    return !isNaN(num) && num > 0 && num <= 5000;
  }

  // 计算本周运动统计数据
  private calcWeeklyStatistic() {
    const nowTime = new Date();
    // 计算7天前时间节点
    const weekBeforeTime = new Date(nowTime.getTime() - 7 * 24 * 60 * 60 * 1000);
    // 筛选本周所有运动记录
    const weekDataList = this.exerciseList.filter(item => {
      const itemTime = new Date(item.recordDate);
      return itemTime >= weekBeforeTime && itemTime <= nowTime;
    })
    // 聚合统计数据
    return {
      totalCount: weekDataList.length,
      totalTime: weekDataList.reduce((sum, item) => sum + item.duration, 0),
      totalCalorie: weekDataList.reduce((sum, item) => sum + item.calorie, 0)
    }
  }

  // 重置表单数据
  private resetFormData() {
    this.selectSportType = "跑步";
    this.inputDuration = "30";
    this.inputCalorie = "200";
    this.inputRemark = "";
  }

  // 新增运动记录
  private addNewRecord() {
    // 数据校验拦截
    if (!this.verifyDuration(this.inputDuration) || !this.verifyCalorie(this.inputCalorie)) {
      return;
    }
    // 组装新数据
    const newRecord: ExerciseItem = {
      id: Date.now(),
      sportType: this.selectSportType,
      duration: parseInt(this.inputDuration),
      calorie: parseInt(this.inputCalorie),
      recordDate: this.getStandardTodayDate(),
      remark: this.inputRemark.trim()
    }
    // 头部插入新记录,最新数据置顶
    this.exerciseList.unshift(newRecord);
    // 重置表单、关闭弹窗
    this.resetFormData();
    this.isShowDialog = false;
  }

  // 删除单条运动记录
  private deleteSingleRecord(id: number) {
    this.exerciseList = this.exerciseList.filter(item => item.id !== id);
  }

  // 自定义新增记录弹窗
  @Builder
  AddExerciseDialog() {
    Column() {
      Text("新增运动记录")
        .fontSize(22)
        .fontWeight(FontWeight.Bold)
        .margin({ bottom: 24 })

      Text("选择运动类型")
        .fontSize(14)
        .fontColor("#333333")
        .width("100%")
      Select(this.sportTypeArray.map(item => ({ value: item })))
        .value(this.selectSportType)
        .width("100%")
        .height(48)
        .margin({ bottom: 16 })
        .onSelect((index: number) => {
          this.selectSportType = this.sportTypeArray[index];
        })

      Text("运动时长(分钟)")
        .fontSize(14)
        .fontColor("#333333")
        .width("100%")
      TextInput({ text: this.inputDuration, placeholder: "请输入1-480有效数值" })
        .width("100%")
        .height(48)
        .inputType(InputType.Number)
        .onChange(val => this.inputDuration = val)
        .margin({ bottom: 16 })

      Text("消耗卡路里")
        .fontSize(14)
        .fontColor("#333333")
        .width("100%")
      TextInput({ text: this.inputCalorie, placeholder: "请输入1-5000有效数值" })
        .width("100%")
        .height(48)
        .inputType(InputType.Number)
        .onChange(val => this.inputCalorie = val)
        .margin({ bottom: 16 })

      Text("运动备注(选填)")
        .fontSize(14)
        .fontColor("#333333")
        .width("100%")
      TextInput({ text: this.inputRemark, placeholder: "记录运动场景、心得等信息" })
        .width("100%")
        .height(48)
        .onChange(val => this.inputRemark = val)
        .margin({ bottom: 24 })

      Row({ space: 20 }) {
        Button("取消")
          .layoutWeight(1)
          .height(44)
          .backgroundColor("#EEEEEE")
          .fontColor("#666666")
          .onClick(() => {
            this.isShowDialog = false;
            this.resetFormData();
          })
        Button("保存记录")
          .layoutWeight(1)
          .height(44)
          .backgroundColor("#1677ff")
          .onClick(() => this.addNewRecord())
      }
    }
    .width("92%")
    .padding(24)
    .backgroundColor(Color.White)
    .borderRadius(20)
  }

  build() {
    Column() {
      // 顶部导航栏
      Row() {
        Text("个人运动记录中心")
          .fontSize(24)
          .fontWeight(FontWeight.Bold)
          .fontColor("#1F2937")
        Blank()
        Button("+ 新增记录")
          .backgroundColor("#1677ff")
          .fontSize(14)
          .borderRadius(20)
          .onClick(() => this.isShowDialog = true)
      }
      .width("100%")
      .padding({ left: 20, right: 20, top: 24, bottom: 16 })

      // 本周数据统计卡片
      Column() {
        Text("本周运动统计数据")
          .fontSize(18)
          .fontColor("#6B7280")
          .width("100%")
          .margin({ bottom: 16 })

        Row({ space: 10 }) {
          Column() {
            Text(`${this.calcWeeklyStatistic().totalCount}`)
              .fontSize(30)
              .fontWeight(FontWeight.Bold)
              .fontColor("#1677ff")
            Text("运动次数")
              .fontSize(13)
              .fontColor("#9CA3AF")
              .margin({ top: 4 })
          }
          .layoutWeight(1)
          .alignItems(HorizontalAlign.Center)

          Column() {
            Text(`${this.calcWeeklyStatistic().totalTime}`)
              .fontSize(30)
              .fontWeight(FontWeight.Bold)
              .fontColor("#1677ff")
            Text("总时长(分钟)")
              .fontSize(13)
              .fontColor("#9CA3AF")
              .margin({ top: 4 })
          }
          .layoutWeight(1)
          .alignItems(HorizontalAlign.Center)

          Column() {
            Text(`${this.calcWeeklyStatistic().totalCalorie}`)
              .fontSize(30)
              .fontWeight(FontWeight.Bold)
              .fontColor("#1677ff")
            Text("总卡路里")
              .fontSize(13)
              .fontColor("#9CA3AF")
              .margin({ top: 4 })
          }
          .layoutWeight(1)
          .alignItems(HorizontalAlign.Center)
        }
      }
      .width("92%")
      .padding(24)
      .backgroundColor(Color.White)
      .borderRadius(20)
      .margin({ bottom: 20 })

      // 运动记录列表区域
      if (this.exerciseList.length > 0) {
        List() {
          ForEach(this.exerciseList, (item: ExerciseItem) => {
            ListItem() {
              Row() {
                Text(this.getSportIcon(item.sportType))
                  .fontSize(36)
                  .margin({ right: 16 })

                Column() {
                  Text(item.sportType)
                    .fontSize(17)
                    .fontWeight(FontWeight.Medium)
                    .fontColor("#1F2937")
                  Text(`时长${item.duration}分钟 · 消耗${item.calorie}千卡`)
                    .fontSize(13)
                    .fontColor("#6B7280")
                    .margin({ top: 4 })
                  if (item.remark) {
                    Text(item.remark)
                      .fontSize(12)
                      .fontColor("#9CA3AF")
                      .maxLines(1)
                      .textOverflow({ overflow: TextOverflow.Ellipsis })
                      .margin({ top: 2 })
                  }
                }
                .layoutWeight(1)

                Column() {
                  Text(this.getSmartDateText(item.recordDate))
                    .fontSize(12)
                    .fontColor("#6B7280")
                  Button("删除")
                    .fontSize(12)
                    .height(26)
                    .backgroundColor("#F53F3F")
                    .margin({ top: 8 })
                    .onClick(() => this.deleteSingleRecord(item.id))
                }
                .alignItems(HorizontalAlign.End)
              }
              .width("100%")
              .padding(18)
              .backgroundColor(Color.White)
              .borderRadius(16)
              .margin({ bottom: 10 })
            }
          })
        }
        .width("92%")
        .layoutWeight(1)
      } else {
        Column() {
          Text("暂无运动记录,点击上方按钮添加你的第一条运动记录")
            .fontSize(14)
            .fontColor("#9CA3AF")
            .textAlign(TextAlign.Center)
        }
        .layoutWeight(1)
        .width("100%")
        .justifyContent(FlexAlign.Center)
      }

      // 弹窗遮罩层
      if (this.isShowDialog) {
        Stack() {
          Rect().width("100%").height("100%").fillColor(0x88000000)
          this.AddExerciseDialog()
        }
        .width("100%")
        .height("100%")
        .position({ x: 0, y: 0 })
      }
    }
    .width("100%")
    .height("100%")
    .backgroundColor("#F5F7FA")
  }
}

在这里插入图片描述

六、项目运行流程详解

  1. 项目初始化:应用启动后,运动记录列表为空,统计数据默认归零,页面展示空数据友好提示,整体界面简洁干净。
  2. 新增运动记录:点击页面右上角新增按钮唤起自定义弹窗,选择对应运动类型,填写合规的时长、卡路里数据与备注,点击保存即可完成新增,新记录自动置顶展示。
  3. 实时数据统计更新:页面每次渲染都会重新执行统计方法,新增、删除记录后,本周运动次数、总时长、总卡路里数据会自动实时刷新,无需手动干预。
  4. 智能时间展示:系统自动识别记录日期,当天记录展示「今天」、昨日记录展示「昨天」,更早的记录展示具体月日,大幅优化用户体验。
  5. 历史记录删除:点击单条记录的删除按钮,可即时移除对应数据,列表视图与统计数据同步更新,数据实时联动。
    七、开发常见问题与解决方案
    问题1:输入非法数值导致统计数据错乱
    解决方案:针对性封装双重校验方法,严格限制运动时长、卡路里的数值区间,自动拦截空值、负数、超大超限数值,从源头规避异常数据,保证统计结果精准无误。
    问题2:数据增减后统计卡片不刷新
    解决方案:统计方法不做静态缓存,采用实时计算逻辑,页面每次渲染都会重新遍历计算最新数据,确保视图与数据完全同步。
    问题3:弹窗关闭后残留上次填写数据
    解决方案:监听弹窗关闭事件,每次关闭弹窗自动重置表单为默认初始值,彻底解决数据残留问题。
    问题4:本周日期筛选范围不准确
    解决方案:通过时间戳精准计算7天时间区间,使用Date对象时间比对筛选数据,规避字符串匹配带来的误差问题,筛选结果精准可靠。
    八、项目进阶拓展方向
    本项目基础功能完善,可基于此持续迭代进阶功能,适合二次开发学习:
    •新增本地数据持久化功能,应用重启保留所有历史记录
    •接入图表组件,实现运动数据趋势可视化展示
    •新增运动目标打卡、数据成就体系
    •实现记录编辑、批量删除、运动类型筛选功能
    •适配深色模式,优化多场景视觉体验
    九、项目总结
    本项目是一款高性价比、高适配度的鸿蒙Next零基础实战项目,完整覆盖鸿蒙ArkTS开发的核心基础知识点,包含UI布局、响应式状态管理、自定义弹窗、表单校验、数组数据统计、日期工具封装、列表渲染与交互等高频核心技能。
    项目代码结构规范、逻辑清晰、注释完善、无冗余报错,界面美观适配移动端,功能完整贴合课程实训与期末作业要求,上手简单、运行稳定,非常适合鸿蒙新手巩固基础、积累实战经验。同时可作为健康类原生App的基础模板,进行个性化二次开发。
    项目名称:ExerciseRecordTracker 运动数据记录统计小程序
    适配版本:HarmonyOS NEXT API20+
Logo

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

更多推荐