🏃 新手零基础学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() 显式转换

最佳实践 ✅

  1. 表单验证前置:在 addRecord() 开头先验证,不符合直接 return
  2. TypeScript 接口先行:写任何功能前,先定义好 Interface
  3. 组件化拆分:超过 50 行的 UI 块就拆成 @Builder
  4. 错误状态覆盖:空数据、搜索无结果、输入错误都要有 UI 反馈
  5. 用户体验细节:添加成功后的"震动反馈"、数据变化时的"微动效"

🔮 扩展练习

  1. 🏆 排行榜:统计本周/本月运动最多的类型,展示排行榜
  2. 🎯 目标设定:每日运动目标 30 分钟,进度条展示完成度
  3. 🌐 云同步:接入华为云,多设备同步运动数据
  4. 🧠 AI 建议:根据历史数据,推荐运动计划和恢复时间
  5. 📱 卡片样式:用 Grid 组件实现日历热力图

📚 参考资料

Logo

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

更多推荐