iOS蓝牙开发实战:CoreBluetooth核心链路与避坑指南
2026/9/12 2:40:37 网站建设 项目流程

简介:一份面向iOS开发者的蓝牙开发学习工程,围绕苹果核心蓝牙框架演示低功耗蓝牙设备通信的完整实现。工程以BluetoothDemo-master命名,覆盖从初始化中央管理器、扫描发现外围设备、建立连接,到获取服务与特征、读写数据及订阅通知等关键环节,并对通用属性协议结构及iOS 13以上蓝牙权限处理做了示例,适合正在学习蓝牙协议或需要快速搭建低功耗蓝牙功能的中级开发者。压缩包共39个文件,包含9个M源码文件、6个头文件、12张界面预览图、三个属性列表与两个故事板文件,以及工程配置文件,整体仅60KB,结构精简便于逐文件分析。已有137人学习浏览这份工程,代码注释清晰、目录规整,参照示例即可掌握iOS蓝牙开发的核心流程与排错思路,是搭建可用低功耗蓝牙应用的实用参考。

1. iOS 蓝牙开发为什么给人「难搞」的印象

iOS 蓝牙开发最劝退的地方不是 API 复杂,而是所有操作都靠异步回调:扫描、连接、发现服务、发现特征、读写各走各的 delegate,代码一多就分不清当前处在哪个阶段。CoreBluetooth 的设计其实很对称,中心设备(Central)负责扫描和连接,外设(Peripheral)负责广播和响应,主线一旦理清,剩下的就是权限、参数和边界条件。

下文把从零到能跑的最小链路写全,依次覆盖 Info.plist 权限配置、CBCentralManager 扫描过滤、特征读写、MTU 分包、断线重连,最后收在一个直接能用的 BleManager 封装上。新手按章节顺序敲完就能接到真实硬件,熟手可以直接跳到第 4、5 章对照状态机维护和粘包处理。

2. 先搭好 iOS 蓝牙开发的权限与扫描地基

2.1 Info.plist 与蓝牙权限:不配好直接闪退

从 iOS 13 开始,CoreBluetooth 的授权策略收紧:任何访问蓝牙 API 的 App 都必须在 Info.plist 里声明NSBluetoothAlwaysUsageDescription,否则调用CBCentralManager初始化时系统直接终止进程,日志里只有一条This app has crashed because it attempted to access privacy-sensitive data without a usage description。开发阶段最容易漏的就是这条,因为工程模板不会自动生成。

iOS 12 及以下系统使用的是NSBluetoothPeripheralUsageDescription,如果最低支持版本低于 iOS 13,两个 key 都要配上。描述文案建议写清楚用途,例如「用于连接心率计并同步运动数据」,App Store 审核会读取这段文案,写「用于连接蓝牙设备」这类空泛表述有被拒的先例。授权状态可以主动查询CBManager.authorization,枚举有notDeterminedrestricteddeniedallowedAlways四种,注意 iOS 13 之前的authorized状态要留兜底分支。

初始化时还有一个容易被忽略的选项:CBCentralManagerOptionRestoreIdentifierKey。传入恢复标识符后,应用在后台被系统挂起时,蓝牙事件可以回调唤醒(需配合 UIBackgroundModes);不需要后台能力的项目可以不用传,传了反而要额外处理willRestoreState的恢复逻辑。

2.2 CBCentralManager 初始化与扫描参数

常见做法是让控制器强持有CBCentralManager,并遵循CBCentralManagerDelegate

import CoreBluetooth final class CentralHandler: NSObject, CBCentralManagerDelegate { private var central: CBCentralManager! override init() { super.init() central = CBCentralManager( delegate: self, queue: nil, options: [CBCentralManagerOptionShowPowerAlertKey: true] ) } func centralManagerDidUpdateState(_ central: CBCentralManager) { guard central.state == .poweredOn else { print("蓝牙状态异常: \(central.state.rawValue)") return } central.scanForPeripherals( withServices: [CBUUID(string: "FFE0")], options: [CBCentralManagerScanOptionAllowDuplicatesKey: false] ) } }

queuenil表示回调在 main queue 执行,UI 更新最省事;扫描数据量大时可以传自定义串行队列,回到 UI 线程再刷新界面。ShowPowerAlert设为 true 时,用户关闭蓝牙会看到系统弹窗,比自己在界面里提示省心。

