鸿蒙原生应用实战(十五)ArkUI 健康计步器:加速度传感器 + 峰值检测 + SQLite 存储 + 周报统计

🏃 鸿蒙原生应用实战(十五)ArkUI 健康计步器:加速度传感器 + 峰值检测 + SQLite 存储 + 周报统计

博主说: 每天走多少步?消耗了多少卡路里?今天我们用 ArkUI 的加速度传感器 API + SQLite 数据库,从零实现一个带峰值检测算法、日/周统计图表、卡路里换算、目标设定的完整健康计步器。读完你将掌握传感器数据处理的全链路技能。


📱 应用场景

功能 说明
👟 实时计步 加速度传感器 + 峰值检测算法
📊 周报统计 柱状图展示 7 天步数趋势
🔥 卡路里估算 步数 → 卡路里 / 距离换算
🎯 目标设定 每日目标步数 + 完成进度环
📅 历史记录 SQLite 持久化存储每日数据

⚙️ 运行环境要求

项目 版本要求
DevEco Studio 5.0.3.800 及以上
HarmonyOS SDK API 12
核心 API @ohos.sensor + @ohos.data.relationalStore
真机要求 必须真机(模拟器无加速度传感器)

🛠️ 实战:从零搭建健康计步器

Step 1:计步算法原理(核心干货)

计步器最难的不是代码,而是算法------如何在走路/跑步的震动中准确识别出"一步"。

复制代码
加速度传感器   →   三轴矢量合成   →   带通滤波   →   峰值检测   →   防抖   →   步数+1
  (50Hz)        sqrt(x²+y²+z²)     0.5~3Hz       >阈值       200ms

每一步的加速度波形特征:

复制代码
幅值
↑
│       ╱╲
│      ╱  ╲        ╱╲          ← 每一步产生一个波峰
│     ╱    ╲      ╱  ╲
│    ╱      ╲    ╱    ╲
│   ╱        ╲  ╱      ╲
│  ╱          ╲╱        ╲
│ ╱                      ╲
└─────────────────────────────→ 时间
   ↑峰值       ↑防抖窗口(200ms)
   检测到一步    内不重复计数

关键参数调优:

参数 推荐值 说明
采样频率 50 Hz 人正常步频 1~2Hz,50Hz 足够
加速度阈值 11 m/s² 大于重力加速度 9.8 视为有动作
防抖时间 200ms 小于这个时间算一步
带通滤波 0.5~3Hz 过滤走路以外的高频噪声

Step 2:数据模型

typescript 复制代码
// 每日步数记录
interface DailyStep {
  date: string;        // YYYY-MM-DD
  steps: number;       // 总步数
  goal: number;        // 当日目标
  calories: number;    // 卡路里
  distance: number;    // 距离(米)
}

// 运动换算常量
const CALORIE_PER_STEP = 0.04;   // 每步 0.04 千卡
const DISTANCE_PER_STEP = 0.65;  // 每步 0.65 米
const DEFAULT_GOAL = 8000;       // 默认每日目标 8000 步

Step 3:完整代码

typescript 复制代码
// pages/Index.ets --- 健康计步器
import sensor from '@ohos.sensor';
import relationalStore from '@ohos.data.relationalStore';
import abilityAccessCtrl from '@ohos.abilityAccessCtrl';

@Entry
@Component
struct Pedometer {
  // ======== 状态变量 ========
  @State todaySteps: number = 0;
  @State dailyGoal: number = DEFAULT_GOAL;
  @State calories: number = 0;
  @State distance: number = 0;
  @State isTracking: boolean = false;
  @State weekData: { date: string; steps: number }[] = [];
  @State currentView: 'today' | 'week' = 'today';
  @State batteryLevel: number = 100;

  private store!: relationalStore.RdbStore;
  private lastPeakTime: number = 0;
  private lastMagnitude: number = 0;
  private accelBuffer: number[] = [];  // 滑动平均缓冲区
  private readonly BUFFER_SIZE = 5;

  aboutToAppear() {
    this.initDB();
    this.loadTodayData();
  }

  // ======== SQLite 初始化 ========
  async initDB() {
    try {
      const config = {
        name: 'pedometer.db',
        securityLevel: relationalStore.SecurityLevel.S1
      };
      this.store = await relationalStore.getRdbStore(getContext(this), config);
      await this.store.executeSql(
        `CREATE TABLE IF NOT EXISTS daily_steps (
          id INTEGER PRIMARY KEY AUTOINCREMENT,
          date TEXT UNIQUE,
          steps INTEGER DEFAULT 0,
          goal INTEGER DEFAULT ${DEFAULT_GOAL},
          calories REAL DEFAULT 0,
          distance REAL DEFAULT 0
        )`
      );
    } catch (err) {
      console.error('数据库初始化失败:', JSON.stringify(err));
    }
  }

