2026/10/10 10:21:29

iOS蓝牙开发实战:从CoreBluetooth状态机到数据读写完整指南

iOS蓝牙开发实战:从CoreBluetooth状态机到数据读写完整指南 简介这份资源面向具备一定iOS基础、希望系统掌握蓝牙低功耗开发的移动端开发者围绕苹果Core Bluetooth框架讲解CBCentralManager、CBPeripheral、CBService、CBCharacteristic及GATT协议等核心概念帮助读者理清中央设备扫描、连接、服务发现与数据收发的完整流程。压缩包共39个文件约60KB以9个.m实现文件与6个.h头文件为主体配合12个png示意图、storyboard界面文件、plist配置及工程文件构成一个可直接运行的蓝牙示例工程。目前已有141人学习。通过该示例读者可对照代码理解扫描周边设备、连接目标外设、读取与写入特征值、订阅通知等关键环节并了解iOS 13及以上版本的蓝牙权限申请与处理方式为在健康追踪、智能穿戴等场景中集成BLE功能提供可复用的参考。1. 从零拆解 iOS 蓝牙开发为什么你的第一行代码总是连不上设备很多人第一次做 iOS 蓝牙开发代码照着文档敲完运行起来却卡在centralManagerDidUpdateState里出不来或者扫描半天一个外设都搜不到。这不是玄学是 CoreBluetooth 的状态机和权限模型在起作用。iOS 蓝牙开发的核心框架是 CoreBluetooth它把手机抽象成 Central中心设备把外设抽象成 Peripheral外围设备所有交互都围绕广播、扫描、连接、发现服务、读写特征值这条链路展开。这篇文章面向的是需要把蓝牙功能真正落地到 App 里的开发者不管你是要连智能手环、打印机、体脂秤还是自定义的 BLE 模块下面这套从权限配置到数据收发的完整路径都能直接照着复现。我会把每个环节的参数含义、常见翻车点和排查手段讲清楚让你少走我当年踩过的弯路。2. CoreBluetooth 的状态机与权限先把地基打对2.1 为什么蓝牙权限不是「弹窗同意」就完事iOS 的蓝牙权限分两层。第一层是系统级的蓝牙开关第二层是 App 级的隐私授权。从 iOS 13 开始NSBluetoothAlwaysUsageDescription成为必须项如果你还在用旧的NSBluetoothPeripheralUsageDescription在新系统上会直接崩溃或者静默失败。这个描述字符串会出现在系统弹窗里写清楚用途能提高用户同意率但更重要的是它必须存在。权限状态通过CBManager.authorization获取返回值有.allowedAlways、.denied、.restricted、.notDetermined四种。很多人只判断了CBCentralManager.state .poweredOn就开始扫描结果在未授权时state一直是.unauthorized扫描永远不触发。正确的做法是同时监听centralManagerDidUpdateState和授权状态变化。import CoreBluetooth class BluetoothManager: NSObject, CBCentralManagerDelegate { var centralManager: CBCentralManager! override init() { super.init() // 传入 nil 表示在主队列回调方便直接更新 UI centralManager CBCentralManager(delegate: self, queue: nil) } func centralManagerDidUpdateState(_ central: CBCentralManager) { switch central.state { case .poweredOn: print(蓝牙可用可以开始扫描) // 此时才应该调用 scanForPeripherals case .unauthorized: print(未授权检查 Info.plist 中的 NSBluetoothAlwaysUsageDescription) case .poweredOff: print(系统蓝牙未开启) case .unsupported: print(设备不支持 BLE) default: print(状态未知: \(central.state.rawValue)) } } }这段代码的关键在于CBCentralManager初始化后不会立刻可用必须等centralManagerDidUpdateState回调到.poweredOn才能执行扫描。queue参数传nil表示使用主队列如果你在后台线程处理大量数据可以传一个自定义的DispatchQueue但要注意 UI 更新需要切回主线程。NSBluetoothAlwaysUsageDescription的值要具体比如「需要蓝牙连接您的智能设备以同步数据」不要写「需要蓝牙权限」这种废话审核和用户信任都会受影响。2.2 扫描参数怎么设才不丢设备scanForPeripherals(withServices:options:)有两个参数值得细说。第一个是服务 UUID 数组传nil会扫描所有设备但这样耗电高且容易混入无关设备。实际项目中我建议至少传一个主服务的 UUID这样系统会在底层做过滤回调频率更合理。第二个参数options里最常用的是CBCentralManagerScanOptionAllowDuplicatesKey默认是false意味着同一个设备只回调一次。如果你需要根据 RSSI 做距离判断或者持续更新信号强度必须设为true但要注意这会显著增加回调次数扫描时间不宜过长。// 只扫描指定服务的设备并允许重复回调以获取实时 RSSI let services [CBUUID(string: FFF0)] let options: [String: Any] [ CBCentralManagerScanOptionAllowDuplicatesKey: true ] centralManager.scanForPeripherals(withServices: services, options: options) // 15 秒后主动停止避免长时间扫描耗电 DispatchQueue.main.asyncAfter(deadline: .now() 15) { self.centralManager.stopScan() print(扫描已停止) }didDiscover回调里会拿到peripheral、advertisementData、RSSI三个值。advertisementData里的CBAdvertisementDataLocalNameKey就是设备广播名但注意这个字段不是所有设备都广播有些厂商只在连接后才暴露名称。RSSI是负值越接近 0 信号越强一般 -50 以内算很近-90 以下基本不可用。扫描阶段不要做连接操作先把候选设备存到一个字典里用peripheral.identifier作为 key因为CBPeripheral对象每次回调可能是不同的实例但identifier是稳定的。3. 连接、发现服务与特征值把数据通道打通3.1 连接超时与断开重连的处理逻辑调用connect(_:options:)后成功会回调didConnect失败会回调didFailToConnect但这两个回调都不会自动超时。也就是说如果设备不在范围内你可能等很久都没有任何反馈。我的做法是启动一个定时器比如 10 秒内没有收到didConnect就主动调用cancelPeripheralConnection然后提示用户重试。func connect(_ peripheral: CBPeripheral) { centralManager.connect(peripheral, options: nil) // 10 秒超时保护 DispatchQueue.main.asyncAfter(deadline: .now() 10) { [weak self] in guard let self self else { return } if peripheral.state ! .connected { self.centralManager.cancelPeripheralConnection(peripheral) print(连接超时已取消) } } } func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) { print(已连接: \(peripheral.name ?? 未知设备)) peripheral.delegate self // 发现服务传 nil 表示发现所有服务 peripheral.discoverServices(nil) } func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) { print(连接断开: \(error?.localizedDescription ?? 无错误信息)) // 这里可以加入重连逻辑但要注意避免无限重试 }断开回调里的error很关键。如果是nil说明是主动断开或设备正常断开如果有值常见的是CBError.peripheralDisconnected或CBError.connectionTimeout。重连策略我一般设三次上限每次间隔递增避免疯狂重试导致系统资源耗尽。另外peripheral.delegate必须在连接成功后设置否则服务发现和特征值回调都不会触发。3.2 服务与特征值的发现顺序不能乱连接成功后第一步是discoverServices回调didDiscoverServices里拿到[CBService]。然后对每个感兴趣的服务调用discoverCharacteristics回调didDiscoverCharacteristicsFor里拿到[CBCharacteristic]。特征值的properties属性决定了你能对它做什么.read表示可读.write表示可写.notify表示可订阅通知.indicate表示可指示。写数据前必须检查properties是否包含.write或.writeWithoutResponse否则会直接报错。func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) { guard error nil else { return } for service in peripheral.services ?? [] { // 只发现我们关心的服务 if service.uuid CBUUID(string: FFF0) { peripheral.discoverCharacteristics(nil, for: service) } } } func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) { guard error nil else { return } for characteristic in service.characteristics ?? [] { // 订阅通知 if characteristic.properties.contains(.notify) { peripheral.setNotifyValue(true, for: characteristic) } // 读取初始值 if characteristic.properties.contains(.read) { peripheral.readValue(for: characteristic) } } } func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) { guard error nil, let data characteristic.value else { return } // 解析数据注意字节序和协议格式 let bytes [UInt8](data) print(收到数据: \(bytes)) }setNotifyValue(true, for:)是订阅通知的关键调用之后设备主动推送数据时会触发didUpdateValueFor。注意didUpdateValueFor既会响应readValue的结果也会响应通知推送你需要通过characteristic.uuid区分数据来源。数据解析时一定要确认字节序BLE 协议通常是小端序但有些厂商会自定义大端序这个坑我在三个项目里都遇到过血泪经验是拿到原始数据先打印十六进制和硬件同事确认协议文档再写解析逻辑。4. 数据读写与通知把字节流变成业务数据4.1 写数据的两种方式与分包策略写特征值有两个方法writeValue(_:for:type:)和writeValue(_:for:type:)配合.withoutResponse。带响应的写会回调didWriteValueFor确认写入成功不带响应的写速度更快但不保证送达。BLE 单次写入有 MTU 限制默认是 20 字节超过会失败。iOS 从 10 开始支持协商 MTU但需要外设配合。实际做法是主动分包每包不超过 20 字节包与包之间加 20 到 50 毫秒延时。func writeData(_ data: Data, to characteristic: CBCharacteristic, peripheral: CBPeripheral) { let maxLength 20 let total data.count var offset 0 while offset total { let chunkSize min(maxLength, total - offset) let chunk data.subdata(in: offset..(offset chunkSize)) // 根据特征值属性选择写入类型 let type: CBCharacteristicWriteType characteristic.properties.contains(.write) ? .withResponse : .withoutResponse peripheral.writeValue(chunk, for: characteristic, type: type) offset chunkSize // 带响应的写需要等回调这里简单延时生产环境建议用信号量或串行队列 if type .withResponse { Thread.sleep(forTimeInterval: 0.05) } } }这段代码里Thread.sleep只是为了演示分包节奏实际项目中不要在主线程阻塞。更好的做法是用DispatchQueue串行队列每包写入后在didWriteValueFor回调里发下一包。CBCharacteristicWriteType的选择要看特征值属性如果只有.writeWithoutResponse却传了.withResponse系统会直接抛异常。4.2 通知订阅的开关时机与数据粘包setNotifyValue(true, for:)之后外设会按照自己的频率推送数据。这里有两个常见问题一是订阅后没有立刻收到数据因为外设可能只在状态变化时才推送二是多个通知包在didUpdateValueFor里合并到达需要自己做粘包处理。我的做法是在数据头部加长度字段或者用固定的结束符分割。// 假设协议格式为包头(0xAA) 长度(1字节) 数据 校验和 var receiveBuffer Data() func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) { guard error nil, let data characteristic.value else { return } receiveBuffer.append(data) while receiveBuffer.count 3 { let bytes [UInt8](receiveBuffer) guard bytes[0] 0xAA else { // 包头不对丢弃第一个字节继续找 receiveBuffer.removeFirst() continue } let payloadLength Int(bytes[1]) let totalLength 2 payloadLength 1 // 包头长度数据校验 guard receiveBuffer.count totalLength else { break } let payload receiveBuffer.subdata(in: 2..(2 payloadLength)) let checksum bytes[2 payloadLength] // 校验逻辑根据协议实现 if validateChecksum(payload, checksum) { handlePayload(payload) } receiveBuffer.removeFirst(totalLength) } }这个粘包处理逻辑的核心是维护一个receiveBuffer每次收到数据就追加然后循环尝试解析完整包。removeFirst在数据量大时性能一般可以用索引游标优化但一般 BLE 数据量不大这样写足够清晰。校验和的计算方式必须和硬件端一致常见的是累加和取反或 CRC16这个要对齐文档。5. 避坑与排查那些让你加班到凌晨的蓝牙问题5.1 扫描不到设备但系统蓝牙里能看到现象App 里scanForPeripherals回调一直不触发但进入系统设置蓝牙列表能看到目标设备。原因通常是scanForPeripherals的withServices参数传了错误的 UUID或者设备广播包里根本不包含这个服务 UUID。解决方法是先传nil扫描所有设备确认能搜到目标后再逐步缩小服务过滤范围。另外检查CBCentralManager是否在.poweredOn之后才调用扫描提前调用会被系统忽略。5.2 连接成功但发现不了任何服务现象didConnect回调触发但didDiscoverServices返回空数组或报错。原因可能是设备需要先进行配对或认证才能暴露服务或者peripheral.delegate没有设置。解决方法是确认peripheral.delegate self在连接成功后立即执行并且检查设备是否需要调用discoverServices之前先做某种握手。有些加密设备会在连接后要求写入一个密钥特征值才开放其他服务。5.3 写入数据成功但设备没反应现象didWriteValueFor回调没有错误但外设行为不符合预期。原因通常是数据格式不对比如字节序反了、缺少包头、校验和错误。解决方法是把写入的原始字节打印成十六进制和硬件同事逐字节核对协议文档。我遇到过把UInt16直接withUnsafeBytes写入导致小端序和大端序颠倒的情况设备收到后解析出完全错误的值。5.4 后台运行时蓝牙断开现象App 切到后台几分钟后蓝牙连接断开通知也收不到。原因是 iOS 后台蓝牙需要开启bluetooth-central后台模式并且要在 Xcode 的 Signing Capabilities 里添加 Background Modes。但即使开启系统也会在资源紧张时回收且扫描频率会大幅降低。解决方法是评估业务是否真的需要后台常驻如果只是短暂切换可以在applicationDidEnterBackground时保持连接但停止扫描回到前台再恢复。5.5 多设备连接时状态混乱现象同时连接多个外设时回调里的peripheral参数分不清是哪个设备。原因是CBPeripheral对象在多次扫描中可能是不同实例但identifier是唯一的。解决方法是用[UUID: CBPeripheral]字典维护设备映射所有回调里先通过peripheral.identifier查找业务上下文再处理数据。不要依赖peripheral.name做唯一标识因为多个设备可能同名。6. 进阶技巧用 LLDB 和 Packet Logger 定位玄学问题当你把上面的流程都跑通后仍然可能遇到一些「时好时坏」的问题比如偶尔连接失败、通知延迟、数据错乱。这时候光靠print已经不够了需要用工具抓底层数据。Xcode 自带的 Packet Logger 可以抓取 BLE 空口包但需要配合 Apple 的额外工具安装。更轻量的做法是用 LLDB 在回调里下断点查看CBPeripheral和CBCharacteristic的完整状态。# 在 LLDB 中查看 peripheral 的所有服务 po peripheral.services # 查看某个特征值的当前值十六进制 po characteristic.value as NSData # 查看 centralManager 的当前状态 po centralManager.state.rawValuepo命令在蓝牙调试里非常有用尤其是po characteristic.value as NSData能直接打印十六进制比在代码里写转换逻辑快得多。另外CBPeripheral的state属性在 LLDB 里也能直接查看连接不稳定时先确认state是不是.connected。另一个技巧是模拟弱信号环境。把设备放进金属盒或者用手捂住观察 RSSI 降到 -90 以下时连接是否断开、数据是否丢包。我一般会在didUpdateValueFor里加一个 RSSI 阈值判断低于 -85 时提示用户靠近设备低于 -95 时主动断开并触发重连。这个策略在智能锁和体脂秤项目里都显著降低了客诉。最后说一个我自己的习惯每次新建蓝牙项目先写一个最小扫描 Demo只做扫描和打印设备名确认权限和状态机没问题后再加连接和读写。这个习惯帮我省掉了至少三次在复杂业务代码里排查权限问题的加班。蓝牙开发没有捷径但把状态机、权限、分包、粘包这四个点吃透剩下的就是和硬件同事对齐协议的事了。希望帮到你。本文还有配套的精品资源点击获取