若要使用 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 直接发布,而无需等待审核。