跳转至正文

如何使用 Capacitor 构建心率监测应用

使用 Capacitor 和 Bluetooth Low Energy 构建心率监测器:扫描 0x180D 传感器,解析 BPM 和 RR 间期,并将读数保存到 Health。

Capacitor 应用实时显示蓝牙胸带的心率数据

要使用 Capacitor 构建心率监测器,请连接一个提供标准 Heart Rate Service 的 Bluetooth Low Energy 传感器(0x180D),并订阅 Heart Rate Measurement 特性(0x2A37), 并将每条通知解码为每分钟心跳数 (BPM)。本教程将通过 @capgo/capacitor-bluetooth-low-energy 在 Capacitor 8 上,包括 RR 间期、传感器接触状态、重连,以及将读数保存到 Apple Health 或 Health Connect。

BLE 心率传感器的工作原理

蓝牙低功耗 (BLE) 将设备分为两种角色。你的手机是 中心设备 (central):它负责扫描、连接并请求数据。胸带是 外围设备 (peripheral):它负责广播并提供数据。

外围设备会暴露 服务 (services),而每个服务包含 特征值 (characteristics),可读取、写入或订阅的小数据值。标准服务使用 Bluetooth SIG 分配的 16 位 UUID:

UUID 名称 访问权 提供的功能
0x180D 心率服务 服务 下方条目容器
0x2A37 心率测量 通知 BPM、传感器接触、能量消耗、RR 间期
0x2A38 身体传感器位置 读取 胸部、手腕、手指、手、耳垂、脚
0x2A39 心率控制点 写入 0x01 重置能量消耗计数器
0x180F 电池服务 服务 表带电池电量
0x2A19 电池电量 读取 / 通知 0 到 100%

16 位 UUID 是 128 位完整形式的简写 0000XXXX-0000-1000-8000-00805F9B34FB。Capgo 插件同时支持这两种形式。

前提条件

  • 一个 Capacitor 8 应用(任意框架:Angular、React、Vue、Svelte 或纯 TypeScript)
  • 一台实体 iPhone 或 Android 手机。模拟器和仿真器没有可用的蓝牙无线电
  • 一条 BLE 心率带,例如 Polar H10、Garmin HRM-Pro 或 Wahoo TICKR。请润湿电极并佩戴,许多心率带在没有皮肤接触时会保持休眠状态

如果你是从零开始:

bun create vite heart-rate --template vanilla-ts
cd heart-rate
bun add @capacitor/core @capacitor/ios @capacitor/android
bun add -d @capacitor/cli
bunx cap init "Heart Rate" com.example.heartrate --web-dir dist
bun run build
bunx cap add ios
bunx cap add android

安装蓝牙低功耗插件

bun add @capgo/capacitor-bluetooth-low-energy
bunx cap sync

iOS 配置

将蓝牙用途字符串添加到 ios/App/App/Info.plist,以及 bluetooth-central 如需在锁屏状态下获取读数,请启用后台模式:

<key>NSBluetoothAlwaysUsageDescription</key>
<string>Connect to your heart rate sensor during workouts.</string>
<key>NSBluetoothPeripheralUsageDescription</key>
<string>Connect to your heart rate sensor during workouts.</string>
<key>UIBackgroundModes</key>
<array>
  <string>bluetooth-central</string>
</array>

Android 配置

插件会把需要的权限合并到你的 manifest 中: BLUETOOTH_SCAN (将 neverForLocation), BLUETOOTH_CONNECT 以及 BLUETOOTH_ADVERTISE 适用于 Android 12+,以及旧版 BLUETOOTH, BLUETOOTH_ADMIN 和 Android 11 及更低版本的位置权限。在 Android 12+ 上,你仍需在运行时请求它们,这 requestPermissions() 。

它还会声明 android.hardware.bluetooth_le as required, which hides your app on Google Play for devices without BLE. If heart rate is an optional feature in your app, override it in android/app/src/main/AndroidManifest.xml:

<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools">
    <uses-feature
        android:name="android.hardware.bluetooth_le"
        android:required="false"
        tools:replace="android:required" />
</manifest>

解码心率测量值

先做这一步,因为大多数实现都会在这里出错。特征值(characteristic value)是一个字节数组:

字节 含义
0 参数
1 或 1-2 心率:flag bit 0 为 0 时为 UINT8,为 1 时为 UINT16 小端序
后 2 个(可选) 能量消耗(kJ),flag bit 3 为 1 时存在
剩余部分(可选) RR 间期,UINT16 小端序,单位 1/1024 s,flag bit 4 为 1 时存在