scanForPeripheralswithServicesnil会扫到周围所有 BLE 设备,回调频繁且耗电;常见做法是只扫描目标服务 UUID,让系统做硬件级过滤。AllowDuplicates默认 false,表示同一设备广播只上报一次,需要做 RSSI 趋势判断或广播内动态数据时才改成 true。

提示:模拟器上central.state会停在 unsupported,CoreBluetooth 调试请直接上真机。

iOS 面试题里问到 iOS 蓝牙开发,大概率会考centralManagerDidUpdateState的状态对应关系,这张表可以直接背:

CBManagerStaterawValue含义与处理
unknown0初始态,等待下一次回调
resetting1系统蓝牙服务重启,必要时重建 central
unsupported2设备不支持 BLE,模拟器常见
unauthorized3权限被拒,引导去系统设置
poweredOff4蓝牙关闭,提示用户
poweredOn5可以开始扫描

2.3 从广播数据里认出你要的设备

扫描回调centralManager(_:didDiscover:advertisementData:rssi:)的三个参数各有用处:peripheral是后续连接的句柄,advertisementData是设备主动广播的键值对,RSSI是信号强度(负值,越接近 0 越近)。识别设备不能只看peripheral.name,很多硬件广播时不带 Local Name:

func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String: Any], rssi RSSI: NSNumber) { let localName = advertisementData[CBAdvertisementDataLocalNameKey] as? String let manufacturerData = advertisementData[CBAdvertisementDataManufacturerDataKey] as? Data let serviceUUIDs = advertisementData[CBAdvertisementDataServiceUUIDsKey] as? [CBUUID] guard let localName, localName.hasPrefix("TT-") else { return } guard RSSI.intValue > -80 else { return } targetPeripheral = peripheral central.stopScan() }

广播字典的 key 是固定的,除了上面三个,还有CBAdvertisementDataIsConnectableKey(是否可连接)和CBAdvertisementDataTxPowerLevelKey(发射功率)。需要按厂商自定义数据过滤时,解析ManufacturerData的前两个字节 Company ID,与蓝牙 SIG 的成员编号对应。过滤逻辑要轻,不要在didDiscover里做字符串切割或转换,提前把目标前缀、UUID 编译成常量,不满足条件直接 return。

3. 连接外设并完成服务、特征的发现与读写

3.1 连接是异步的,状态机要自己维护

connect(_:options:)调用后不会立刻成功,结果通过didConnectdidFailToConnect通知。从发现设备到可读写,中间隔着连接成功、服务发现、特征发现三步,每步都有独立回调,回调里再触发下一步很容易造成时序错乱。所以要维护一个显式状态机:disconnected、connecting、discovering、ready 四态,回调里先判断当前状态再推进。

连接参数里值得关注的是CBConnectPeripheralOptionNotifyOnConnectionKeyCBConnectPeripheralOptionNotifyOnDisconnectionKey,都设为 true 后,App 在后台也能收到连上/断开回调。系统不提供连接超时机制,常见做法是发起连接时同时起一个 5 秒的 DispatchWorkItem,超时后调用cancelPeripheralConnection并复位状态:

enum ConnState { case disconnected, connecting, discovering, ready } private var state: ConnState = .disconnected private var connectTimer: DispatchWorkItem? func connect(_ peripheral: CBPeripheral) { state = .connecting peripheral.delegate = self central.connect(peripheral, options: [ CBConnectPeripheralOptionNotifyOnDisconnectionKey: true ]) let work = DispatchWorkItem { [weak self] in guard let self, self.state == .connecting else { return } self.central.cancelPeripheralConnection(peripheral) self.state = .disconnected } connectTimer = work DispatchQueue.main.asyncAfter(deadline: .now() + 5, execute: work) } func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) { connectTimer?.cancel() state = .discovering peripheral.discoverServices([CBUUID(string: "FFE0")]) } func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) { state = .disconnected }

discoverServices不建议传 nil,服务数量多时会浪费一次握手周期,明确知道服务 UUID 就传数组。每次didConnectperipheral.delegate都要重新赋值,重连时外设对象可能是新实例,委托不会自动保留。

3.2 特征属性表:读、写、通知到底支不支持

服务(CBService)之下是特征(CBCharacteristic),BLE 的数据读写最终都落在特征上。每个特征声明了一组属性,决定能对它做什么。判断能力要用characteristic.properties,不要凭 UUID 猜,同一 UUID 在不同固件版本里的属性可能不一样:

