iOS 蓝牙开发这个方向,刚接触的人容易有两极化的感觉:一种是觉得“不就是调几个代理方法吗”,真上手才发现光是系统蓝牙权限的弹窗时机就能把人绕晕;另一种是被网上各种零散代码片段吓住,以为要从底层Socket写起,实际上苹果的 CoreBluetooth 框架已经帮你挡住了绝大部分硬件的复杂性。
我最近在做一款智能硬件的配套App,需要和自研的BLE设备做双向通信,整个开发加调试走了不少弯路。这篇文章就把我从权限配置到真机数据收发这整条链路重新捋一遍,把我踩过的坑、试出来的可靠做法,以及排查问题的思路都写清楚。内容适配两种人:一是马上要接手蓝牙模块、想快速跑通主流程的iOS开发者,二是已经能跑起来但经常被断连、数据错乱、后台模式这类问题折磨的进阶选手。
1. 开工前先分清:CoreBluetooth 跑的是 BLE,不是所有蓝牙
很多人第一次接到需求时,硬件同事抛过来一句“我们用蓝牙4.0”,这时候你脑子里要立即反映出:蓝牙4.0意味着低功耗蓝牙(BLE),也就是 CoreBluetooth 框架的管辖区。
1.1 两种蓝牙协议栈的适用边界
iOS 里蓝牙开发其实有两条路线:
| 方案 | 框架 | 适用场景 | 硬件要求 |
|---|---|---|---|
| BLE 低功耗 | CoreBluetooth | 智能手环、传感器、小数据量控制指令 | 蓝牙4.0以上 |
| 经典蓝牙/高速率 | ExternalAccessory | 音频传输、高吞吐透传、MFi外设 | 需要MFi认证,苹果审核严格 |
我做的是低功耗设备App,所以全程都在 CoreBluetooth 的体系内。这里有个关键概念要先立起来:BLE 的数据承载能力非常有限,理论带宽不高,实际应用里往往还要被 MTU(最大传输单元)进一步限制。所以它天生适合控制指令、状态上报这类小数据包场景,不适合做音视频类大流量传输。
1.2 理解中心设备和外围设备的关系
CoreBluetooth 里最容易混淆的就是角色问题。手机端我们通常扮演 CBCentralManager(中心管理器),而硬件设备是 CBPeripheral(外围设备)。这个关系从名字就能看出来:手机是核心,设备是外设,一切以手机为发起方。
但要注意,这个角色划分不是绝对的。如果你的App要跟另一台手机通信,一台做 Central、一台做 Peripheral 也可以,只是绝大多数硬件App场景都是中心-外围模式。我在项目里也试过让 iPhone 广播自己的服务,用来做两台手机间的近场小文件传输,那走的是 CBPeripheralManager,逻辑跟在设备端看到的视角一致,理解一遍以后对调试会更有帮助。
2. Info.plist、扫描、连接:代理回调三件套的一次性打通
跑通蓝牙App主流程其实就三步:请求权限、扫描设备、发起连接。每一步都有对应的系统回调,而回调不到、回调慢、回调乱这三类问题,几乎覆盖了所有初期开发抱怨。
2.1 权限配置与系统弹窗的触发时机
iOS 13 之后,蓝牙权限被收紧,App 的 Info.plist 里必须添加描述文案,否则访问蓝牙时系统会直接崩溃。
在 Info.plist 中添加:
<key>NSBluetoothAlwaysUsageDescription</key> <string>需要使用蓝牙连接智能硬件设备进行数据同步</string>仅此一项,iOS 13 以上的设备就够用了。iOS 12 及以下还需要 NSBluetoothPeripheralUsageDescription,如果你的最低支持版本还没有到 iOS 13,建议两行都写上。
这里有个容易踩的认知偏差:权限弹窗并不是在创建 CBCentralManager 时出现,而是在你调用扫描方法(scanForPeripherals)之后系统才会真正弹出。如果你的代码里初始化管理器但迟迟不扫描,用户将看不到任何弹窗,然后测试人员会来问“手机有没有弹出权限”。这个时机问题我第一次做的时候就搞错过。
2.2 初始化管理器与状态回调的顺序问题
CBCentralManager 的初始化是异步的,它的 state 属性需要时间从 .unknown 过渡到 .poweredOn。如果你在初始化后立刻去扫描,往往得到的是操作无效果。
正确做法是监听状态回调,在回调里驱动后续动作:
import CoreBluetooth class BluetoothManager: NSObject, CBCentralManagerDelegate { private var centralManager: CBCentralManager! override init() { super.init() centralManager = CBCentralManager(delegate: self, queue: .main) } func centralManagerDidUpdateState(_ central: CBCentralManager) { switch central.state { case .poweredOn: startScan() case .poweredOff: print("蓝牙已关闭") case .unauthorized: print("权限被拒绝") case .unsupported: print("设备不支持BLE") default: break } } }我个人的习惯是回调里只做状态分发,不在里面堆扫描逻辑之外的业务代码,保持状态机清晰。这一步做到了,后面至少能少排查一半的玄学问题。
2.3 扫描参数与代理回调的执行细节
扫描方法长这样:
func startScan() { centralManager.scanForPeripherals(withServices: nil, options: [CBCentralManagerScanOptionAllowDuplicatesKey: false]) }withServices 参数有两个选择:传 nil 扫描所有设备,或者传你关注的服务 UUID 数组。实际开发中强烈建议传具体 UUID,一来省电,二来避免在办公室扫出一堆别人的设备。
但这里有个真实存在的坑:有些硬件设备广播包里并没有携带服务 UUID,或者只做了少量广播数据,你用 UUID 过滤可能什么都扫不到。稳妥的做法是开发阶段先用 nil 把所有设备扫出来,看设备名、RSSI、广播数据,确定特征后再收紧过滤条件。我这边设备刚开始调试时广播包不完整,花了不少时间确认这一点。
发现设备的代理:
func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String : Any], rssi RSSI: NSNumber) { if peripheral.name == "MyDevice" { centralManager.stopScan() centralManager.connect(peripheral, options: nil) } }扫描到设备后要立即 stopScan,否则会在回调里反复收到同一设备,而且持续的扫描对连接稳定性有一定干扰。连接是异步的,所以接下来要处理连接的回调。
2.4 连接、失败、断开的三个回调
连接相关的代理有三个,它们容易被混在一起,但语义完全不同:
- didConnect:连接成功了
- didFailToConnect:连接发出了但失败了,可能是设备离线或信号太差
- didDisconnectPeripheral:连接建立后因为某种原因被断开
这三个回调你最好都打日志,尤其是断开回调,里面带错误参数,是排查异常断连的第一手信息:
func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) { if let error = error { print("异常断开: \(error.localizedDescription)") } else { print("正常断开") } }我在项目里就是靠这个错误信息区分了“设备主动断开”和“链路意外丢失”,从而决定到底是触发重连还是保留当前界面状态。
3. 从设备到特征:连接之后的数据读写链路
连接成功只是拿到了设备的“入口”,真正收发数据还得走完一层层“树”结构:设备里有服务,服务里有特征,特征才有读写和通知的能力。
3.1 服务与特征发现机制
连接成功后第一步是发现服务:
func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) { peripheral.delegate = self peripheral.discoverServices([uuidOfService]) }在 didDiscoverServices 回调后继续发现特征:
func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) { guard let services = peripheral.services else { return } for service in services { peripheral.discoverCharacteristics([uuidOfCharacteristic], for: service) } }这里的 UUID 全部要和硬件端约定好,硬件固件里服务UUID和特征UUID是写死的,App 端必须保持一致。换一个不匹配的 UUID,等待你的就是回调永远不来,没有任何报错提示。
3.2 读、写、通知各自的使用场景
特征读写有三种基本模式,对应不同的硬件设计:
| 模式 | 方法 | 适用场景 | 注意事项 |
|---|---|---|---|
| 读 | readValue(for:) | 主动查一次当前值 | 返回在 didUpdateValueFor 回调 |
| 写(有响应) | writeValue(_:for:type: .withResponse) | 需要确认的指令下发 | 硬件处理完会回复,可靠但慢 |
| 写(无响应) | writeValue(_:for:type: .withoutResponse) | 高频下发、实时控制 | 快但硬件不一定每次收到 |
| 通知 | setNotifyValue(true, for:) | 硬件主动上报状态、数据流 | 最常用的实时通道 |
硬件如果设计成 Notify 上报,App 端要做的是打开订阅,之后硬件有新数据会不断往回调里推。订阅代码很简单:
peripheral.setNotifyValue(true, for: characteristic)推荐把 Notify 通道作为数据主动上报的默认方式,而不是让客户端轮询 read。BLE 本身是为突发小数据设计的,Notify 可以在硬件数据更新时立刻推到手机,响应速度和实时性都远好于轮询,同时因为避免周期性空转,整体功耗也更友好。
3.3 MTU 限制与分包策略
BLE 单包能承载的数据量有限,默认 MTU 按 23 字节算,减去 3 字节的 ATT 头之后,实际应用层可用数据只有 20 字节左右。iPhone 上可以协商更大的 MTU,比如 185 字节或更高,但最终取决于硬件端支持的上限。
为什么要分包?以我正在写的设备举例,硬件上报一条完整状态记录大约是 150 字节,如果不做分包,一次 write 或一次 Notify 根本塞不下。可靠做法是应用层协议里约定好“包头 + 包序号 + 数据长度 + 数据体”,然后按照协商好的 MTU 切包发送,接收端按序号拼包。
我在发送端做了这样的处理:
func sendData(_ data: Data, to characteristic: CBCharacteristic) { let mtu = peripheral.maximumWriteValueLength(for: .withoutResponse) var offset = 0 while offset < data.count { let length = min(data.count - offset, mtu) let chunk = data.subdata(in: offset..<(offset + length)) peripheral.writeValue(chunk, for: characteristic, type: .withoutResponse) offset += length } }分包时序还牵涉到硬件端是否做背压控制——硬件没准备好时你发太快,丢包率会上来。所以我在分包发送循环里加了一个小的间隔控制,而不是纯 for 循环突突突地发完。具体间隔要根据硬件处理速度调整,常见的做法是每包之间延迟 10-20 毫秒,如果硬件接收缓存大也可以不延迟。
4. 状态机才是蓝牙模块的灵魂:断连、重连、后台模式的应对
这是我在实际项目里打磨最久的一部分。很多人把蓝牙模块写成“连接-发送-接收-断开”这种线性流程,结果一遇到断连就懵了,不知道当前处于什么状态、该恢复还是该重连。正确的做法是维护一个状态机。
4.1 五个核心状态的流转设计
我的蓝牙状态机就五态:待机中(Idle)、扫描中(Scanning)、连接中(Connecting)、已连接(Connected)、重连中(Reconnecting)。
各个状态的关键变化点放在回调里:
- 启动扫描:Idle -> Scanning
- 发现目标设备并调用 connect:Scanning -> Connecting
- didConnect:Connecting -> Connected
- 异常断开且需要恢复业务:Connected -> Reconnecting
- 重连成功:Reconnecting -> Connected
- 用户主动取消或超时:Reconnecting -> Idle
状态机的好处是每个回调事件都有一个清晰的上下文:你在 didDisconnectPeripheral 里需要判断之前是 Connected 还是 Reconnecting,就能决定是走用户主动断开的逻辑还是走自动重连的逻辑。
4.2 自动重连的幂等处理
重连最忌讳的是重复触发扫描和连接。如果断线回调触发后,重新进入扫描、又发现设备、又去连接,那整个流程是自洽的。但如果你在用户触发、代理回调、业务层定时器里各自搞一套重连,就会出现“同时两个连接请求在飞”的局面,系统日志里能看见不和谐的回调顺序,UI 端更是乱套。
我的做法是:只允许重连流程由断线回调或业务超时定时器发起,且每次发起前检查当前状态机的状态,保证只有 Idle 或 Reconnecting 才能启动新一轮。
private func startReconnectIfNeeded() { switch connectionState { case .connected, .connecting, .scanning: return case .idle, .reconnecting: connectionState = .scanning startScan() } }断线回调里、UI 重试按钮里、App 进入前台的通知里,全部先过这个检查再走重连,从根本上避免重复连接。
4.3 后台模式的限制与正确配置
iOS 的蓝牙后台运行是个高频需求点。很多人以为 App 退到后台连接就断,其实不全对。为了在后台保持蓝牙连接与数据接收,需要给 App 开启后台模式。
在 Info.plist 中添加:
<key>UIBackgroundModes</key> <array> <string>bluetooth-central</string> <string>bluetooth-peripheral</string> </array>这个配置加了以后,App 在后台仍然可以扫描、连接和接收 BLE 通知。但它不是无限制的:系统可能在合适的时候挂起你的 App,此时 BLE 外设发的通知会被系统缓存,等 App 恢复运行时统一推送过来。所以如果硬件数据链路对实时性要求高,光靠 iOS 的后台模式还不够,通常还要配合后台执行任务(beginBackgroundTask)或者推送唤醒。
另外要留意“后台模式 + 状态机”组合下的一个诡异现象:如果你的扫描是挂在 App 的前后台切换时启动的,在某些系统版本上会扫描到设备但连接回调异常,看起来像是系统 bug,实际上是因为后台扫描参数和前台不同。这时候可以用代码统一前台后台的扫描参数,或用同一次扫描逻辑来处理。
5. 实测中重复率最高的几个坑和完整排查链路
最后这部分是重头戏。我把这段时间碰到的、以及四周同事们在蓝牙模块里栽过的跟头集中整理,每个问题都给出完整排查思路,而不是直接给答案。
5.1 扫描不到设备:先从系统层面割除干扰
现象是 App 打开后扫描列表一直为空,硬件设备明明在附近且已经进入广播状态。
排查顺序:
- 确认手机系统蓝牙已打开,并且其他蓝牙设备(耳机、手环)能正常搜索到,排除系统级故障。
- 打开系统设置,确认 App 的蓝牙权限是“允许”。在 iOS 设置里权限有时会被误关。
- 检查硬件是否在广播。硬件设备需要处于可被发现状态,一般短按功能键或进入配对模式才会持续广播。
- 用其他工具(比如系统自带的调试工具或另一台手机)确认设备广播名称是否正确。
- 用 nil 扫描全量设备,看能否出现在列表里。如果全量扫描能看到、按服务 UUID 过滤却看不到,问题出在硬件广播包结构上——硬件需要把服务 UUID 加进广播包。
我遇到的情况是第 5 种:硬件同事只把温度传感器加进了扫描响应包,广播包里的服务 UUID 列表为空,导致按 UUID 扫描永远搜不到。最后在全量扫描模式里认出来了。
5.2 代理回调静默:回调没触发时别乱怀疑蓝牙框架
常见场景:连接请求发出后 didConnect 和 didFailToConnect 都没回调,或者 didDiscoverServices 之后再也不触发。
排查思路分两步:
第一步,确认你在正确的时间调用 discoverServices。有人会在连接回调还没触发前就调 discoverServices,方法调用无效但没有任何报错。在 didConnect 里调用最稳,同时要先把 peripheral.delegate 设好。
第二步,确认你的 UUID 字符串格式没有写错。CoreBluetooth 的 UUID 是 16 位十六进制,格式类似180D或者FFE0。你如果从硬件文档里复制了一个带横杠的完整 128 位 UUID,大概率也能用,但如果你把大小写搞混、横杠位置错乱、或者把字节序写反,系统不会报错,只是默默不回调。
还有一个很低级但我犯过的错:UUID 传了 nil 能不能发现所有服务?可以。但某些硬件对服务发现请求比较敏感,如果发现过程还没完成,后续的特征发现会没有结果。所以我现在的做法是先发现服务,在服务的回调里等待,而不是一把梭把 discoverCharacteristics 写在 didConnect 里。
5.3 收发数据乱序、粘包:应用层协议不背锅
BLE 数据链路是按 MTU 分包传输的,接收端看到的是底层重组后的完整包。在个别硬件实现不佳的情况下,App 端拿到的数据可能不是完整的一帧,也可能是多帧黏在一起。
这个问题一般不能指望框架帮你处理,因为 CoreBluetooth 的 notifying 数据是按硬件特征上报的数据块给到 App 的,硬件端怎么组帧、有没有分包,直接影响 App 收到的数据形态。所以应用层必须自己做一帧协议的解析:
协议帧设计参考:
| 字段 | 长度 | 说明 |
|---|---|---|
| 帧头 | 2字节 | 固定魔数,比如 0xAA55 |
| 命令字 | 1字节 | 用于区分这是状态上报还是控制回执 |
| 长度 | 2字节 | 数据段长度 |
| 数据段 | N字节 | 实际数据 |
| 校验 | 1字节 | 简单 CRC 或和校验 |
接收端处理流程就是:攒缓冲 → 扫描帧头 → 读长度 → 截取完整帧 → 校验 → 交给业务层。我在代码里维护一个 Data Buffer,每收到一段数据就 append,然后在一个 while 循环里尝试解析出尽可能多的完整帧,解析不到就等下一包。
这个模块的代码我放在一个单独的工具类里,单元测试能覆盖拆包的边界情况,是很值得投资的部分——因为一旦协议解析有 bug,后面所有功能都跟着错。
5.4 与 Android 端联调时的兼容性差异
同一个智能硬件设备,往往要同时做 iOS 和 Android 两个端。两边联调最常遇到的现象:Android 端能正常收数据,iOS 端经常丢包,或者反过来。
常见的根因有三个:
第一,MTU 协商结果不同。Android 端偏好在连接后去请求一个较大的 MTU,iOS 端没有主动请求远程设备 MTU,导致 iOS 用的还是默认 23 字节,数据包一封就碎成很多片。
第二,Notify 的使能时机不同。Android 的某些蓝牙栈在 setNotifyValue 后立即就能收到数据,而 iOS 偶发需要等一会儿。如果硬件发的太快,iOS 端会丢前面的几个包。我的解决方式是在发现服务并打开 Notify 后,等 100-200 毫秒再开始发送指令,给系统留出订阅生效的时间。
第三,连接参数的差异。部分硬件在 Android 端可以设置更短的连接间隔,从而获得更低延迟,但 iOS 端不接受外设主动修改连接参数更多细节,导致响应延迟变高。这个属于系统行为差异,得在硬件侧评估是否要为 iOS 单独优化广播和连接参数。
5.5 崩溃、卡顿与内存问题:holo 的调试与保护
BLE 项目常见的崩溃点有两个:一是在代理回调里抓取 peripheral 之后没有持有它,野指针崩溃;二是数据解析时强行转换边界越界。
第一点的保护方式是始终对 peripheral 做局部强引用。我在初始化时用一个字典把扫描到的外设缓存起来,key 是 peripheral.identifier.uuidString,value 是 peripheral,这样即使系统在后续回调里给的是同一个对象,也不存在中间被释放的问题。
private var discoveredPeripherals: [String: CBPeripheral] = [:] func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String : Any], rssi RSSI: NSNumber) { discoveredPeripherals[peripheral.identifier.uuidString] = peripheral }第二点的保护方式是所有数据解析必须带上越界检查,不要因为“我们协议定死了”就盲目下标截取。一旦硬件固件升级或某个异常帧出现,越界访问会直接闪退。
调试方面我的做法是建一个统一的日志工具,给蓝牙模块单独打标签,日志粒度分为 verbose(每一帧原始数据)、info(状态变化)、error(异常回调)。到了联调阶段,把 verbose 日志发给硬件同事,双方对着同一帧数据对问题,效率比口头描述高得多。
6. 几个值得一开始就建立的开发习惯
正文收尾前,把这段时间沉淀下来最想推荐的习惯写在这里,不写成枯燥的原则,就说下我实际是怎么操作的。
6.1 日志里把 UUID 和操作名打在一起
蓝牙调试里最烦的就是几百行“didDiscoverServices”这种不知来自哪一次的日志。我的日志格式固定为:
[蓝牙][UUID:F8E0...][服务发现] 发现3个服务 [蓝牙][UUID:F8E0...][特征发现] 服务180D下发现2个特征UUID 长,但调试时一眼就能追踪到对应外设。连接多设备时非常有用。
6.2 把 CBCentralManager 的 queue 统一到主队列
CBCentralManager 初始化可以传入后台队列,回调就会在后台线程执行。但 App 里大量 UI 逻辑需要对回调结果做反应,线程切换稍不留神就会导致 UI 更新错乱。
我的建议是开发前期直接传 .main,回调全在主线程执行,UI操作直接写,逻辑简单可控。等模块稳定后如果真的有性能瓶颈,再考虑把数据处理放到子线程,UI更新切回主线程。前期一律主线程,减少一类线程问题的排查负担。
6.3 给蓝牙模块写一个 UI 可视化的状态展示
调试时看到的日志是一维的,状态流转放在脑子里容易错。我在Debug页面放了一个简单的状态展示控件:当前状态机状态、连接的外设名称、信号强度 RSSI、收发包计数、最后更新时间。有了这个面板,很多问题扫一眼就知道是卡在扫描还是卡在连接,不用再每次看控制台刷屏。
这些习惯不是项目成功的必要条件,但一定会帮你少熬几个调试的夜。蓝牙开发的难点从来不在 iOS 框架本身,而在如何把硬件协议的不稳定性和系统的回调机制一并管理起来。把状态机搭好、把日志铺到位、把协议拆帧测透,你的蓝牙模块就不会是玄学。