iOS蓝牙开发实战:CoreBluetooth从扫描到重连的完整指南

发布时间:2026/9/24 19:38:24
iOS蓝牙开发实战:CoreBluetooth从扫描到重连的完整指南 简介这是一份面向iOS开发者、尤其是初涉蓝牙低功耗BLE编程人群的实战示例资源围绕苹果Core Bluetooth框架展开帮助读者理解CBCentralManager、CBPeripheral、CBService、CBCharacteristic及GATT协议等核心概念解决从扫描、连接设备到读写与订阅特征值的完整流程问题。压缩包共39个文件约60KB以12个png界面截图、9个m实现文件与6个h头文件为主体另含storyboard界面、plist配置、xcworkspacedata工程数据及README说明构成一个可直接运行的BluetoothDemo工程。目前已有137人学习下载。通过该示例读者可对照中央管理者初始化、设备发现回调、服务与特征获取、数据交换及代理方法实现等关键代码掌握iOS蓝牙开发的基本骨架并了解iOS 13及以上版本的蓝牙权限申请与审核注意事项适合作为入门练手与项目参考。1. 从一次“搜不到设备”的翻车说起iOS 蓝牙开发到底难在哪做过 iOS 蓝牙的人大概率都经历过这个场景代码照着文档写完CBCentralManager也初始化了结果扫描半天一个外设都搜不到或者连上了却收不到数据。更玄学的是同一份代码在 A 手机上正常换到 B 手机就时好时坏。这不是你代码写得差而是 iOS 蓝牙开发本身就有一套自己的“规矩”——权限、状态机、队列、后台模式任何一环没对齐表现就是“没反应”。这篇笔记要讲清楚的就是怎么清晰、简单、详细地把 iOS 蓝牙开发跑通。核心是苹果的CoreBluetooth框架它把手机抽象成中心设备Central把外设抽象成周边设备Peripheral两者通过服务Service和特征Characteristic交换数据。适合谁看一是刚接触 iOS 蓝牙、想从零跑通扫描-连接-收发全流程的新手二是做过 Android 蓝牙、想把那套“搜到设备塞进 ListView”的思路迁移到 iOS 的开发者三是被权限、后台、断连重连折腾过的老手想回头把边界条件理一遍。下面按“先立住概念、再动手复现、最后避坑”的顺序推下去。2. CoreBluetooth 的角色模型与最小可跑通工程2.1 Central 与 Peripheral先把数据流向画在脑子里CoreBluetooth 里所有交互都围绕两个角色展开。Central是主动方负责扫描、发起连接、读写数据对应CBCentralManagerPeripheral是被动方负责广播自己、响应请求对应CBPeripheralManager。手机既能当 Central 也能当 Peripheral但绝大多数业务场景里手机是 Central去连一个硬件设备手环、血压计、蓝牙锁。数据不是直接读的而是分层组织一个 Peripheral 可以暴露多个 Service一个 Service 下挂多个 Characteristic真正的数据放在 Characteristic 的 value 里。Characteristic 还有属性Properties决定它是可读、可写、可通知Notify还是可指示Indicate。你连上设备后如果发现读不到数据八成是没找对 Characteristic或者它的属性根本不支持读。理解这个模型有个好处它解释了为什么 iOS 蓝牙代码总是“回调套回调”。扫描是异步的连接是异步的发现服务和特征是异步的读写也是异步的。你不能像写同步代码那样一行行往下拿结果只能靠 delegate 回调把状态串起来。这是新手最容易翻车的地方——在didDiscover里直接去读特征值结果对象还没准备好。2.2 权限声明与后台模式Info.plist 里两个必须改的键在写任何代码之前先把工程配置做对否则后面全是无用功。iOS 13 之后蓝牙权限必须在Info.plist里显式声明否则系统直接拒绝连弹窗都不给。!-- Info.plist 中新增以下键值 -- keyNSBluetoothAlwaysUsageDescription/key string需要蓝牙权限来连接附近的设备/string !-- iOS 12 及更早还需要这个兼容老系统时保留 -- keyNSBluetoothPeripheralUsageDescription/key string需要蓝牙权限来连接附近的设备/string这两段的作用是当 App 第一次调用CBCentralManager时系统弹出授权框文案就是 string 里的内容。注意文案不能空也不能写得太含糊审核时会被打回。如果要做后台扫描或后台连接还要在Signing Capabilities里勾上Background Modes的Uses Bluetooth LE accessories它对应Info.plist里的UIBackgroundModes数组加一项bluetooth-central。提示后台模式下扫描有额外限制服务 UUID 必须显式指定不能传 nil 扫全部否则后台拿不到回调。2.3 用 CBCentralManager 跑通扫描到连接的最小闭环下面这段代码是一个能直接跑的最小闭环初始化中心设备、扫描、连接、发现服务、订阅通知。我把它拆成几个 delegate 方法方便你对照状态流转。import CoreBluetooth class BluetoothManager: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate { var centralManager: CBCentralManager! var targetPeripheral: CBPeripheral? // 目标设备的服务 UUID按实际硬件填写 let serviceUUID CBUUID(string: FFF0) // 用于接收通知的特征 UUID let notifyCharUUID CBUUID(string: FFF1) override init() { super.init() // 传入 self 作为 delegatequeue 传 nil 表示在主队列回调 centralManager CBCentralManager(delegate: self, queue: nil) } // 中心设备状态变化只有 poweredOn 才能开始扫描 func centralManagerDidUpdateState(_ central: CBCentralManager) { switch central.state { case .poweredOn: // 指定 serviceUUID 扫描比扫全部更省电也更稳 central.scanForPeripherals(withServices: [serviceUUID], options: nil) case .unauthorized: print(蓝牙权限被拒绝检查 Info.plist 和系统设置) case .poweredOff: print(蓝牙未开启) default: break } } // 发现外设回调可能被多次调用 func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String: Any], rssi RSSI: NSNumber) { // 用名称或 RSSI 过滤避免连错设备 guard let name peripheral.name, name.contains(MyDevice) else { return } targetPeripheral peripheral peripheral.delegate self // 停止扫描再连接边扫边连会拖慢连接速度 central.stopScan() central.connect(peripheral, options: nil) } // 连接成功 func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) { // 连接后立刻发现服务传入 nil 表示发现全部服务 peripheral.discoverServices([serviceUUID]) } // 发现服务 func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) { guard let services peripheral.services else { return } for service in services { // 针对每个服务发现特征 peripheral.discoverCharacteristics([notifyCharUUID], for: service) } } // 发现特征 func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) { guard let chars service.characteristics else { return } for char in chars { if char.uuid notifyCharUUID { // 订阅通知数据变化会走 didUpdateValueFor peripheral.setNotifyValue(true, for: char) } } } // 收到通知数据 func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) { guard let data characteristic.value else { return } // 按硬件协议解析这里假设是 UTF-8 字符串 let text String(data: data, encoding: .utf8) print(收到数据: \(text ?? )) } }逻辑说明CBCentralManager初始化后不会立刻可用必须等centralManagerDidUpdateState回调到.poweredOn才能扫描这是新手最常见的“代码没反应”原因。扫描时传serviceUUID而不是 nil能过滤掉大量无关广播连接成功率更高。连接成功后先discoverServices再在服务回调里discoverCharacteristics最后setNotifyValue订阅这条链路一步都不能跳。参数说明queue传 nil 表示 delegate 回调在主队列UI 更新方便如果传自定义串行队列回调在后台线程更新 UI 要手动切回主线程。scanForPeripherals的options可以传CBCentralManagerScanOptionAllowDuplicatesKey: true来重复上报同一设备但会显著增加耗电一般只在调试 RSSI 时用。connect没有超时参数连接失败会走didFailToConnect需要自己加超时定时器兜底。3. 数据读写、通知订阅与断连重连的工程化处理3.1 读、写、通知三种交互怎么选连上设备后和特征打交道的方式有三种选错了要么拿不到数据要么白耗电。交互方式调用方法适用场景注意点读 ReadreadValue(for:)偶尔取一次配置、电量特征必须支持.read属性写 WritewriteValue(_:for:type:)下发指令、设置参数分.withResponse和.withoutResponse通知 NotifysetNotifyValue(true, for:)持续接收传感器数据特征必须支持.notify或.indicate写操作里.withResponse会走didWriteValueFor回调确认可靠但慢.withoutResponse不回调快但可能丢包。下发关键指令用前者高频小数据用后者。判断特征支持哪种属性看characteristic.properties它是个 OptionSet用.contains(.write)判断。3.2 写数据的 MTU 限制与分包iOS 蓝牙单次写有长度限制默认约 20 字节BLE 4.0 的 ATT MTU 是 23减去 3 字节头。超过这个长度直接写会失败或截断这是很多人“指令发出去设备没反应”的根因。// 把长数据按 20 字节分包发送 func sendData(_ data: Data, to characteristic: CBCharacteristic, peripheral: CBPeripheral) { let maxLen 20 var offset 0 while offset data.count { let chunkSize min(maxLen, data.count - offset) let chunk data.subdata(in: offset..(offset chunkSize)) // 关键指令用 withResponse确保每包都被确认 peripheral.writeValue(chunk, for: characteristic, type: .withResponse) offset chunkSize } }逻辑说明循环把 Data 切成不超过 20 字节的块逐包写入。用.withResponse时系统会串行发送并等待每包的didWriteValueFor不会因为发太快而丢包。参数maxLen在 iOS 9 之后可以通过peripheral.maximumWriteValueLength(for: .withResponse)动态获取实际值可能大于 20但为了兼容老设备固定 20 最稳。注意分包发送时如果设备协议要求“一包完整指令”你需要自己在协议层加长度头或结束符否则设备端无法判断包边界。3.3 断连重连别让用户手动去点第二次蓝牙断连是常态——设备走远、电量低、系统回收资源都会断。didDisconnectPeripheral回调触发后如果不处理用户就得手动重连体验很差。// 断连回调里发起重连 func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) { // 记录断连原因error 为 nil 通常是设备主动断开 print(断开连接: \(error?.localizedDescription ?? 设备主动断开)) // 延迟 1 秒重连避免频繁重试被系统限流 DispatchQueue.main.asyncAfter(deadline: .now() 1.0) { // 用 retrievePeripherals 拿回之前的设备引用 if let uuid peripheral.identifier as UUID?, let known central.retrievePeripherals(withIdentifiers: [uuid]).first { central.connect(known, options: nil) } } }逻辑说明retrievePeripherals(withIdentifiers:)能拿回系统缓存的设备对象比重新扫描快得多也更省电。延迟 1 秒是为了避免断连瞬间立刻重连导致的失败循环。参数上connect的options可以传CBConnectPeripheralOptionNotifyOnDisconnectionKey: true让系统在 App 挂起时也弹断连提示适合需要提醒用户的场景。重连还要考虑一个边界如果设备已经不在范围内重连会一直失败。常见做法是设一个重试上限比如 5 次超过就停止并通知上层避免无限重试耗电。4. iOS 蓝牙开发避坑5 个血泪踩坑记录4.1 扫描不到任何设备现象scanForPeripherals调了didDiscover一次都不回调。原因最常见是centralManagerDidUpdateState还没到.poweredOn就调了扫描或者Info.plist权限文案缺失导致授权被静默拒绝。解决把扫描逻辑严格放在.poweredOn分支里检查Info.plist两个权限键是否都有去系统设置里确认 App 的蓝牙开关是开的。4.2 连接成功但读不到特征值现象didConnect走了discoverServices也回调了但characteristic.value一直是 nil。原因要么特征属性不支持.read要么没等didDiscoverCharacteristicsFor回调就去读了。解决打印characteristic.properties确认支持读读操作必须放在特征发现回调之后如果只支持通知改用setNotifyValue。4.3 后台收不到通知数据现象App 切到后台通知数据就断了。原因后台模式没开或者扫描时serviceUUID传了 nil。解决Capabilities 里勾上Uses Bluetooth LE accessories后台扫描必须指定服务 UUID另外后台回调频率会被系统降低不能指望实时性。4.4 写数据设备没反应现象writeValue没报错但设备端没执行。原因数据超过 MTU 被截断或者用了.withoutResponse但设备要求确认。解决按 20 字节分包关键指令改用.withResponse用didWriteValueFor确认每包是否成功。4.5 同一份代码换手机就异常现象A 手机正常B 手机扫描慢或连不上。原因不同机型蓝牙芯片和系统版本对扫描间隔、连接参数的处理不同iOS 版本差异也会影响权限弹窗时机。解决不要假设扫描是即时的加超时兜底权限申请后给用户明确引导测试至少覆盖一个新机型和一个老机型。5. 用 RSSI 做距离粗判与连接质量监控的进阶技巧跑通基本流程后真正拉开差距的是对连接质量的监控。RSSI信号强度是 CoreBluetooth 免费给你的一个指标扫描回调didDiscover和连接后的peripheral.readRSSI()都能拿到。它的值通常是负的越接近 0 信号越强-50 左右很近-90 以下基本要断了。我一般会做一个简单的滑动窗口把最近 5 次 RSSI 取平均避免单次抖动误判。下面这段代码在连接后定时读取 RSSI 并做平滑处理var rssiWindow: [Int] [] let windowSize 5 // 定时器每 2 秒触发一次 objc func pollRSSI() { targetPeripheral?.readRSSI() } // 读取 RSSI 回调 func peripheral(_ peripheral: CBPeripheral, didReadRSSI RSSI: NSNumber, error: Error?) { guard error nil else { return } rssiWindow.append(RSSI.intValue) if rssiWindow.count windowSize { rssiWindow.removeFirst() } let avg rssiWindow.reduce(0, ) / rssiWindow.count // 低于阈值触发提醒比如设备快走远了 if avg -85 { print(信号弱可能即将断连平均值: \(avg)) } }逻辑说明readRSSI是异步的结果走didReadRSSI回调。滑动窗口取平均能过滤掉人体遮挡、瞬间干扰造成的尖峰。参数windowSize设 5 是经验值太小不抗抖太大反应迟钝。阈值 -85 不是绝对的不同设备发射功率不同最好在真实环境里测一遍再定。除了 RSSI还可以监控连接间隔和丢包率。丢包率可以通过在协议层给每包加序号统计didUpdateValueFor里序号是否连续来估算。如果丢包率超过 5%说明环境干扰大或距离远可以考虑降低通知频率或提示用户靠近。一个我踩过的坑不要用 RSSI 做精确测距。2.4GHz 频段受人体、墙壁、微波炉影响极大同样距离下 RSSI 可能差 20dB。它只适合做“近/远/即将断开”的粗判别拿它算厘米级距离那是玄学。最后说个习惯每次调蓝牙我都会先开一个日志面板把central.state、每次didDiscover的 RSSI、didConnect、didDisconnect的 error 全打出来。蓝牙问题十有八九是状态时序问题日志比断点好用得多。希望帮到你。本文还有配套的精品资源点击获取