# [特殊字符] 新手零基础学ArkUI13—— 手把手教你开发运动记录器App
摘要:运动记录器App开发指南 本文手把手教你使用ArkUI开发一个运动记录器App,主要功能包括: 记录运动类型、时长、卡路里等数据 支持列表展示、滑动删除、搜索筛选 提供今日运动数据统计 核心知识点: List+LazyForEach实现高性能列表 @State管理表单和数据状态 计算属性实现数据统计 日期选择器和表单验证 开发环境要求: DevEco Studio 4.0+ HarmonyO
🏃 新手零基础学ArkUI13—— 手把手教你开发运动记录器App
📖 应用场景
“小张每天跑步想记录自己的运动数据……”
“健身房教练想帮学员统计每周的运动量和消耗卡路里……”
运动记录器 是健康类 App 的核心功能。通过开发这个应用,你将学到比汇率转换器更深入的 ArkUI 知识:
| 技术点 | 说明 |
|---|---|
List + LazyForEach |
高性能列表渲染,大数据不卡顿 |
DatePicker 日期选择器 |
处理日期数据 |
@State 数组操作 |
增删改查完整 CRUD |
Toggle 开关组件 |
控制编辑模式 |
| 统计汇总 | 求和、平均、最大值的计算 |
| 搜索筛选 | 按运动类型搜索记录 |
SwipeAction |
滑动删除列表项 |
| 数据验证 | 表单输入的校验逻辑 |
⚙️ 运行环境要求
| 项目 | 要求 |
|---|---|
| IDE | DevEco Studio 4.0 Release 以上 |
| SDK | HarmonyOS SDK API 9+ |
| 调试设备 | 模拟器或真机(推荐 API 9+) |
| Node.js | 18.x LTS |
环境配置请参考第一篇,本篇不再重复。如果你还没配置环境,先去看第一篇的"运行环境要求"部分。
🧩 实战小案例:运动记录器
📸 最终效果预览
┌─────────────────────────────────────┐
│ 🏃 运动记录器 📊 🔍 │
├─────────────────────────────────────┤
│ ┌─ 今日概览 ──────────────────┐ │
│ │ 🏃 运动 2 项 │ ⏱ 85分钟 │ │
│ │ 🔥 消耗 520千卡│ 📅 今日 │ │
│ └──────────────────────────────┘ │
│ │
│ ┌─ 添加运动记录 ───────────────┐ │
│ │ 运动类型: [跑步 🏃] │ │
│ │ 时长(分): [45 ] │ │
│ │ 卡路里: [320 ] │ │
│ │ 运动日期: [2025-01-15] │ │
│ │ [✅ 保存记录] │ │
│ └──────────────────────────────┘ │
│ │
│ ┌─ 运动记录列表(近期3条)───┐ │
│ │ 🏃 跑步 45min 320kcal ← 滑 │ │
│ │ 🏊 游泳 40min 200kcal │ │
│ │ 🚴 骑行 30min 180kcal │ │
│ └──────────────────────────────┘ │
└─────────────────────────────────────┘
🧠 知识点预习:List 组件详解
在开始编码前,先搞懂 ArkUI 中最重要的列表组件——List。
List 的三层嵌套结构
List({ space: 间距 }) { // 第一层:List 容器
ForEach(数据源, (item) => { // 第二层:循环遍历
ListItem() { // 第三层:每一项
// 你的 UI 内容
}
})
}
| 层级 | 作用 | 类比 HTML |
|---|---|---|
List |
滚动列表容器 | <ul> |
ForEach |
循环渲染数据 | .map() |
ListItem |
单个列表项 | <li> |
为什么用 LazyForEach 而不是 ForEach?
// ✅ 大数据量推荐(只渲染可见项)
LazyForEach(this.dataSource, (item) => {
ListItem() { ... }
})
// ❌ 小数据量(全部渲染)
ForEach(this.dataSource, (item) => {
ListItem() { ... }
})
💡 最佳实践: 数据量 < 50 条用
ForEach即可;超过 50 条或不确定用LazyForEach。运动记录通常在几十条级别,用ForEach完全够用。
🧱 第一步:定义数据结构
// ===== 运动记录接口 =====
interface SportRecord {
id: number; // 唯一标识(时间戳)
type: string; // 运动类型:跑步/游泳/骑行/瑜伽...
typeEmoji: string; // 运动类型 emoji:🏃/🏊/🚴/🧘
duration: number; // 运动时长(分钟)
calories: number; // 消耗卡路里
date: string; // 运动日期
note: string; // 备注
createdAt: string; // 创建时间
}
🤔 思考题: 为什么 id 用 Date.now() 而不是自增数字?
答:Date.now() 返回毫秒级时间戳,在多用户、多设备场景下几乎不可能重复,天然唯一。
运动类型数据
const SPORT_TYPES: SportType[] = [
{ name: '跑步', emoji: '🏃', calPerMin: 7.1 },
{ name: '游泳', emoji: '🏊', calPerMin: 8.0 },
{ name: '骑行', emoji: '🚴', calPerMin: 6.0 },
{ name: '瑜伽', emoji: '🧘', calPerMin: 3.5 },
{ name: '跳绳', emoji: '🪢', calPerMin: 10.0 },
{ name: '篮球', emoji: '🏀', calPerMin: 6.5 },
{ name: '爬山', emoji: '⛰️', calPerMin: 5.5 },
{ name: '其他', emoji: '🏋️', calPerMin: 4.0 },
];
💡 最佳实践:
calPerMin(每分钟消耗卡路里)是一个很巧妙的设计——当用户输入运动时长后,系统可以自动估算卡路里,提升用户体验。
🏗️ 第二步:状态管理设计
@Component
struct SportTracker {
// ===== 表单状态 =====
@State selectedType: number = 0; // 选中运动类型索引
@State durationInput: string = ''; // 时长输入
@State caloriesInput: string = ''; // 卡路里输入
@State selectedDate: Date = new Date(); // 选中日期
@State noteInput: string = ''; // 备注输入
// ===== 数据状态 =====
@State records: SportRecord[] = []; // 所有运动记录
@State searchKeyword: string = ''; // 搜索关键词
@State isEditing: boolean = false; // 编辑模式
// ===== 计算属性 =====
// 筛选后的记录
get filteredRecords(): SportRecord[] {
if (!this.searchKeyword) return this.records;
return this.records.filter(r =>
r.type.includes(this.searchKeyword)
);
}
// 今日总时长
get todayTotalDuration(): number {
const today = new Date().toLocaleDateString('zh-CN');
return this.records
.filter(r => r.date === today)
.reduce((sum, r) => sum + r.duration, 0);
}
// 今日总卡路里
get todayTotalCalories(): number {
const today = new Date().toLocaleDateString('zh-CN');
return this.records
.filter(r => r.date === today)
.reduce((sum, r) => sum + r.calories, 0);
}
}
🔑 关键知识点: ArkUI 不支持
computed属性自动缓存(像 Vue 的 computed),但get访问器会在每次渲染时重新计算。对于简单的统计求和,性能完全足够。
状态变量设计原则
| 变量类型 | 示例 | 设计原则 |
|---|---|---|
| 表单状态 | durationInput |
和 UI 输入一一对应 |
| 数据状态 | records[] |
业务核心数据 |
| UI 状态 | isEditing |
控制界面模式切换 |
| 计算属性 | filteredRecords |
从已有数据派生,不单独存储 |
⚠️ 避坑指南: 不要把过滤后的数据存成另一个
@State!
❌@State filteredRecords: SportRecord[] = []
✅get filteredRecords()从原始数据实时计算
原因:存两份数据会导致"改了原始数据忘了更新过滤数据"的 bug。
📝 第三步:添加记录功能
addRecord(): void {
// ===== 数据验证 =====
const duration = parseInt(this.durationInput);
const calories = parseInt(this.caloriesInput);
if (isNaN(duration) || duration <= 0) {
AlertDialog.show({ message: '请输入有效的运动时长(分钟)' });
return;
}
if (isNaN(calories) || calories <= 0) {
AlertDialog.show({ message: '请输入有效的卡路里消耗' });
return;
}
// ===== 创建记录 =====
const record: SportRecord = {
id: Date.now(),
type: SPORT_TYPES[this.selectedType].name,
typeEmoji: SPORT_TYPES[this.selectedType].emoji,
duration: duration,
calories: calories,
date: this.selectedDate.toLocaleDateString('zh-CN'),
note: this.noteInput,
createdAt: new Date().toLocaleString('zh-CN'),
};
// ===== 不可变更新 =====
this.records = [record, ...this.records];
// ===== 重置表单 =====
this.durationInput = '';
this.caloriesInput = '';
this.noteInput = '';
}
数据验证清单:
// 教你写好验证,面试常考!
if (!val) → "字段不能为空"
if (isNaN(parseInt(val))) → "请输入有效数字"
if (parseInt(val) <= 0) → "数字必须大于0"
if (val.length > 100) → "输入过长"
📋 第四步:列表展示与交互
列表项:滑动删除
ListItem() {
SwipeAction({ end: this.deleteAction(item.id) }) {
Row() {
// 运动 emoji
Text(record.typeEmoji).fontSize(32)
Column() {
Text(record.type).fontSize(16).fontWeight(FontWeight.Bold)
Text(record.date).fontSize(12).fontColor('#A0AEC0')
}
.margin({ left: 12 })
.alignItems(HorizontalAlign.Start)
Blank()
Column() {
Text(`${record.duration}分钟`).fontSize(16)
Text(`${record.calories}千卡`).fontSize(13).fontColor('#48BB78')
}
.alignItems(HorizontalAlign.End)
}
.width('100%')
.padding(16)
.backgroundColor('#FFFFFF')
.borderRadius(12)
}
}
SwipeAction是 ArkUI 提供的滑动交互组件,支持左右滑出操作按钮(删除、编辑、置顶等)。这是原生级的交互体验,Web 端需要自己实现。
今日概览卡片
使用 @Builder 抽离统计卡片:
@Builder
StatsCard(title: string, value: string, unit: string, icon: string, color: string) {
Column() {
Text(icon).fontSize(28)
Text(value).fontSize(22).fontWeight(FontWeight.Bold).fontColor(color)
Text(unit).fontSize(12).fontColor('#A0AEC0')
Text(title).fontSize(12).fontColor('#A0AEC0').margin({ top: 4 })
}
.width('30%')
.padding(12)
.backgroundColor('#FFFFFF')
.borderRadius(16)
.shadow({ radius: 4, color: '#10000000' })
}
这样在 build() 中只需一行调用:
Row() {
this.StatsCard('今日运动', '2', '项', '🏃', '#667EEA')
this.StatsCard('运动时长', '85', '分钟', '⏱', '#48BB78')
this.StatsCard('消耗热量', '520', '千卡', '🔥', '#ED8936')
}
.width('100%')
.justifyContent(FlexAlign.SpaceAround)
🎨 第五步:日期选择器
Button() {
Row() {
Text('📅')
Text(this.selectedDate.toLocaleDateString('zh-CN'))
.margin({ left: 6 })
}
}
.onClick(() => {
DatePickerDialog.show({
startDate: new Date('2024-01-01'),
endDate: new Date(),
selectedDate: this.selectedDate,
onDateAccept: (date: Date) => {
this.selectedDate = date;
}
});
})
💡 最佳实践:
DatePickerDialog以弹窗形式展示日期选择器,比内联DatePicker更节省屏幕空间。起始日期设为 2024-01-01,结束日期设为今天,防止用户选了未来的日期。
🔍 第六步:搜索与筛选
// 搜索输入框
TextInput({ placeholder: '🔍 搜索运动类型...', text: this.searchKeyword })
.onChange((val: string) => {
this.searchKeyword = val;
})
// 列表渲染改为使用过滤后的数据
ForEach(this.filteredRecords, (record: SportRecord) => {
ListItem() {
this.RecordItem(record)
}
}, (record: SportRecord) => record.id.toString())
💡 实现原理:
filteredRecords是一个get访问器,它会实时去过滤records数组。当searchKeyword变化 → UI 重新渲染 → 调用filteredRecords→ 返回过滤结果。这一切都是声明式的,不需要手动触发更新。
✨ 第七步:更精致的体验
空状态提示
当用户还没有任何运动记录时:
if (this.records.length === 0) {
Column() {
Text('🏃♂️').fontSize(80).opacity(0.3)
Text('还没有运动记录').fontSize(16).fontColor('#A0AEC0')
Text('在表单中添加你的第一次运动吧!')
.fontSize(13).fontColor('#CBD5E0').margin({ top: 4 })
}
.width('100%')
.height(200)
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
}
删除确认
deleteRecord(id: number): void {
AlertDialog.show({
title: '确认删除',
message: '确定要删除这条运动记录吗?',
primaryButton: {
value: '取消',
action: () => {}
},
secondaryButton: {
value: '删除',
fontColor: '#E53E3E',
action: () => {
this.records = this.records.filter(r => r.id !== id);
}
}
});
}
📊 第八步:数据统计可视化
用最基础的 Column + Text 画出柱状图效果:
@Builder
WeeklyChart() {
Column() {
Text('📊 本周运动统计').fontSize(16).fontWeight(FontWeight.Bold)
Row() {
// 用 Column 的高度模拟柱状图
ForEach(this.weekStats, (day: DayStat) => {
Column() {
Text(`${day.total}分`)
.fontSize(10)
.fontColor('#667EEA')
Column()
.width(24)
.height(day.total * 2) // 按比例放缩
.backgroundColor('#667EEA')
.borderRadius(6)
Text(day.name).fontSize(11).fontColor('#A0AEC0').margin({ top: 4 })
}
.margin({ left: 6, right: 6 })
.alignItems(HorizontalAlign.Center)
})
}
.width('100%')
.justifyContent(FlexAlign.SpaceAround)
.padding(16)
.backgroundColor('#FFFFFF')
.borderRadius(16)
.margin({ top: 8 })
}
.width('100%')
.margin({ top: 16 })
}
💡 小技巧: 用
Column().height(day.total * 2)来模拟柱状图!虽然不如 Canvas 图表专业,但对于新手来说,这是一种零依赖、零学习成本的"伪图表"方案。
🧠 技术深度总结
你学会了什么?
| 知识点 | 难度 | 说明 |
|---|---|---|
List + ListItem |
⭐⭐ | 列表渲染是 App 开发的核心技能 |
SwipeAction 滑动 |
⭐⭐ | 原生级手势交互 |
DatePickerDialog |
⭐ | 日期选择弹窗 |
AlertDialog 确认框 |
⭐ | 删除前二次确认 |
| 表单验证 | ⭐⭐ | 提升用户体验的关键 |
| 搜索过滤 | ⭐⭐ | filter() + includes() 实战 |
| 数据统计 | ⭐⭐ | reduce()、filter() 组合使用 |
避坑指南 🚫
| 坑 | 错误写法 | 正确写法 |
|---|---|---|
| 删除不刷新 | this.records.splice(index, 1) |
this.records = this.records.filter(...) |
| List 高度 | 忘了给 List 设置高度 | List().height('100%') 或 .layoutWeight(1) |
| 日期比较 | 直接比较 Date 对象 | 用 toLocaleDateString() 转为字符串比较 |
| 输入数字 | 类型转换丢失 | 用 parseInt() 或 parseFloat() 显式转换 |
最佳实践 ✅
- 表单验证前置:在
addRecord()开头先验证,不符合直接 return - TypeScript 接口先行:写任何功能前,先定义好 Interface
- 组件化拆分:超过 50 行的 UI 块就拆成 @Builder
- 错误状态覆盖:空数据、搜索无结果、输入错误都要有 UI 反馈
- 用户体验细节:添加成功后的"震动反馈"、数据变化时的"微动效"
🔮 扩展练习
- 🏆 排行榜:统计本周/本月运动最多的类型,展示排行榜
- 🎯 目标设定:每日运动目标 30 分钟,进度条展示完成度
- 🌐 云同步:接入华为云,多设备同步运动数据
- 🧠 AI 建议:根据历史数据,推荐运动计划和恢复时间
- 📱 卡片样式:用
Grid组件实现日历热力图
📚 参考资料
- 官方的 List 组件文档:List 组件 API
- 华为运动健康服务:Health Service Kit
- 开发者社区:华为开发者论坛
- 欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net/
- 官方文档:HarmonyOS 应用开发文档
更多推荐
所有评论(0)