Flag bit 1 和 2 描述皮肤接触:bit 2 表示传感器是否支持接触检测,bit 1 表示是否检测到接触。

export interface HeartRateMeasurement {
  bpm: number;
  contactDetected: boolean | null; // null when the sensor does not report contact
  energyExpendedKj?: number;
  rrIntervalsMs: number[];
}

export function parseHeartRate(bytes: number[]): HeartRateMeasurement {
  const data = Uint8Array.from(bytes);
  const view = new DataView(data.buffer);
  const flags = data[0];
  let offset = 1;

  const is16Bit = (flags & 0x01) !== 0;
  const bpm = is16Bit ? view.getUint16(offset, true) : view.getUint8(offset);
  offset += is16Bit ? 2 : 1;

  const contactSupported = (flags & 0x04) !== 0;
  const contactDetected = contactSupported ? (flags & 0x02) !== 0 : null;

  let energyExpendedKj: number | undefined;
  if (flags & 0x08) {
    energyExpendedKj = view.getUint16(offset, true);
    offset += 2;
  }

  const rrIntervalsMs: number[] = [];
  if (flags & 0x10) {
    for (; offset + 1 < data.length; offset += 2) {
      rrIntervalsMs.push(Math.round((view.getUint16(offset, true) / 1024) * 1000));
    }
  }

  return { bpm, contactDetected, energyExpendedKj, rrIntervalsMs };
}

本指南中介绍的 true 的参数 getUint16 表示小端序,符合 Bluetooth 规范的要求。Code 将第一个字节左移(byte1 << 8 | byte2) 会反向读取 16 位数值。

快速验证测试:

parseHeartRate([0x16, 0x48, 0x00, 0x04]);
// flags 0x16: 8-bit value, contact supported and detected, RR present
// => { bpm: 72, contactDetected: true, rrIntervalsMs: [1000] }

连接传感器

以下模块会扫描广播 Heart Rate Service 的设备,连接第一个找到的设备,并将解析后的读数流式传输到回调函数。

import { BluetoothLowEnergy } from '@capgo/capacitor-bluetooth-low-energy';
import type { PluginListenerHandle } from '@capacitor/core';
import { parseHeartRate, type HeartRateMeasurement } from './heart-rate-parser';

const HEART_RATE_SERVICE = '180D';
const HEART_RATE_MEASUREMENT = '2A37';
const BODY_SENSOR_LOCATION = '2A38';
const BATTERY_SERVICE = '180F';
const BATTERY_LEVEL = '2A19';

// iOS reports short UUIDs ("2A37"), Android reports the full 128-bit form
const isUuid = (value: string, short: string) =>
  value.toUpperCase() === short || value.toUpperCase().startsWith(`0000${short}-`);

let listeners: PluginListenerHandle[] = [];
let connectedId: string | null = null;

export async function startHeartRate(onReading: (m: HeartRateMeasurement) => void) {
  await BluetoothLowEnergy.initialize({ mode: 'central' });

  const { available } = await BluetoothLowEnergy.isAvailable();
  if (!available) throw new Error('This device has no Bluetooth LE');

  const perms = await BluetoothLowEnergy.requestPermissions();
  if (perms.bluetooth !== 'granted') throw new Error('Bluetooth permission denied');

  const { enabled } = await BluetoothLowEnergy.isEnabled();
  if (!enabled) {
    await BluetoothLowEnergy.openBluetoothSettings();
    throw new Error('Bluetooth is off. Turn it on and tap Connect again.');
  }

  listeners.push(
    await BluetoothLowEnergy.addListener('characteristicChanged', (event) => {
      if (isUuid(event.characteristic, HEART_RATE_MEASUREMENT)) {
        onReading(parseHeartRate(event.value));
      }
    }),
    await BluetoothLowEnergy.addListener('deviceDisconnected', ({ deviceId }) => {
      if (deviceId === connectedId) void reconnect(deviceId);
    }),
  );

  try {
    const device = await scanForSensor(10_000);
    try {
      await connectTo(device.deviceId);
    } catch (error) {
      // connect() may have succeeded before a later step failed: release the half-open link
      await BluetoothLowEnergy.disconnect({ deviceId: device.deviceId }).catch(() => undefined);
      throw error;
    }
  } catch (error) {
    // Don't leave listeners behind, or each retry would handle every event again
    await Promise.all(listeners.map((l) => l.remove()));
    listeners = [];
    throw error;
  }
}

