拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

iOS蓝牙开发实战:从Demo到可复用服务层,避开回调时序与权限坑

iOS蓝牙开发实战:从Demo到可复用服务层,避开回调时序与权限坑 简介这是一份面向iOS开发者、尤其是初涉蓝牙低功耗BLE编程人群的实战示例资源围绕苹果Core Bluetooth框架展开帮助读者理解CBCentralManager、CBPeripheral、CBService、CBCharacteristic及GATT协议等核心概念解决从扫描、连接设备到读写特征、订阅通知的完整流程问题。压缩包共39个文件约60KB以9个.m实现文件与6个.h头文件为主体辅以12张png截图、storyboard界面文件、plist配置及工程文件构成一个可直接运行的蓝牙Demo工程。目前已有137人学习下载。通过该示例读者可对照真实代码掌握中央管理者初始化、设备发现回调、服务与特征获取、数据交换及连接状态处理等关键环节并了解iOS 13及以上版本的蓝牙权限申请与审核注意事项适合作为入门练手与项目参考的轻量素材。1. 从一份能跑通的 iOS 蓝牙 Demo 说起它到底解决了什么如果你做过 iOS 端和 BLE 设备打交道的活大概率经历过这种场景设备就在手边CBCentralManager也初始化了但扫描回调死活不触发或者连上了外设服务、特征一层层往下扒最后卡在写数据没反应。这类问题不是靠读文档能解决的得有一份能跑通的完整工程对着看。这份「清晰、简单、详细的搞定iOS蓝牙开发.zip」就是干这个的——解压出来是一个叫BluetoothDemo-master的 Xcode 工程带BluetoothDemo.xcodeproj、BluetoothDemo主目录、BluetoothDemoTests和BluetoothDemoUITests测试目录还引了SVProgressHUD做加载提示。它把 Core Bluetooth 从扫描、连接、发现服务、订阅特征到收发数据这条链路完整串了一遍适合两类人一是刚接触 BLE 的 iOS 开发想找个能直接跑的参照物二是接过蓝牙项目但被回调时序和权限坑过的老手想回头把状态机理清楚。下面我按「工程怎么拆、代码怎么写、坑在哪」的顺序把这份资源拆开讲。2. 拆开 BluetoothDemo-master工程结构与 Core Bluetooth 角色分工2.1 目录里每个文件夹对应哪块职责先把压缩包解开根目录下能看到这些东西BluetoothDemo.xcodeproj是工程文件BluetoothDemo是主 target 的源码目录BluetoothDemoTests和BluetoothDemoUITests分别是单元测试和 UI 测试SVProgressHUD是第三方加载指示器README.md是说明.gitignore和.DS_Store属于工程杂项。真正要读的是BluetoothDemo目录蓝牙逻辑基本都集中在这里。路径作用阅读优先级BluetoothDemo.xcodeprojXcode 工程配置含 target、Build Settings中改 Bundle ID 和权限时看BluetoothDemo/主源码蓝牙核心逻辑所在高重点读BluetoothDemoTests/单元测试验证数据解析等纯逻辑低按需看BluetoothDemoUITests/UI 测试跑界面流程低SVProgressHUD/扫描/连接时的加载提示中理解交互反馈README.md项目说明高先扫一遍常见做法是先把BluetoothDemo目录里的文件按后缀过一遍.swift或.m是逻辑.storyboard或.xib是界面.plist是配置。蓝牙相关的类通常命名里带Bluetooth、Central、Peripheral这类词找到它们就找到了入口。2.2 CBCentralManager 与 CBPeripheral 的职责边界Core Bluetooth 的核心是中心-外设模型。你的 iPhone 扮演 Central中央BLE 设备扮演 Peripheral外设。CBCentralManager管扫描和连接CBPeripheral代表一个具体外设CBService是外设上的服务CBCharacteristic是服务里的数据点。GATT 协议规定了这套层级一个外设可以广播多个服务一个服务包含多个特征读写和订阅都发生在特征这一层。理解这个层级很关键因为回调是逐层触发的扫描到外设 → 连接 → 发现服务 → 发现特征 → 读写/订阅。任何一层没回调后面都走不下去。Demo 里通常会把CBCentralManager和CBPeripheral的 delegate 分开实现或者放在同一个控制器里用 extension 区分读代码时先定位这两个 delegate 的实现位置。2.3 初始化 Central Manager 与权限声明第一步是初始化CBCentralManager。这一步有个容易忽略的点初始化时会触发centralManagerDidUpdateState:回调只有 state 变成.poweredOn才能开始扫描。很多人把扫描写在viewDidLoad里结果 manager 还没就绪就调用扫描自然没反应。import CoreBluetooth class BluetoothManager: NSObject, CBCentralManagerDelegate { // 持有 centralManager避免被释放 private var centralManager: CBCentralManager! // 已发现的外设用字典按 UUID 去重 private var discoveredPeripherals: [UUID: CBPeripheral] [:] override init() { super.init() // 传入 delegatequeue 传 nil 表示在主队列回调 centralManager CBCentralManager(delegate: self, queue: nil) } // 状态变化回调必须实现 func centralManagerDidUpdateState(_ central: CBCentralManager) { switch central.state { case .poweredOn: // 只有 poweredOn 才允许扫描 startScan() case .unauthorized: print(蓝牙权限被拒绝需引导用户去设置开启) case .poweredOff: print(蓝牙未开启) default: break } } private func startScan() { // nil 表示扫描所有外设生产环境建议传服务 UUID 过滤 centralManager.scanForPeripheral(nil, options: [ CBCentralManagerScanOptionAllowDuplicatesKey: false ]) } }这段代码的逻辑是初始化 manager 后不急着扫描等centralManagerDidUpdateState回调确认.poweredOn再动手。scanForPeripheral的第一个参数传nil会扫描所有广播设备功耗和噪音都大实际项目里一般传目标设备的服务 UUID 做过滤。CBCentralManagerScanOptionAllowDuplicatesKey设为false表示同一设备只回调一次设为true会持续回调 RSSI做距离估算时才需要。权限方面iOS 13 起蓝牙权限走的是NSBluetoothAlwaysUsageDescription需要在Info.plist里声明用途描述否则系统不会弹授权框state 会停在.unauthorized。这是硬性要求漏了就是玄学问题。2.4 扫描、连接与发现服务的回调链扫描到设备后centralManager:didDiscoverPeripheral:advertisementData:rssi:会回调。这里拿到CBPeripheral对象后先别急着连通常要按名称或广播数据过滤确认是目标设备再连。连接成功后触发centralManager:didConnectPeripheral:这时才能调用discoverServices。// 发现外设回调 func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String: Any], rssi RSSI: NSNumber) { // 按名称过滤避免连错设备 guard let name peripheral.name, name.contains(YourDevice) else { return } // 去重避免重复连接 if discoveredPeripherals[peripheral.identifier] nil { discoveredPeripherals[peripheral.identifier] peripheral peripheral.delegate self // 停止扫描再连接省电 centralManager.stopScan() centralManager.connect(peripheral, options: nil) } } // 连接成功回调 func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) { // 连接后才允许发现服务 peripheral.discoverServices(nil) // nil 表示发现所有服务 } // 发现服务回调 func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) { if let error error { print(发现服务失败: \(error.localizedDescription)) return } guard let services peripheral.services else { return } for service in services { // 对每个服务继续发现特征 peripheral.discoverCharacteristics(nil, for: service) } }这段链路的关键是时序connect之后必须等didConnectPeripheral回调才能调discoverServicesdiscoverServices回调里再对每个 service 调discoverCharacteristics。任何一步提前调用都会失败或静默无响应。discoverServices(nil)传nil是发现全部服务实际项目里建议传目标服务的CBUUID减少发现耗时。peripheral.delegate self这行不能漏漏了后面的服务、特征回调都不会来这是新手最常见的翻车点之一。2.5 特征读写与订阅通知特征这一层是真正交换数据的地方。读用readValue(for:)写用writeValue(_:for:type:)订阅用setNotifyValue(_:for:)。写操作分.withResponse和.withoutResponse两种前者有回调确认后者快但不可靠选哪种取决于外设固件的约定。// 发现特征回调 func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) { guard let characteristics service.characteristics else { return } for characteristic in characteristics { // 按 UUID 匹配目标特征 if characteristic.uuid CBUUID(string: FFF1) { // 订阅通知数据变化时会回调 didUpdateValueFor peripheral.setNotifyValue(true, for: characteristic) } if characteristic.uuid CBUUID(string: FFF2) { // 写入数据withResponse 会触发 didWriteValueFor 回调 let data Hello BLE.data(using: .utf8)! peripheral.writeValue(data, for: characteristic, type: .withResponse) } } } // 数据更新回调读结果或通知都走这里 func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) { guard let data characteristic.value else { return } // 按业务协议解析 data print(收到数据: \(data as NSData)) }setNotifyValue(true, for:)订阅后外设主动推数据会走didUpdateValueFor读操作的结果也走同一个回调靠characteristic区分。写操作的type参数很关键.withResponse会在写完后回调didWriteValueFor能确认写入成功.withoutResponse不回调适合高频小数据。UUID 字符串FFF1、FFF2是示例实际要换成外设文档里给的值写错了就是连上但读不到数据。3. 把 Demo 跑起来从 Xcode 配置到真机联调3.1 打开工程与签名配置解压后双击BluetoothDemo.xcodeproj打开。第一件事是改 Bundle Identifier 和签名否则真机跑不起来。在 Xcode 里选中 target进 Signing Capabilities勾上 Automatically manage signing选自己的开发者账号Bundle ID 改成唯一的字符串。模拟器不支持蓝牙必须用真机这点没有绕过的办法。3.2 Info.plist 权限声明前面提过iOS 13 起要加NSBluetoothAlwaysUsageDescription。在 Info.plist 里加一行值是给用户看的用途说明比如「需要使用蓝牙连接设备」。如果 target 支持 iOS 12 及以下还要加NSBluetoothPeripheralUsageDescription。漏了这两项授权框不弹state 停在.unauthorized扫描永远不触发。3.3 真机联调与日志观察真机连上后在 Xcode 的 Console 里观察回调顺序。正常流程是centralManagerDidUpdateState→didDiscoverPeripheral→didConnectPeripheral→didDiscoverServices→didDiscoverCharacteristicsFor→didUpdateValueFor。哪一步断了问题就在那一步。常见做法是在每个回调里打日志把peripheral.identifier、service.uuid、characteristic.uuid都打出来对照外设文档核对。3.4 用 nRF Connect 交叉验证调试 BLE 时手机上装个 nRF Connect 之类的通用扫描工具很有用。它能直接看到外设广播了哪些服务、每个服务下有哪些特征、哪些特征支持读/写/通知。Demo 里读不到数据时先用 nRF Connect 确认外设本身是否正常广播、特征 UUID 是否和代码里一致。这一步能快速区分是 App 的问题还是设备的问题省掉大量瞎猜。4. 蓝牙开发避坑五条血泪经验4.1 扫描回调不触发现象centralManagerDidUpdateState已经是.poweredOn但didDiscoverPeripheral一直不来。原因通常是权限没声明或者扫描时传了错误的服务 UUID 过滤把目标设备过滤掉了。解决先确认 Info.plist 权限项齐全再把scanForPeripheral的第一个参数临时改成nil排除过滤问题确认能扫到后再加回 UUID。4.2 连接成功但发现不了服务现象didConnectPeripheral回调了但didDiscoverServices不回调或返回空。原因多半是peripheral.delegate没设或者设了但对象被提前释放。解决确保peripheral.delegate self在连接前就设好并且持有 peripheral 的强引用别让它被 ARC 回收。4.3 写数据无反应现象调了writeValue但外设没反应didWriteValueFor也不回调。原因是type参数和外设固件不匹配固件只接受.withoutResponse却传了.withResponse或者反过来。解决查外设文档确认写类型两种都试一遍观察哪个能触发回调或设备有响应。4.4 通知订阅后收不到数据现象setNotifyValue(true, for:)调了但didUpdateValueFor不来。原因是订阅的特征不支持 notify 属性或者订阅前没先读一次特征确认属性。解决在didDiscoverCharacteristicsFor里检查characteristic.properties.contains(.notify)不支持就别订阅改用轮询读。4.5 后台断连现象App 切到后台一段时间后连接断开回前台要重连。原因是 iOS 后台对 BLE 有严格限制没声明后台模式的话进后台不久就会被系统挂起。解决在 target 的 Signing Capabilities 里加 Background Modes勾上 Uses Bluetooth LE accessories并在Info.plist里配UIBackgroundModes。即便如此后台行为也受限别指望长时间稳定连接。5. 进阶把 Demo 的状态机改成可复用的蓝牙服务层Demo 跑通之后下一步是把它从「一个能跑的示例」变成「能塞进项目里的模块」。我一般会把蓝牙逻辑抽成一个单例服务层对外暴露连接状态和数据回调界面层只订阅状态不直接碰 Core Bluetooth 的 delegate。这样做的价值在于状态集中管理重连、超时、错误处理都有统一入口不会散落在各个 ViewController 里。具体做法是定义一个BluetoothService类内部持有CBCentralManager和当前连接的CBPeripheral用一个枚举表示连接状态idle、scanning、connecting、connected、disconnected状态变化通过闭包或 Combine 的CurrentValueSubject往外发。重连逻辑放在didDisconnectPeripheral回调里带退避重试避免疯狂重连耗电。enum BluetoothState { case idle, scanning, connecting, connected, disconnected } final class BluetoothService: NSObject { static let shared BluetoothService() // 状态用 CurrentValueSubject 对外发布界面层订阅 let stateSubject CurrentValueSubjectBluetoothState, Never(.idle) private var centralManager: CBCentralManager! private var targetPeripheral: CBPeripheral? // 重连退避计数 private var retryCount 0 private override init() { super.init() centralManager CBCentralManager(delegate: self, queue: nil) } func connect(peripheral: CBPeripheral) { targetPeripheral peripheral peripheral.delegate self stateSubject.send(.connecting) centralManager.connect(peripheral, options: nil) } // 断连后按退避策略重连 func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) { stateSubject.send(.disconnected) guard retryCount 3 else { return } retryCount 1 // 退避1s、2s、4s let delay pow(2.0, Double(retryCount - 1)) DispatchQueue.main.asyncAfter(deadline: .now() delay) { [weak self] in self?.centralManager.connect(peripheral, options: nil) } } }这段代码把状态发布和重连退避做进去了。stateSubject用CurrentValueSubject的好处是新订阅者能立刻拿到当前状态不用等下一次变化。重连退避用pow(2.0, ...)实现指数退避避免断连后立刻重连导致的耗电和连接风暴。retryCount在连接成功时要清零否则重连三次后就再也不重试了。验证这套服务层是否可靠我的习惯是拿一个真实 BLE 设备做三组测试正常连接收发、主动断开后自动重连、设备断电再上电后能否恢复。三组都过基本能进项目。从那以后我每次接蓝牙项目都强制先把状态机和重连策略写清楚再动界面不然回调时序能把人逼疯。希望这份 Demo 和上面的拆解能帮你少走点弯路。本文还有配套的精品资源点击获取
返回列表