属性能力对应调用
.read主动读取当前值peripheral.readValue(for:)
.write带应答写入writeValue(_:for:type:.withResponse)
.writeWithoutResponse无应答写入writeValue(_:for:type:.withoutResponse)
.notify订阅值变化通知setNotifyValue(true, for:)
.indicate带确认的通知同样走setNotifyValue
.broadcast特征值广播一般不用于数据交互

写数据前先做属性检查,避免向只读特征写入时收到错误:guard characteristic.properties.contains(.write) else { return }。无应答写入适合高频传感器数据,速度快但没有失败反馈;带应答写入适合指令下发,每个包都有链路层确认,吞吐量略低。硬件端常用的搭配是「写一个命令特征、订阅一个数据特征」,命令通道用 .write,数据通道用 .notify。iOS 面试题如果追问 iOS 蓝牙开发,常会问这两种写法的差异,答案核心是:是否等待对端 ACK、吞吐量差别、以及 .withoutResponse 需要 App 自行做分包节流。

3.3 写数据与订阅通知的完整代码

发现特征横跨两个连续回调,先didDiscoverServices,再对每个服务调discoverCharacteristics

func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) { for service in peripheral.services ?? [] { if service.uuid == CBUUID(string: "FFE0") { peripheral.discoverCharacteristics(nil, for: service) } } } func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) { for ch in service.characteristics ?? [] { switch ch.uuid.uuidString { case "FFE1": writeChar = ch peripheral.writeValue(Data([0x01, 0x02, 0x03]), for: ch, type: .withResponse) case "FFE2": notifyChar = ch peripheral.setNotifyValue(true, for: ch) default: break } } }

写入结果在didWriteValueFor里确认,error 为 nil 才算成功;设备主动上报的数据和readValue的结果统一走didUpdateValueFor

func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) { guard error == nil, let value = characteristic.value else { return } var bytes = [UInt8](value) let type = bytes.removeFirst() // 第一字节是帧类型 handlePacket(type: type, payload: Data(bytes)) } func peripheral(_ peripheral: CBPeripheral, didWriteValueFor characteristic: CBCharacteristic, error: Error?) { if let error { print("写入失败: \(error.localizedDescription)") } }

注意didUpdateValueFor同时承载主动读和通知两种来源,区分方式看characteristic.isNotifying。解析前把characteristic.value拷贝成新 Data,不要持有特征对象引用等到下次回调,系统会复用缓冲区。数据频率超过 50 Hz 时,解析逻辑丢到串行队列,避免阻塞主线程。

4. iOS 蓝牙开发最常见的坑与调试手段

4.1 连接频繁断开:后台模式与系统弹窗

连接一会就断,第一嫌疑是 App 进了后台。iOS 默认后台会挂起蓝牙回调,需要到 Signing & Capabilities 里加 UIBackgroundModes 的bluetooth-central(App 作为中心)或bluetooth-peripheral(App 作为外设)。加上之后,配合第 2 章的 RestoreIdentifier,系统才能在后台维持连接并唤醒 App。

第二个嫌疑是系统蓝牙资源被其他 App 抢占。代码层面能做的只有区分断开原因,判断系统主动断开(error 为 nil)还是链路异常(error 非 nil),再决定是否静默重连。硬件休眠唤醒本来就会断开一次,不要收到断开就弹窗。断开错误可以按 error.code 归类:

error.codeSwift 符号常见场景
1.operationCancelled主动 cancel 连接
2.notConnected读写时外设未连接
4.timeout操作超时
5.peripheralDisconnected对端断开
9.connectionFailed连接失败,如超出范围

4.2 数据粘包与 MTU:分包发送怎么算

BLE 单包最大长度是连接后协商出来的,不是固定 20 字节。iOS 真机上常见 185 字节,老硬件可能只有 27 字节。分包不要硬编码长度,用peripheral.maximumWriteValueLength(for:)动态取值:

func send(data: Data, to peripheral: CBPeripheral, char: CBCharacteristic) { let type: CBCharacteristicWriteType = .withResponse let maxLen = peripheral.maximumWriteValueLength(for: type) var offset = 0 while offset < data.count { let length = min(maxLen, data.count - offset) let chunk = data.subdata(in: offset..<(offset + length)) peripheral.writeValue(chunk, for: char, type: type) offset += length } }

.withResponse模式下系统会按序排队,上面的循环可以连续发;如果改成.withoutResponse,必须自己维护发送窗口,比如每包间隔 20ms,或先查peripheral.canSendWriteWithoutResponse,否则低端蓝牙芯片的缓冲区会溢出丢包。接收端的粘包是反方向的问题:设备一次上报的数据可能要跨多个通知包拼成一个完整指令。常见做法是定义帧格式[帧头0xAA][长度][负载][校验],在didUpdateValueFor里累积缓冲,长度够了再截取解析。

注意:.withResponse分包循环里不要手动加延时,系统以链路层 ACK 为准,人为 sleep 只会拖慢整体吞吐。

4.3 断线重连与 RSSI 过滤

重连不能无脑高频循环,硬件的连接参数里通常有 supervision timeout,刚断开的设备短时间内很难立刻重连。后台重连的常见做法是退避:第一次 1 秒,之后 2、4、8 秒封顶,重连成功再重置:

func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) { guard shouldReconnect else { return } retryCount += 1 let delay = min(pow(2.0, Double(retryCount)), 8.0) DispatchQueue.global().asyncAfter(deadline: .now() + delay) { [weak self] in guard let self, self.central.state == .poweredOn else { return } self.central.scanForPeripherals(withServices: [self.serviceUUID]) } }