function scanForSensor(timeoutMs: number) {
  return new Promise<{ deviceId: string; name: string | null }>((resolve, reject) => {
    let handle: PluginListenerHandle | undefined;
    let settled = false;

    const finish = (error: Error | null, device?: { deviceId: string; name: string | null }) => {
      if (settled) return;
      settled = true;
      clearTimeout(timer);
      void handle?.remove();
      void BluetoothLowEnergy.stopScan().catch(() => undefined);
      if (error) reject(error);
      else resolve(device!);
    };

    const timer = setTimeout(
      () => finish(new Error('No heart rate sensor found. Is the strap worn and moist?')),
      timeoutMs,
    );

    BluetoothLowEnergy.addListener('deviceScanned', ({ device }) => finish(null, device))
      .then((h) => {
        handle = h;
        if (settled) {
          void h.remove();
          return;
        }
        return BluetoothLowEnergy.startScan({ services: [HEART_RATE_SERVICE] });
      })
      .catch((error) => finish(error instanceof Error ? error : new Error(String(error))));
  });
}

async function connectTo(deviceId: string) {
  await BluetoothLowEnergy.connect({ deviceId });
  await BluetoothLowEnergy.discoverServices({ deviceId });
  await BluetoothLowEnergy.startCharacteristicNotifications({
    deviceId,
    service: HEART_RATE_SERVICE,
    characteristic: HEART_RATE_MEASUREMENT,
  });
  connectedId = deviceId;
  localStorage.setItem('hr-sensor', deviceId);
}

async function reconnect(deviceId: string, attempt = 0) {
  // stopHeartRate() clears connectedId, which ends any pending retry chain
  if (attempt > 5 || connectedId !== deviceId) return;
  try {
    await connectTo(deviceId);
  } catch {
    setTimeout(() => void reconnect(deviceId, attempt + 1), 2000 * (attempt + 1));
  }
}

export async function stopHeartRate() {
  if (connectedId) {
    await BluetoothLowEnergy.stopCharacteristicNotifications({
      deviceId: connectedId,
      service: HEART_RATE_SERVICE,
      characteristic: HEART_RATE_MEASUREMENT,
    }).catch(() => undefined);
    const id = connectedId;
    connectedId = null; // prevents the disconnect listener from reconnecting
    await BluetoothLowEnergy.disconnect({ deviceId: id });
  }
  await Promise.all(listeners.map((l) => l.remove()));
  listeners = [];
}

为什么这样写:

  • 按服务过滤扫描。 startScan({ services: ['180D'] }) 仅返回心率传感器,因此无需匹配设备名称,因为不同型号和固件版本的设备名称各不相同。
  • 在订阅之前注册监听器,否则首批通知可能在 handler 就绪前到达。
  • 连接成功后停止扫描。 在部分 Android 手机上,扫描会消耗电量并降低连接速度。
  • 设备 ID 因平台而异:Android 上是 MAC 地址,iOS 上是每个应用各自的 UUID。保存拿到的值,并且只在同一设备上复用。

读取传感器位置与电量

const LOCATIONS = ['Other', 'Chest', 'Wrist', 'Finger', 'Hand', 'Ear lobe', 'Foot'];

export async function readSensorInfo(deviceId: string) {
  const location = await BluetoothLowEnergy.readCharacteristic({
    deviceId,
    service: HEART_RATE_SERVICE,
    characteristic: BODY_SENSOR_LOCATION,
  }).catch(() => null);

  const battery = await BluetoothLowEnergy.readCharacteristic({
    deviceId,
    service: BATTERY_SERVICE,
    characteristic: BATTERY_LEVEL,
  }).catch(() => null);

  return {
    location: location ? LOCATIONS[location.value[0]] ?? 'Unknown' : 'Unknown',
    batteryPercent: battery ? battery.value[0] : null,
  };
}

规范中这两项均为可选,因此请封装读取操作并处理缺失值。

构建界面

一个包含连接按钮、实时 BPM 和接触警告的极简 UI:

<main>
  <h1>Heart rate</h1>
  <p id="bpm" aria-live="polite">--</p>
  <p id="status"></p>
  <button id="connect" type="button">Connect sensor</button>
</main>
import { startHeartRate } from './heart-rate';

const bpmEl = document.getElementById('bpm')!;
const statusEl = document.getElementById('status')!;

document.getElementById('connect')!.addEventListener('click', async () => {
  statusEl.textContent = 'Searching...';
  try {
    await startHeartRate((m) => {
      bpmEl.textContent = m.contactDetected === false ? '--' : `${m.bpm} bpm`;
      statusEl.textContent = m.contactDetected === false ? 'Check strap contact' : 'Connected';
    });
  } catch (e) {
    statusEl.textContent = (e as Error).message;
  }
});

