跳转至正文

如何用 Capacitor 构建心率监测器

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

如何用 Capacitor 构建心率监测器

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

BLE 心率传感器的工作原理

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

一个 peripheral 会公开 services,而每个 service 包含 characteristics,这些是你可以读取、写入或订阅的小数值。标准服务的 16 位 UUID 由 Bluetooth SIG 分配:

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

安装 Bluetooth Low Energy 插件

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>

解码心率测量值

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

字节 含义
0 标志位
1 或 1-2 心率:若标志位第 0 位为 0,则为 UINT8;若为 1,则为小端序 UINT16
接下来的 2 个(可选) 消耗能量 (kJ),若标志位第 3 位为 1 则存在
剩余部分(可选) RR 间期,小端序 UINT16,单位 1/1024 s,若标志位第 4 位为 1 则存在

标志位 1 和 2 用于描述皮肤接触情况:第 2 位表示传感器是否支持接触检测,第 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'] }) 仅返回心率传感器,这样你就无需匹配设备名称,因为不同型号和固件版本的名称各不相同。
  • 在订阅之前先注册监听器,否则第一条通知可能会在你的处理函数准备就绪前就已到达。
  • 连接成功后停止扫描。 扫描会消耗电池电量,并在某些 Android 手机上降低连接速度。
  • 设备 ID 在不同平台下有所不同:Android 上是 MAC 地址,iOS 上是每个 App 对应的 UUID。请存储获取到的 ID,且仅在同一台设备上复用。

读取传感器位置与电量

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 在首次运行时都会显示权限 prompt,当用户了解原因时,更有可能点击允许。

在设备上运行:

bun run build
bunx cap run android
bunx cap run ios

使用 RR 间期计算 HRV

RR 间期是指两次心跳之间的时间间隔。通过它们可以计算心率变异性 (HRV),许多 App 利用该指标来评估恢复情况和压力水平。常用的指标是滑动窗口内的 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()来保持屏幕常亮。若需实现真正的后台记录,请配合使用您自己的 foreground service。 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 手机锁屏后通知会停止:电池优化。请在会话期间保持屏幕常亮,或使用 foreground service。

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

下一步

同样的模式也适用于其他标准 GATT profile:骑行速度与踏频 (0x1816)、跑步速度与踏频 (0x1814),以及骑行功率 (0x1818)。只需更换解析器即可。请参阅 Bluetooth Low Energy 插件文档 用于写入、MTU 请求和外设模式。一旦原生应用已上架应用商店,解析修复和 UI 变更都属于 JavaScript 层面的改动,因此你可以通过 Capgo Live Update 直接发布,而无需等待审核。

为 Capacitor 应用提供实时更新

当 Web 层 Bug 已上线时,通过 Capgo 推送修复,不必等 App Store 审核数天。用户会在后台获取更新,而原生变更仍走常规审核流程。

博客最新文章

Capgo 为你提供打造真正专业移动应用所需的深度洞察。