重连目标用peripheral.identifier(UUID)而不是name,名字可以变,identifier 在系统内是稳定的身份标识。RSSI 过滤要跟业务耦合:手环、门锁这类设备 -80 dBm 以下基本不可用;信标类场景反而要保留远距离信号做范围判断。RSSI 是短时均值、抖动大,别用单次值卡阈值,取 3 次采样的中位数更可靠。

4.4 用日志和自动化脚本提高排查效率

调试 iOS 蓝牙开发,日志要覆盖全生命周期:状态变化、扫描命中、连接成功与失败、服务发现、特征发现、每次读写的数据长度与结果。数据用 hex 字符串打印,不要直接输出 Data 的 description。常见做法是包一层BTLog开关,release 包直接裁剪。

权限弹窗的重复测试交给 UI 自动化:用 XCUITest 的addUIInterruptionMonitor(withDescription:)监控系统弹窗并点击 Allow,可以自动跑完「拒绝授权 → 重新授权」两条路径。真机上先用 LightBlue 这类通用调试工具复现读写流程,能快速定位是硬件问题还是 iOS 端代码问题。这个思路同样适用 iOS 面试题里的场景题:面试官问「连接频繁断开怎么排查」,把日志分层、错误码归类、工具链验证三步讲清楚,比背 API 列表有用得多。

5. 一个直接能用的 BleManager 封装与调试技巧

最后给一个可控的最小封装,把 Central 和 Peripheral 的 delegate 收敛到一个类里,对外只暴露connectsend和回调闭包,业务层不再关心回调嵌套。这个结构也对应组件化开发里的基础层划分,蓝牙模块只提供连接与收发原语,业务状态由上层自己维护:

final class BleManager: NSObject { static let shared = BleManager() private var central: CBCentralManager! private var peripheral: CBPeripheral? private var writeChar: CBCharacteristic? private var namePrefix = "" var onStateChanged: ((String) -> Void)? var onDataReceived: ((Data) -> Void)? override private init() { super.init() central = CBCentralManager(delegate: self, queue: nil) } func connect(namePrefix: String) { self.namePrefix = namePrefix central.scanForPeripherals(withServices: nil) } func send(_ data: Data) { guard let peripheral, let writeChar, peripheral.state == .connected else { print("设备未连接,发送失败") return } peripheral.writeValue(data, for: writeChar, type: .withResponse) } } extension BleManager: CBCentralManagerDelegate, CBPeripheralDelegate { // didDiscover 里按 namePrefix 过滤并 stopScan // didConnect 之后的服务发现与特征发现流程与第 3 章一致 // 在 didDiscoverCharacteristicsFor 里保存 writeChar 和 notifyChar }

封装时有两个细节值得注意。一是每个 delegate 回调都要做[weak self]捕获,并在didConnect里重新设置peripheral.delegate = self,防止重连后回调丢失。二是send内部必须判断peripheral.state == .connected,设备没连接时不要静默失败,打日志并把错误回抛给业务层,界面才能给出正确提示。

调试技巧:写入不生效时,按顺序确认三件事——特征属性里是否包含对应的 write 类型、maximumWriteValueLength是否被超长数据绕过、固件端是否真的收到了空中包。三步做完,九成写入问题都有结论。如果怀疑 iOS 系统缓存了旧特征值,先cancelPeripheralConnection断开,等 2 秒再重连,必要时重启蓝牙开关刷新系统缓存。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询