通过点击按钮启动扫描,而不是在页面加载时自动开始。iOS 和 Android 首次都会弹出权限弹窗,用户明白原因后更容易接受。

在设备上运行:

bun run build
bunx cap run android
bunx cap run ios

使用 RR 间期计算 HRV

RR 间期是两次心跳之间的时间间隔。通过它们可以计算心率变异性 (HRV),应用常用它来得出恢复和压力评分。常见指标是滑动窗口内的 RMSSD:

export function rmssd(rr: number[]): number | null {
  if (rr.length < 2) return null;
  let sum = 0;
  for (let i = 1; i < rr.length; i++) sum += (rr[i] - rr[i - 1]) ** 2;
  return Math.sqrt(sum / (rr.length - 1));
}

在静息状态下至少采集一分钟的间期,并剔除运动伪影造成的异常值(例如与前一个间期相差超过 20% 的间期)。

将读数保存到 Apple Health 或 Health Connect

若要让其他 App 也能读取这些读数,请用以下方式写入: @capgo/capacitor-health在短时间窗口内取平均,而不是每次通知都写入:

import { Health } from '@capgo/capacitor-health';

await Health.requestAuthorization({ write: ['heartRate'] });

export async function saveAverage(bpm: number, start: Date, end: Date) {
  await Health.saveSample({
    dataType: 'heartRate',
    value: bpm,
    unit: 'bpm',
    startDate: start.toISOString(),
    endDate: end.toISOString(),
  });
}

在 iOS 上,HealthKit 需要 HealthKit capability 和用途说明字符串。Android 上,Health Connect 需要在 manifest 中声明权限,并提供隐私政策页面。我们的 Google Fit 迁移至 Health Connect 指南 涵盖了这些配置步骤。

在运动期间保持监测器运行

  • iOS:使用 bluetooth-central 中的 UIBackgroundModes,即使应用处于后台且屏幕已锁定,通知仍会持续到达。在内存压力下,iOS 仍可能终止连接,因此请保留重连逻辑。
  • Android:已建立的 GATT 会话会在进程存活期间持续推送通知。对于屏幕关闭且持续几分钟以上的锻炼,Android 可能会冻结或杀死应用。简单的解决方法是在活动会话期间保持屏幕常亮,使用 @capgo/capacitor-keep-awake (KeepAwake.keepAwake() 以及 KeepAwake.allowSleep())。若要实现真正的后台录制,请添加自定义前台服务,使用 android:foregroundServiceType="connectedDevice" 以及 FOREGROUND_SERVICE_CONNECTED_DEVICE 权限。

故障排除

未找到设备:未佩戴心率带或电极片干燥。许多心率带仅支持单连接,请先断开与手表、码表或其他 App 的连接。

requestPermissions 在 Android 11 及以下版本返回拒绝:这些版本的 BLE 扫描需要位置权限,并需开启位置服务。请检查 isLocationEnabled() 并调用 openLocationSettings().

Android 12+ 扫描无结果,但在 Android 11 上正常:用户拒绝了“附近设备”权限。请引导用户前往 openAppSettings().

心率始终为 0:未接触皮肤。请检查 contactDetected.

数值超过 255 bpm 或为随机数:你忽略了 flag bit 0,或者按大端序(big-endian)读取 16 位值。请使用上面的解析器。

Android 手机锁屏后通知停止:电池优化。会话期间保持屏幕常亮,或使用前台服务。

部分设备在 Google Play 上找不到该应用:插件的 uses-feature BLE 默认是必需的。请按 Android 配置章节所示覆盖。

后续步骤

同样的模式也适用于其他标准 GATT profile:骑行速度与踏频(0x1816),跑步速度与步频(0x1814),以及骑行功率(0x1818)。只有解析器会变。请参阅 Bluetooth Low Energy 插件文档 用于写入、MTU 请求和外设模式。原生应用上架后,解析修复和 UI 变更都是 JavaScript,因此你可以把它们 Capgo 实时更新 发布,而无需等待审核。

为 Capacitor 应用提供 Live Updates

当 Web 层出现线上 Bug 时,通过 Capgo 发布修复,而不用等 App Store 审核好几天。用户会在后台收到更新,原生变更则仍走正常审核流程。

博客最新动态

Capgo 为你提供打造真正专业的移动应用所需的最有价值的洞察。