心率带是目前运动监测场景中最常见也最可靠的蓝牙外设之一,几乎所有正规厂商生产的心率带都遵循蓝牙SIG制定的Heart Rate Profile标准。这意味着只要掌握了标准的服务和特征值结构,用Core Bluetooth写一次代码,就能兼容Polar、Garmin、Wahoo等绝大多数设备。本文按照实际开发顺序,完整梳理从扫描连接到数据解析的全过程。

扫描与连接:CBCentralManager的正确使用姿势
Core Bluetooth把手机一侧称为中心设备,心率带则是外设。开发的第一步是创建CBCentralManager实例并实现它的代理回调。这里有个新手常踩的坑:CBCentralManager必须在主线程创建,而且创建后不能立刻调用扫描方法,要等centralManagerDidUpdateState回调返回poweredOn状态才行。蓝牙模块的初始化是异步的,太早扫描会直接失败。
扫描时强烈建议带上服务UUID过滤参数,而不是扫描所有蓝牙设备。手机周围可能同时存在十几个BLE广播,全量扫描不仅耗电,还会让过滤逻辑变复杂。心率带的标准服务UUID是0x180D,对应完整的128位字符串180D。扫描到目标外设后调用connect发起连接,连接成功后系统会回调didConnect方法,此时才能进入服务发现阶段。
import CoreBluetooth
class HeartRateManager: NSObject, CBCentralManagerDelegate {
var centralManager: CBCentralManager!
var heartRatePeripheral: CBPeripheral?
override init() {
super.init()
centralManager = CBCentralManager(delegate: self, queue: nil)
}
func centralManagerDidUpdateState(_ central: CBCentralManager) {
guard central.state == .poweredOn else { return }
// 只扫描包含心率服务的外设
central.scanForPeripherals(withServices: [CBUUID(string: "180D")],
options: [CBCentralManagerScanOptionAllowDuplicatesKey: false])
}
func centralManager(_ central: CBCentralManager,
didDiscover peripheral: CBPeripheral,
advertisementData: [String: Any],
rssi RSSI: NSNumber) {
print("发现设备: \(peripheral.name ?? "未知") RSSI: \(RSSI)")
heartRatePeripheral = peripheral
peripheral.delegate = self
central.stopScan()
central.connect(peripheral, options: nil)
}
func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
// 连接成功后开始发现服务
peripheral.discoverServices([CBUUID(string: "180D")])
}
}连接环节还要考虑失败重连机制。如果外设超出距离断开,didDisconnectPeripheral回调中的error参数为非空时表示异常断开,此时调用connect重新连接,系统会在设备重新进入范围后自动恢复连接。另外可以为connect调用设置超时保护,iOS本身没有连接超时参数,需要自己起一个Timer,超过规定时间没有回调didConnect就执行cancelPeripheralConnection并提示用户。
服务发现与特征值订阅:数据通道的建立
连接成功只是建立了链路,此时还拿不到任何数据。必须调用discoverServices进行服务发现,系统会从外设的GATT数据库中读取服务列表,然后通过peripheral:didDiscoverServices回调返回。发现服务后继续在服务下调用discoverCharacteristics发现特征值,心率服务下通常包含两个特征:0x2A37心率测量和0x2A38身体传感器位置,前者承载数据,后者是只读的描述信息。
拿到心率测量特征后,关键是判断它的属性。心率测量特征支持Notify通知方式,这是BLE中最高效的数据推送模式——外设端有新数据时主动推给手机,手机端不需要轮询读取。调用setNotifyValue(true, for:)订阅通知后,数据会持续通过didUpdateValueFor回调送进来。有些设备还支持指示Indicate方式,区别在于Indicate需要手机端每收一包就回一个确认,Notify则不需要,心率带基本都用Notify。
extension HeartRateManager: CBPeripheralDelegate {
func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {
guard let services = peripheral.services else { return }
for service in services where service.uuid == CBUUID(string: "180D") {
// 发现心率服务下的所有特征值
peripheral.discoverCharacteristics(nil, for: service)
}
}
func peripheral(_ peripheral: CBPeripheral,
didDiscoverCharacteristicsFor service: CBService,
error: Error?) {
for characteristic in service.characteristics ?? [] {
if characteristic.uuid == CBUUID(string: "2A37") {
// 订阅心率测量通知
peripheral.setNotifyValue(true, for: characteristic)
}
}
}
func peripheral(_ peripheral: CBPeripheral,
didUpdateNotificationStateFor characteristic: CBCharacteristic,
error: Error?) {
if let error = error {
print("订阅失败: \(error.localizedDescription)")
}
}
}这里提醒一点,discoverCharacteristics传nil表示发现所有特征,正式项目里最好传指定的UUID数组,减少不必要的GATT交互。另外服务发现是层层递进的异步过程,千万不要在连接成功的回调里直接去读特征值,此时特征对象还不存在,强制访问会导致崩溃或者拿到空值。
心率数据解析协议:字节级拆解0x2A37特征值
心率测量特征返回的是一包结构化的字节数据,格式由蓝牙SIG的GATT Specification严格定义。第一个字节是标志位,它的最低位决定了心率值用1个字节还是2个字节表示:标志位为0时心率值是UInt8,最大255,绝大多数场景够用;标志位为1时心率值是UInt16小端序,用于某些高强度场景。很多开发者不做这个判断直接按1字节解析,遇到 Polar H10 这类设备在特定模式下就会解析出错误数据。
标志位的第4位表示后面是否附带RR间期数据,这是心率变异性分析HRV的基础数据,单位是1/1024秒。第2到3位表示能量消耗字段是否存在。解析时要用位运算逐个判断标志位,然后按顺序移动读取偏移量。下面是完整的解析实现:
struct HeartRateMeasurement {
var heartRateValue: Int = 0
var rrIntervals: [Double] = []
var sensorContact: Bool = false
}
func parseHeartRate(data: Data) -> HeartRateMeasurement {
var result = HeartRateMeasurement()
let bytes = [UInt8](data)
guard let flags = bytes.first else { return result }
// 标志位第0位:心率值宽度
var offset = 1
if flags & 0x01 != 0 {
// UInt16小端序
guard bytes.count >= offset + 2 else { return result }
result.heartRateValue = Int(bytes[offset]) | (Int(bytes[offset + 1]) << 8)
offset += 2
} else {
guard bytes.count >= offset + 1 else { return result }
result.heartRateValue = Int(bytes[offset])
offset += 1
}
// 标志位第1、2位:传感器接触状态
if flags & 0x06 != 0 {
result.sensorContact = (flags & 0x02) != 0
}
// 标志位第3位:能量消耗字段,跳过2字节
if flags & 0x08 != 0 {
offset += 2
}
// 标志位第4位:RR间期,每条2字节小端序,可能有多条
if flags & 0x10 != 0 {
while offset + 1 < bytes.count {
let rrRaw = Int(bytes[offset]) | (Int(bytes[offset + 1]) << 8)
result.rrIntervals.append(Double(rrRaw) / 1024.0 * 1000.0) // 转成毫秒
offset += 2
}
}
return result
}拿到解析结果后,建议在UI更新前做一个简单的数据校验,比如心率值超出合理范围30到220就丢弃,RR间期异常短或者异常长的数据过滤掉。蓝牙传输偶尔会有干扰产生脏数据,运动场景下这些异常值如果不处理,图表上会出现突兀的尖刺。还可以结合RSSI信号强度判断设备是否即将离开连接范围,提前做好断线提示。
工程化实践:后台运行、耗电与多设备管理
运动类App通常需要在锁屏后台持续接收心率数据,这要求在Info.plist中声明bluetooth-central后台模式,并使用state preservation and restoration机制。开启方式是在创建CBCentralManager时传入restoreIdentifier选项,系统杀掉App后蓝牙事件到来时会重新拉起App并恢复先前的扫描和连接状态。需要注意的是后台模式下扫描的广播回调频率会被降低,但已建立连接的通知数据推送不受影响,所以心率这种基于Notify的数据流在后台是完全可靠的。
耗电方面有几个优化点值得注意:扫描时关闭AllowDuplicates选项避免重复广播唤醒CPU;不使用的外设及时调用cancelPeripheralConnection断开,因为iOS对未主动断开的外设会维持底层连接,白白消耗电量;如果业务只关心心率不关心RR间期,可以优先选择不带RR数据的设备或模式,数据包更小传输更省电。
如果需要同时连接多条心率带,比如团队训练场景,Core Bluetooth本身支持一个中心设备连接多个外设,只要为每个外设维护独立的CBPeripheral引用并分别订阅即可。实践中建议封装一个外设管理类,用字典维护外设标识与业务模型的映射,统一处理连接状态机、自动重连和数据分发,避免代理回调的判断逻辑散落在各处难以维护。
// 兼容iOS 10以上版本的蓝牙权限声明
// Info.plist中必须添加:
// NSBluetoothAlwaysUsageDescription 用于说明蓝牙用途
// UIBackgroundModes 中添加 bluetooth-central 以支持后台采集
// 判断当前设备是否支持蓝牙4.0以上
if CBCentralManager.authorization == .denied {
// 引导用户去设置页开启蓝牙权限
print("蓝牙权限被拒绝,请在设置中开启")
}最后说说真机调试的技巧。Xcode自带的Bluetooth Explorer工具在老版本中可以查看底层HCI日志,现在更推荐用PacketLogger抓取完整的蓝牙交互包,配合GATT规范文档逐包核对,遇到解析不对的问题时能快速定位是设备端数据格式特殊还是自己的偏移量算错了。心率带的开发难度其实不高,只要严格按照标准协议走完服务发现、特征订阅、字节解析这三步,再做上健壮的错误处理和重连逻辑,就能做出稳定可靠的蓝牙心率采集功能。
Core BluetoothGATT服务心率带修改时间:2026-09-13 19:53:16