简介:这份资源面向具备一定iOS基础、希望系统掌握蓝牙低功耗开发的移动端开发者,围绕苹果Core Bluetooth框架,讲解CBCentralManager、CBPeripheral、CBService、CBCharacteristic及GATT协议等核心概念,帮助读者理清中央设备扫描、连接、服务发现与特征读写订阅的完整流程。压缩包共39个文件,约60KB,以9个.m实现文件与6个.h头文件为主体,配合12张png示意图、storyboard界面文件、plist配置及工程文件,构成一个可直接运行的蓝牙示例工程,便于对照代码理解各代理回调的触发时机。目前已有137人学习下载。通过该示例,读者可掌握设备扫描、连接管理、数据交换与通知订阅等关键实现,并了解iOS 13及以上蓝牙权限申请与异常处理思路,为在物联网、健康追踪、智能家居等场景中集成BLE功能提供可复用的参考模板。
1. 从零手搓 iOS 蓝牙开发:为什么你的第一行代码就卡在了权限上
很多 iOS 开发者第一次接触 CoreBluetooth 时,都会经历一个相似的场景:代码照着文档敲完了,真机跑起来,centralManagerDidUpdateState回调里打印出的状态却是.unauthorized,或者扫描了半天一个外设都搜不到。这不是代码写错了,而是 iOS 蓝牙开发从第一步就和权限、后台模式、模拟器限制绑在一起。这个标题要讲的就是怎么把 iOS 蓝牙开发从「能跑起来」推到「稳定收发数据」——覆盖中心设备(Central)和外围设备(Peripheral)两条路径、服务与特征的 UUID 设计、连接参数调优,以及那些文档里不会写但真机上一定会遇到的坑。适合已经会 Swift 基础、准备把蓝牙功能接进 App 的开发者,也适合之前用过第三方封装库、现在想搞清楚底层到底发生了什么的人。下面按「先立住概念、再动手复现、最后排错」的顺序展开,每一步都能直接抄进工程里跑。
2. CoreBluetooth 的角色模型与最小可跑工程
2.1 Central 和 Peripheral 到底谁主动
CoreBluetooth 把蓝牙低功耗(BLE)通信抽象成两个角色:Central 负责扫描和发起连接,Peripheral 负责广播和被连接。一个 App 可以同时扮演两个角色,但绝大多数场景只需要其中一个。心率监测 App 是 Central,它扫描心率带;智能灯泡 App 可能既是 Central(配网时扫描)又是 Peripheral(配网后接受手机控制)。
关键对象只有四个:CBCentralManager、CBPeripheral、CBService、CBCharacteristic。数据不是直接读写外设,而是读写外设上某个 Service 下的某个 Characteristic。Service 是一组功能的集合,Characteristic 是具体的数据点。每个 Service 和 Characteristic 都有 UUID,标准 UUID 是 16 位短码(比如心率服务 0x180D),自定义 UUID 是 128 位长码。
选型上有一个容易翻车的点:如果你要连的是自己团队做的硬件,UUID 一定要提前和嵌入式同事对齐,并且写进双方共用的常量文件。我见过太多项目因为 iOS 端写死了一个 UUID、固件端改了但没同步,导致扫描到了却连不上,排查半天以为是系统问题。
2.2 最小 Central 工程:扫描、连接、读数据
下面这段代码是一个能跑通的最小 Central 实现。把它放进一个 UIViewController 里,真机运行就能扫描周围广播了自定义服务的设备。
import CoreBluetooth // 自定义服务的 UUID,必须和固件端一致 let targetServiceUUID = CBUUID(string: "FFF0") let targetCharacteristicUUID = CBUUID(string: "FFF1") class BLECentralViewController: UIViewController { var centralManager: CBCentralManager! var discoveredPeripheral: CBPeripheral? var dataCharacteristic: CBCharacteristic? override func viewDidLoad() { super.viewDidLoad() // 注意:options 传 nil 表示不弹系统蓝牙权限弹窗的定制文案 // 如果要指定后台恢复标识,传 CBCentralManagerOptionRestoreIdentifierKey centralManager = CBCentralManager(delegate: self, queue: nil) } } extension BLECentralViewController: CBCentralManagerDelegate { // 蓝牙状态变化回调,必须实现 func centralManagerDidUpdateState(_ central: CBCentralManager) { switch central.state { case .poweredOn: // 只扫描指定 Service,省电且减少无关回调 central.scanForPeripherals(withServices: [targetServiceUUID], options: [CBCentralManagerScanOptionAllowDuplicatesKey: false]) case .unauthorized: print("蓝牙权限被拒绝,去设置里打开") case .poweredOff: print("蓝牙没开") default: break } } // 发现外设回调 func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String: Any], rssi RSSI: NSNumber) { // 拿到第一个就停,实际项目里应该按 RSSI 或名称过滤 discoveredPeripheral = peripheral central.stopScan() central.connect(peripheral, options: nil) } // 连接成功回调 func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) { peripheral.delegate = self // 发现目标服务 peripheral.discoverServices([targetServiceUUID]) } // 连接失败回调,一定要处理,否则会静默失败 func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) { print("连接失败: \(String(describing: error))") // 清理状态,准备重连 discoveredPeripheral = nil } } extension BLECentralViewController: CBPeripheralDelegate { // 发现服务回调 func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) { guard let services = peripheral.services else { return } for service in services where service.uuid == targetServiceUUID { // 发现该服务下的目标特征 peripheral.discoverCharacteristics([targetCharacteristicUUID], for: service) } } // 发现特征回调 func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) { guard let characteristics = service.characteristics else { return } for characteristic in characteristics where characteristic.uuid == targetCharacteristicUUID { dataCharacteristic = characteristic // 订阅通知,这样外设数据变化时会主动推过来 peripheral.setNotifyValue(true, for: characteristic) // 也可以主动读一次 peripheral.readValue(for: characteristic) } } // 数据更新回调(读或通知都会走这里) func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) { guard let data = characteristic.value else { return } // 按你的协议解析,比如前两字节是命令字,后面是负载 print("收到数据: \(data as NSData)") } }逻辑说明:CBCentralManager初始化后不会立刻可用,必须等centralManagerDidUpdateState回调到.poweredOn才能扫描。扫描时传withServices只扫指定服务,这是省电和减少干扰的关键。连接成功后必须设置peripheral.delegate,否则后续服务发现回调不会触发。setNotifyValue(true)是订阅通知,适合外设主动上报数据的场景;如果只是偶尔读一次,用readValue就够了。
参数说明:CBCentralManagerScanOptionAllowDuplicatesKey设为false时,同一个外设只回调一次,适合列表展示;设为true会持续回调,适合做 RSSI 测距。CBCentralManagerOptionRestoreIdentifierKey用于后台恢复,后面章节会展开。
2.3 最小 Peripheral 工程:广播与响应
如果你的 App 要模拟外设,比如把手机变成信标或者接收端,就需要 Peripheral 路径。下面是一个最小实现。
import CoreBluetooth let peripheralServiceUUID = CBUUID(string: "FFF0") let peripheralCharacteristicUUID = CBUUID(string: "FFF1") class BLEPeripheralViewController: UIViewController { var peripheralManager: CBPeripheralManager! var transferCharacteristic: CBMutableCharacteristic? override func viewDidLoad() { super.viewDidLoad() peripheralManager = CBPeripheralManager(delegate: self, queue: nil) } } extension BLEPeripheralViewController: CBPeripheralManagerDelegate { func peripheralManagerDidUpdateState(_ peripheral: CBPeripheralManager) { guard peripheral.state == .poweredOn else { return } // 特征属性:读 + 写 + 通知 let characteristic = CBMutableCharacteristic( type: peripheralCharacteristicUUID, properties: [.read, .write, .notify], value: nil, permissions: [.readable, .writeable] ) transferCharacteristic = characteristic let service = CBMutableService(type: peripheralServiceUUID, primary: true) service.characteristics = [characteristic] // 添加服务后才会真正注册 peripheralManager.add(service) } // 服务添加完成回调,在这里开始广播 func peripheralManager(_ peripheral: CBPeripheralManager, didAdd service: CBService, error: Error?) { if let error = error { print("添加服务失败: \(error)") return } peripheralManager.startAdvertising([ CBAdvertisementDataServiceUUIDsKey: [peripheralServiceUUID], CBAdvertisementDataLocalNameKey: "MyBLEDevice" ]) } // 收到 Central 的读请求 func peripheralManager(_ peripheral: CBPeripheralManager, didReceiveRead request: CBATTRequest) { // 返回当前特征值 request.value = "Hello".data(using: .utf8) peripheralManager.respond(to: request, withResult: .success) } // 收到 Central 的写请求 func peripheralManager(_ peripheral: CBPeripheralManager, didReceiveWrite requests: [CBATTRequest]) { for request in requests { if let data = request.value { print("收到写入: \(data as NSData)") } } peripheralManager.respond(to: requests[0], withResult: .success) } }逻辑说明:Peripheral 端必须先add(service),等didAdd回调成功后才能startAdvertising,顺序反了广播里不会带服务 UUID。CBMutableCharacteristic的properties和permissions要匹配,比如声明了.write就必须给.writeable权限,否则 Central 写入会收到错误。didReceiveRead和didReceiveWrite必须调用respond,否则 Central 会一直等到超时。
参数说明:CBAdvertisementDataLocalNameKey在后台广播时会被系统截断甚至丢弃,不要依赖它做设备识别。广播数据总长度有限,Service UUID 越多,能放的其他数据越少。
3. 连接参数、后台模式与数据收发的稳定性调优
3.1 连接间隔与超时:为什么你的数据会延迟
BLE 连接建立后,Central 和 Peripheral 之间按「连接间隔」周期性通信。这个间隔由 Peripheral 在广播里提出偏好,Central 最终决定。iOS 对连接间隔有明确限制:前台可以短到 15ms,后台通常被拉长到 100ms 以上。这意味着如果你的 App 退到后台,实时性会明显下降。
在代码层面,iOS 不直接暴露设置连接间隔的 API,但你可以通过CBConnectPeripheralOptionNotifyOnDisconnectionKey等选项影响行为。真正要调的是外设固件端的connInterval。常见做法是:固件在广播和连接建立初期用短间隔(比如 30ms)保证快速交互,稳定后切到长间隔(比如 200ms)省电。如果 iOS 端发现数据延迟忽大忽小,先查固件有没有做这个切换。
另一个参数是peripheralMaximumLatency,它允许 Peripheral 跳过若干个连接事件不应答。设大了省电但延迟高,设 0 最灵敏。心率、遥控这类场景建议 0,传感器周期上报可以设 4 到 10。
3.2 后台模式配置:info.plist 里那两个 key
iOS 要支持蓝牙后台,必须在Info.plist里声明UIBackgroundModes,加入bluetooth-central或bluetooth-peripheral。只加一个就够,取决于你的角色。
<key>UIBackgroundModes</key> <array> <string>bluetooth-central</string> </array>加了之后,App 退到后台仍然能接收连接和通知,但扫描行为会被限制:后台扫描必须指定withServices,不能扫全部;而且系统会降低扫描占空比,发现设备的速度变慢。如果还需要 App 被系统杀死后自动唤醒,就要用状态恢复。
// App 启动时用同一个 restore identifier 初始化 centralManager = CBCentralManager( delegate: self, queue: nil, options: [CBCentralManagerOptionRestoreIdentifierKey: "com.example.mycentral"] ) // 实现恢复回调 func centralManager(_ central: CBCentralManager, willRestoreState dict: [String: Any]) { // 从 dict 里取回之前连接的外设,重新设置 delegate if let peripherals = dict[CBCentralManagerRestoredStatePeripheralsKey] as? [CBPeripheral] { for peripheral in peripherals { peripheral.delegate = self // 重新发现服务或直接使用 } } }逻辑说明:状态恢复不是万能的,它只在系统因为内存压力终止 App 后、且有蓝牙事件时触发。恢复后willRestoreState会先于centralManagerDidUpdateState调用,你需要在里面把之前的外设和特征重新挂上 delegate。参数说明:restore identifier 必须是全局唯一的字符串,同一个 App 多次初始化要用同一个值,否则恢复会失败。
3.3 大数据量传输:分包与流控
BLE 单次传输的有效负载很小。默认 ATT MTU 是 23 字节,减去 3 字节头,实际能带 20 字节。iOS 10 以后支持 MTU 协商,最大可以到 185 字节左右,但需要外设配合。写数据时用writeValue(_:for:type:),.withResponse会等外设确认,.withoutResponse不等确认但可能丢包。
传文件或固件升级时,常见做法是自己做分包和流控:把数据切成 MTU 大小的块,每发一包等一个确认,或者用滑动窗口。下面是一个简单的分包发送示例。
func sendData(_ data: Data, to peripheral: CBPeripheral, characteristic: CBCharacteristic) { // 根据协商后的 MTU 计算每包大小,保守取 180 let mtu = peripheral.maximumWriteValueLength(for: .withResponse) let chunkSize = mtu var offset = 0 while offset < data.count { let end = min(offset + chunkSize, data.count) let chunk = data.subdata(in: offset..<end) // withResponse 保证顺序和可靠性,但速度慢 peripheral.writeValue(chunk, for: characteristic, type: .withResponse) offset = end } }逻辑说明:maximumWriteValueLength返回当前连接下单次写入的最大字节数,它取决于协商后的 MTU。用.withResponse时,系统会串行发送并在didWriteValueFor回调里通知你,适合可靠性优先的场景。如果要提速,可以改用.withoutResponse并自己控制发送节奏,但必须在外设端做丢包检测。
参数说明:MTU 协商由 Central 发起,调用peripheral.maximumWriteValueLength之前最好先触发一次读写,确保 MTU 已经协商完成。如果外设不支持 MTU 协商,这个值会停留在 20 左右。
4. 避坑与排查:真机上一定会遇到的五个问题
4.1 模拟器扫不到任何设备
现象:在 Xcode 模拟器里运行,centralManagerDidUpdateState返回.poweredOn,但扫描回调一次都不触发。
原因:iOS 模拟器不支持蓝牙硬件,CoreBluetooth 在模拟器上只能返回.unsupported或空结果。这是系统限制,不是代码问题。
解决:所有蓝牙功能必须用真机调试。如果团队只有模拟器,可以用 Xcode 的「Devices and Simulators」连真机,或者用网络调试工具模拟外设数据,但最终验证一定要上真机。
4.2 扫描到了却连不上,或者连上立刻断开
现象:didDiscover能收到外设,调用connect后要么didFailToConnect,要么didConnect后几秒内didDisconnectPeripheral。
原因:常见有三种。一是外设已经被其他 Central 连接且不支持多连接;二是 UUID 不匹配,外设广播的服务和代码里写的不是同一个;三是连接参数不兼容,比如外设要求的连接间隔超出了 iOS 允许的范围。
解决:先用 LightBlue 这类通用工具确认外设是否可被连接、广播里带了哪些服务。然后检查代码里的 UUID 是否和固件一致。如果外设支持多连接,确认固件端的连接数上限。最后看didDisconnectPeripheral的 error 参数,里面通常有线索。
4.3 后台收不到通知
现象:App 在前台一切正常,退到后台后didUpdateValueFor不再触发。
原因:没有在Info.plist里声明bluetooth-central后台模式,或者声明了但外设没有正确配置通知。另一个常见原因是 App 被系统挂起后,连接虽然还在,但回调被延迟。
解决:确认UIBackgroundModes包含bluetooth-central。确认setNotifyValue(true)在连接后已经调用。如果需要在 App 被杀死后恢复,加上状态恢复逻辑。测试时用 Xcode 的「Debug > Simulate Background Fetch」或者直接锁屏观察。
4.4 写入数据外设收不到
现象:writeValue调用后没有报错,但外设端没有收到数据。
原因:特征属性不匹配。如果特征只声明了.read没有.write,写入会被静默丢弃。或者写入类型用了.withoutResponse,但外设端没有正确处理无响应写入。
解决:用characteristic.properties检查是否包含.write或.writeWithoutResponse。如果是自己开发固件,确认特征的 permissions 包含.writeable。调试阶段统一用.withResponse,确认链路通了再考虑优化。
4.5 状态恢复后 delegate 丢失
现象:App 被系统回收后重新唤醒,willRestoreState被调用,但后续收不到任何数据。
原因:恢复出来的CBPeripheral对象需要重新设置delegate,并且之前发现的服务和特征需要重新关联。系统只恢复连接,不恢复你的业务状态。
解决:在willRestoreState里遍历恢复的外设,重新设置peripheral.delegate = self,然后调用discoverServices重新发现。如果之前已经订阅了通知,恢复后需要重新setNotifyValue(true)。业务层的状态(比如正在传输的文件偏移量)需要自己持久化,恢复后从断点继续。
5. 用 RSSI 做距离分级与连接稳定性验证
RSSI(接收信号强度)是蓝牙开发里最容易被滥用的数据。很多人想用它算精确距离,但实际环境中 2.4GHz 干扰、人体遮挡、设备天线方向都会让 RSSI 剧烈波动。我的经验是:不要算距离,做分级。
具体做法是采集一段时间的 RSSI,做滑动平均,然后分三档:大于 -60dBm 算近,-60 到 -80 算中,小于 -80 算远。这个分级足够支撑「靠近自动连接」「离开自动断开」这类场景。下面是一个滑动窗口的实现。
class RSSIFilter { private var samples: [Int] = [] private let windowSize = 10 func add(_ rssi: Int) -> Double { samples.append(rssi) if samples.count > windowSize { samples.removeFirst() } // 去掉一个最大值和一个最小值,减少突发干扰 let sorted = samples.sorted() let trimmed = sorted.dropFirst().dropLast() guard !trimmed.isEmpty else { return Double(rssi) } return Double(trimmed.reduce(0, +)) / Double(trimmed.count) } func level(_ rssi: Int) -> String { let avg = add(rssi) if avg > -60 { return "近" } if avg > -80 { return "中" } return "远" } }逻辑说明:滑动窗口取最近 10 次采样,去掉最高和最低后求平均,能过滤掉大部分瞬时跳变。level方法返回分级结果,业务层根据分级决定行为。参数说明:窗口大小 10 是经验值,采样频率高可以加大,但响应会变慢。阈值 -60 和 -80 需要根据实际硬件调整,不同手机天线增益不同,同一距离下 RSSI 可能差 10dBm 以上。
验证连接稳定性时,我一般会做一个持续 30 分钟的压力测试:每 100ms 发一包数据,记录丢包率和断连次数。如果丢包率超过 1% 或者 30 分钟内断连超过 2 次,就要回头查连接参数和射频环境。这个测试比任何理论分析都管用,因为蓝牙的玄学问题最终都要靠实测数据说话。
最后说一个我踩过的坑:不要在主线程做蓝牙数据的解析和 UI 更新。CoreBluetooth 的回调默认在创建 manager 时指定的队列上,如果传nil就是主队列。数据量大时会把 UI 卡住。我一般会创建一个专门的串行队列传给CBCentralManager,回调里只做数据搬运,解析和 UI 更新再切回主队列。这个习惯帮我省了很多后悔药。希望帮到你。
本文还有配套的精品资源,点击获取