鸿蒙原生应用实战(十五)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 应用开发文档

相关推荐
在书中成长36 分钟前
HarmonyOS 小游戏《对战五子棋》开发第5篇-五子棋GameConstants常量与类型定义最佳实践
华为·harmonyos
最爱老式锅包肉1 小时前
《HarmonyOS技术精讲-ArkWeb》桥接两岸:JSBridge原生与Web互调
前端·华为·harmonyos
HarmonyOS_SDK4 小时前
HarmonyOS SDK 26发布,持续提升鸿蒙应用开发体验与效率
harmonyos
最爱老式锅包肉5 小时前
《HarmonyOS技术精讲-Core Speech Kit(基础语音服务)》第3篇:语音合成——文本到自然语音的魔法
华为·harmonyos
●VON5 小时前
HarmonyKit | 鸿蒙新特性驱动:组件-工具-页面三层架构设计
华为·harmonyos·鸿蒙
binbin_526 小时前
Flutter 开发鸿蒙实战:Windows 环境下从 HAP 构建到四 Tab 页面运行
windows·flutter·harmonyos
●VON7 小时前
HarmonyKit | 鸿蒙新特性应用:10 个开发工具的选型与分类
华为·harmonyos·鸿蒙
最爱老式锅包肉9 小时前
《HarmonyOS技术精讲-ArkWeb》进阶配置:自适应与交互控制
华为·交互·harmonyos
最爱老式锅包肉9 小时前
《HarmonyOS技术精讲-Core Speech Kit(基础语音服务)》第5篇:实战项目——构建全功能语音助手
华为·harmonyos
最爱老式锅包肉9 小时前
《HarmonyOS技术精讲-Core Speech Kit(基础语音服务)》第4篇:语音唤醒进阶——打造免提交互体验
交互·语音识别·harmonyos