【鸿蒙Next实战】从零开发运动记录追踪应用(ArkTS+数据统计+历史记录管理)
🔥 零基础鸿蒙实战项目,今天带大家手写一款轻量化运动记录追踪应用(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 页面结构分层
项目采用分层模块化设计,页面结构清晰,低耦合高可读:
-
顶部导航栏:页面标题 + 添加记录按钮
-
统计卡片层:本周运动核心数据可视化展示
-
弹窗表单层:新增运动记录弹窗(类型选择+数据输入)
-
列表展示层:全部运动历史记录,支持删除操作
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")
}
}
五、项目运行流程
-
初始化状态:应用启动后页面无记录,展示空白提示,本周统计数据默认为0。
-
新增运动记录:点击右上角添加按钮,弹出表单,选择运动类型、填写时长、卡路里和备注,保存即可新增记录。
-
自动统计数据:新增记录后,页面自动重新计算本周运动次数、总时长、总卡路里,实时刷新统计卡片。
-
智能日期展示:当天记录显示「今天」、昨日显示「昨天」、更早记录显示具体月日。
-
删除记录:点击单条记录删除按钮,即刻移除数据,统计数据同步更新。
六、开发踩坑与问题解决
问题1:输入非法数据导致统计异常
解决方案:增加数值范围校验,限制时长0-480分钟、卡路里0-5000千卡,拦截空值、负数、非数字字符。
问题2:新增/删除数据后统计不刷新
解决方案:统计方法不做缓存,页面渲染时每次重新执行计算,保证数据实时同步。
问题3:日期筛选范围不准确
解决方案:通过时间戳精准计算7天时间范围,使用Date对象对比筛选本周数据,避免字符串匹配误差。
问题4:弹窗重复数据残留
解决方案:关闭弹窗时自动清空表单数据,保证下次打开为默认初始值。
七、项目扩展方向(进阶优化)
-
💾 本地持久化:集成Preferences,退出重启保留所有运动记录
-
📊 图表可视化:引入图表组件,展示近7天运动趋势
-
🎯 运动目标:新增每日/每周运动目标打卡功能
-
🔍 搜索筛选:按运动类型、日期筛选历史记录
-
✏️ 编辑功能:支持修改已保存的运动记录
八、项目总结
本项目是一款高性价比鸿蒙入门实战项目,麻雀虽小五脏俱全。完整覆盖鸿蒙开发核心技能:响应式状态管理、表单开发、弹窗自定义、数组数据筛选与统计、列表渲染、日期工具封装、数据校验等。
代码结构规范、注释清晰、无冗余逻辑,非常适合新手练手、课程实训、期末作业提交,也可作为健康类App的基础模板二次开发。
项目名称:Exercise-tracker 运动记录追踪应用
适配SDK:HarmonyOS NEXT API20+
运行方式:新建空白项目,替换Index.ets直接运行
更多推荐





所有评论(0)