  // ======== 加载今日数据 ========
  async loadTodayData() {
    const today = this.getTodayStr();
    try {
      const predicates = new relationalStore.RdbPredicates('daily_steps');
      predicates.equalTo('date', today);
      const result = await this.store.query(predicates, ['steps', 'goal', 'calories', 'distance']);
      
      if (result.goToFirstRow()) {
        this.todaySteps = result.getLong(result.getColumnIndex('steps'));
        this.dailyGoal = result.getLong(result.getColumnIndex('goal'));
      }
      result.close();
      this.calculateDerived();
    } catch (err) {
      console.error('加载今日数据失败:', JSON.stringify(err));
    }
  }

  getTodayStr(): string {
    const d = new Date();
    return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`;
  }

  // ======== 保存步数到 SQLite ========
  async saveToDB() {
    const today = this.getTodayStr();
    try {
      const exists = await this.checkDateExists(today);
      if (exists) {
        const p = new relationalStore.RdbPredicates('daily_steps');
        p.equalTo('date', today);
        await this.store.update({
          steps: this.todaySteps,
          goal: this.dailyGoal,
          calories: this.calories,
          distance: this.distance
        }, p);
      } else {
        await this.store.insert('daily_steps', {
          date: today,
          steps: this.todaySteps,
          goal: this.dailyGoal,
          calories: this.calories,
          distance: this.distance
        });
      }
    } catch (err) {
      console.error('保存失败:', JSON.stringify(err));
    }
  }

  async checkDateExists(date: string): Promise<boolean> {
    const p = new relationalStore.RdbPredicates('daily_steps');
    p.equalTo('date', date);
    const r = await this.store.query(p, ['id']);
    const exists = r.goToFirstRow();
    r.close();
    return exists;
  }

  // ======== 加载周数据(柱状图用) ========
  async loadWeekData() {
    const list: { date: string; steps: number }[] = [];
    for (let i = 6; i >= 0; i--) {
      const d = new Date();
      d.setDate(d.getDate() - i);
      const dateStr = `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`;
      
      try {
        const p = new relationalStore.RdbPredicates('daily_steps');
        p.equalTo('date', dateStr);
        const r = await this.store.query(p, ['steps']);
        const steps = r.goToFirstRow() ? r.getLong(r.getColumnIndex('steps')) : 0;
        r.close();
        list.push({ date: `${d.getMonth()+1}/${d.getDate()}`, steps });
      } catch {
        list.push({ date: `${d.getMonth()+1}/${d.getDate()}`, steps: 0 });
      }
    }
    this.weekData = list;
  }

  // ======== 开始计步(加速度传感器监听) ========
  startTracking() {
    if (this.isTracking) return;
    this.isTracking = true;

    // 订阅加速度传感器
    sensor.on(sensor.SensorType.ACCELEROMETER, (data) => {
      // 1. 三轴矢量合成
      const magnitude = Math.sqrt(
        data.x * data.x + data.y * data.y + data.z * data.z
      );

      // 2. 滑动平均滤波(消除瞬时噪声)
      this.accelBuffer.push(magnitude);
      if (this.accelBuffer.length > this.BUFFER_SIZE) {
        this.accelBuffer.shift();
      }
      const avgMagnitude = this.accelBuffer.reduce((a, b) => a + b, 0) / this.accelBuffer.length;

      // 3. 峰值检测 + 防抖
      const now = Date.now();
      // 上升沿检测:当前值大于阈值 && 当前值小于前一次值(说明过了峰值)
      if (avgMagnitude > 11 && this.lastMagnitude > 10 && avgMagnitude < this.lastMagnitude) {
        // 防抖:200ms 内只算一步
        if (now - this.lastPeakTime > 200) {
          this.todaySteps++;
          this.lastPeakTime = now;
          this.calculateDerived();
          
          // 每 50 步自动存一次(防数据丢失)
          if (this.todaySteps % 50 === 0) {
            this.saveToDB();
          }
        }
      }
      this.lastMagnitude = avgMagnitude;
    });
  }

  // ======== 停止计步 ========
  stopTracking() {
    sensor.off(sensor.SensorType.ACCELEROMETER);
    this.isTracking = false;
    this.saveToDB(); // 停止时保存
  }

  // ======== 计算衍生数据 ========
  calculateDerived() {
    this.calories = Math.round(this.todaySteps * CALORIE_PER_STEP);
    this.distance = Math.round(this.todaySteps * DISTANCE_PER_STEP);
  }

  // ======== 进度百分比与颜色 ========
  getProgressPercent(): number {
    return Math.min(100, Math.round((this.todaySteps / this.dailyGoal) * 100));
  }

  getProgressColor(): string {
    const p = this.getProgressPercent();
    if (p < 30) return '#FF3B30';    // 红色:刚开始
    if (p < 60) return '#FF9500';    // 橙色:一半
    if (p < 85) return '#FFCC00';    // 黄色:接近
    return '#34C759';                // 绿色:达成
  }

  getProgressStatus(): string {
    const p = this.getProgressPercent();
    if (p === 0) return '还没开始,起来走走!🚶';
    if (p < 30) return '加油,还差很多呢 💪';
    if (p < 60) return '已经走了一半了!👍';
    if (p < 85) return '快要达成目标了!🔥';
    if (p < 100) return '马上就到了!冲刺 🏃';
    return '🎉 目标达成!太棒了!';
  }

  // ======== 格式化 ========
  formatSteps(n: number): string {
    return n >= 10000 ? (n / 10000).toFixed(1) + '万' : n.toString();
  }

  formatDistance(m: number): string {
    return m >= 1000 ? (m / 1000).toFixed(2) + 'km' : m + 'm';
  }

  // ======== 健康小贴士 ========
  getHealthTip(): string {
    const tips = [
      '每天走 8000 步可显著降低心血管疾病风险 🫀',
      '饭后散步 15 分钟有助消化 🍽️',
      '每小时起来走动 5 分钟,缓解久坐伤害 💺',
      '快步走比慢走燃脂多 2 倍 🔥',
      '坚持每天走 10000 步,一个月可减 1kg 🏋️'
    ];
    return tips[Math.floor(Math.random() * tips.length)];
  }

  // ======== UI 构建 ========
  build() {
    Column() {
      // Tab 切换
      Row() {
        Button('📊 今日').width('50%').height(42)
          .backgroundColor(this.currentView === 'today' ? '#34C759' : '#F0F0F0')
          .fontColor(this.currentView === 'today' ? '#fff' : '#333')
          .borderRadius(0).fontSize(16)
          .onClick(() => { this.currentView = 'today'; })
        Button('📈 周报').width('50%').height(42)
          .backgroundColor(this.currentView === 'week' ? '#34C759' : '#F0F0F0')
          .fontColor(this.currentView === 'week' ? '#fff' : '#333')
          .borderRadius(0).fontSize(16)
          .onClick(() => { this.currentView = 'week'; this.loadWeekData(); })
      }.width('100%')

      if (this.currentView === 'today') {
        this.TodayView()
      } else {
        this.WeekView()
      }
    }
    .width('100%').height('100%').backgroundColor('#F8F9FA')
  }

  @Builder
  TodayView() {
    Column() {
      // ---- 步数环形进度 ----
      Stack() {
        // 背景环
        Circle().width(180).height(180).fill('none')
          .stroke('#E8E8E8').strokeWidth(12)
        // 进度环(用 strokeDashArray 实现)
        Circle().width(180).height(180).fill('none')
          .stroke(this.getProgressColor()).strokeWidth(12)
          .strokeDashArray([this.getProgressPercent() / 100 * 502.65, 502.65])
          .rotate({ angle: -90 })
        
        // 中间数字
        Column() {
          Text(this.formatSteps(this.todaySteps))
            .fontSize(40).fontWeight(FontWeight.Bold).fontColor('#333')
          Text(`/ ${this.formatSteps(this.dailyGoal)}`)
            .fontSize(16).fontColor('#888').margin({ top: 2 })
          Text(`${this.getProgressPercent()}%`)
            .fontSize(14).fontColor(this.getProgressColor()).fontWeight(FontWeight.Bold)
            .margin({ top: 4 })
        }
        .alignItems(HorizontalAlign.Center)
      }
      .margin({ top: 20 })

      // ---- 状态提示 ----
      Text(this.getProgressStatus())
        .fontSize(14).fontColor(this.getProgressColor()).margin({ top: 8 })
        .fontWeight(FontWeight.Bold)

      // ---- 三数据卡片 ----
      Row() {
        this.DataCard('🔥', '卡路里', `${this.calories}`, 'kcal')
        this.DataCard('📏', '距离', `${this.formatDistance(this.distance)}`, '')
        this.DataCard('🎯', '完成度', `${this.getProgressPercent()}%`, '')
      }
      .width('94%').gap(8).margin({ top: 16 })

      // ---- 目标滑动条 ----
      Row() {
        Text('🎯 目标:').fontSize(14).fontColor('#555').width(60)
        Slider({
          value: this.dailyGoal,
          min: 2000,
          max: 20000,
          step: 500
        })
        .width('60%').height(30)
        .onChange((v: number) => {
          this.dailyGoal = v;
          this.saveToDB();
        })
        Text(`${this.formatSteps(this.dailyGoal)}`)
          .fontSize(14).fontColor('#34C759').fontWeight(FontWeight.Bold).width(50)
      }
      .width('90%').margin({ top: 12 })

      // ---- 控制按钮 ----
      Button(this.isTracking ? '⏹ 停止计步' : '👟 开始计步')
        .width('80%').height(52)
        .backgroundColor(this.isTracking ? '#FF3B30' : '#34C759')
        .fontColor('#fff').borderRadius(26).fontSize(18).fontWeight(FontWeight.Bold)
        .margin({ top: 16 })
        .onClick(() => {
          this.isTracking ? this.stopTracking() : this.startTracking();
        })

      // ---- 状态 + 健康小贴士 ----
      Text(this.isTracking ? '🔴 正在计步中...' : '⏸ 已暂停')
        .fontSize(13).fontColor('#888').margin({ top: 8 })

      if (!this.isTracking) {
        Text('💡 ' + this.getHealthTip())
          .fontSize(12).fontColor('#999').margin({ top: 8 }).padding({ left: 20, right: 20 })
          .textAlign(TextAlign.Center)
      }
    }
    .width('100%').alignItems(HorizontalAlign.Center).layoutWeight(1)
  }

  @Builder
  WeekView() {
    Column() {
      Text('📅 最近 7 天').fontSize(18).fontWeight(FontWeight.Bold).margin({ top: 16, bottom: 4 })

      // 周统计概要
      if (this.weekData.length > 0) {
        const total = this.weekData.reduce((s, i) => s + i.steps, 0);
        const avg = Math.round(total / this.weekData.length);
        Row() {
          this.StatBox('📊 总步数', `${total.toLocaleString()}`)
          this.StatBox('📈 日均', `${avg.toLocaleString()}`)
          this.StatBox('🏆 最高', `${Math.max(...this.weekData.map(i => i.steps)).toLocaleString()}`)
        }.width('94%').gap(8).margin({ bottom: 12 })
      }

      // 柱状图
      Scroll() {
        Column() {
          ForEach(this.weekData, (item: any) => {
            Row() {
              Text(item.date).fontSize(14).fontColor('#333').width(60)
              
              // 柱状条
              Column() {
                Column()
                  .width(28)
                  .height(Math.max(4, (item.steps / 12000) * 140))
                  .backgroundColor('#34C759')
                  .borderRadius(4)
                  .animation({ duration: 500, curve: Curve.EaseOut })
              }
              .height(150).justifyContent(FlexAlign.End)
              .margin({ left: 8 })

              Text(item.steps.toLocaleString())
                .fontSize(14).fontColor('#888').margin({ left: 8 }).width(50)
            }
            .padding({ left: 16, right: 16, top: 8, bottom: 8 })
            .width('96%').backgroundColor('#FFF').borderRadius(8)
            .shadow({ radius: 1, color: '#08000000', offsetY: 1 })
          }, (item: any) => item.date)
        }.width('100%').padding({ top: 4 })
      }.layoutWeight(1).width('100%')

      // 参考线
      Text('--- 虚线 = 日均步数 ---')
        .fontSize(12).fontColor('#999').padding(8)
    }
    .width('100%').layoutWeight(1)
  }

  @Builder
  DataCard(icon: string, label: string, value: string, unit: string) {
    Column() {
      Text(icon).fontSize(22)
      Text(value).fontSize(20).fontWeight(FontWeight.Bold).fontColor('#333').margin({ top: 4 })
      Text(label + ' ' + unit).fontSize(11).fontColor('#888').margin({ top: 2 })
    }
    .padding(12).backgroundColor('#FFF').borderRadius(10).layoutWeight(1)
    .shadow({ radius: 2, color: '#10000000', offsetY: 1 })
  }

  @Builder
  StatBox(label: string, value: string) {
    Column() {
      Text(value).fontSize(18).fontWeight(FontWeight.Bold).fontColor('#34C759')
      Text(label).fontSize(12).fontColor('#888').margin({ top: 2 })
    }
    .padding(10).backgroundColor('#FFF').borderRadius(8).layoutWeight(1)
    .shadow({ radius: 1, color: '#08000000', offsetY: 1 })
  }
}

📚 核心知识点深度解析

1. 峰值检测算法详解

复制代码
原始信号: 1.02, 9.81, 10.5, 11.2, 12.1, 11.5, 10.8, 9.9...
                      ↑ 峰值点 (12.1 > 阈值11, 且开始下降)
                           ↓
                    检测到一步 + 防抖 200ms

三步检测条件(同时满足):

  1. avgMagnitude > 11 --- 当前值超过阈值(重力加速度 ≈ 9.8)
  2. avgMagnitude < this.lastMagnitude --- 过了峰值(开始下降)
  3. now - lastPeakTime > 200 --- 防抖时间已过

2. 滑动平均滤波

复制代码
缓冲区 [10.2, 10.8, 11.5, 12.1, 11.5]
            ↓
        平均值 = (10.2+10.8+11.5+12.1+11.5) / 5 = 11.22

消除单次噪声,让波峰检测更稳定。

3. 运动换算公式

复制代码
卡路里 = 步数 × 0.04 kcal
距离   = 步数 × 0.65 m
目标完成度 = min(100%, 步数 / 目标 × 100)

📊 每日目标参考表

目标步数 对应距离 对应卡路里 适合人群
3000 1.95 km 120 kcal 办公室久坐族(最低活动量)
6000 3.9 km 240 kcal 普通上班族
8000 5.2 km 320 kcal WHO 推荐每日活动量
10000 6.5 km 400 kcal 健身爱好者
15000 9.75 km 600 kcal 运动达人

⚠️ 避坑指南

原因 正确做法
计步数偏多 颠簸路段误计 提高阈值到 12,或加方差检测
计步数偏少 阈值太高,慢走不触发 阈值降到 10,配合 500ms 防抖窗
传感器不回调 忘了调 sensor.on() aboutToAppear 中订阅
数据丢失 没保存就杀进程 每 50 步自动存 + onStop 存
柱状图不显示 数据全为 0 检查 SQLite 表结构(date TEXT UNIQUE)
重复计步 防抖时间太短 设为 200~300ms
耗电快 传感器常开 停止计步时 sensor.off()
模拟器没数据 模拟器无加速度传感器 必须真机调试
数据库异常 并发写入 用事务包裹写入操作
周报日期错乱 时区问题 setHours(0,0,0,0) 对齐日期

🔥 最佳实践

  1. 滑动平均滤波:缓冲区大小 5~10,消除单次噪声
  2. 防抖窗口:200ms 避免一步被多次计算
  3. 自动保存:每 50 步自动持久化,防止进程被杀数据丢失
  4. 颜色反馈:进度环用红→橙→黄→绿渐变色
  5. 峰值检测优化:结合加速度方差检测,静止时不计数
  6. 目标达成动画:达到 100% 时播放庆祝动画
  7. 电量优化:计步时降低屏幕亮度,延长续航
  8. 后台计步 :用 backgroundTaskManager 保持后台运行

🚀 扩展挑战

  1. 实时步频显示:计算最近 10 秒的步数 × 6 = 步频(步/分钟)
  2. 运动类型识别:走路 vs 跑步 vs 骑车(通过加速度频率区分)
  3. 楼层计算:结合气压传感器检测爬楼梯
  4. Widget 卡片:主屏幕卡片显示今日步数与进度环
  5. 社交排行:好友步数排行(分布式数据库同步)


官方文档: HarmonyOS 应用开发文档

相关推荐
小鹏linux1 小时前
鸿蒙PC迁移:Phototonic Qt 图片查看器鸿蒙适配全记录:一次从 Widgets 桌面应用到 HAP 的迁移
qt·华为·harmonyos
knighthood20011 小时前
鸿蒙PC迁移:KeePassXC Qt 密码管理器鸿蒙PC适配全记录
qt·华为·harmonyos
Swift社区1 小时前
鸿蒙 PC 正在诞生“第二操作系统”:Agent Runtime 架构揭秘
华为·架构·harmonyos
不良使1 小时前
鸿蒙PC迁移_LocalSend 迁移到鸿蒙 PC:一次 Flutter + Rust + 三方库适配的完整记录
flutter·rust·harmonyos
小鹏linux2 小时前
鸿蒙PC使用 Electron 迁移:LX Music 桌面版适配全记录
华为·electron·harmonyos
古德new2 小时前
鸿蒙PC迁移:使用Electron`yesplaymusic-ohos` 鸿蒙迁移实战与适配全记录
华为·electron·harmonyos
鸽芷咕2 小时前
鸿蒙PC迁移:Minitube Qt YouTube 客户端鸿蒙PC适配全记录
qt·华为·harmonyos
小鹏linux2 小时前
鸿蒙PC使用 Electron 迁移:Beekeeper Studio 适配全记录
华为·electron·harmonyos