面向:刚接触蓝牙、但有 iOS/Swift 基础的开发者。
场景假设:我们要做一个 iOS App,连接一款双模蓝牙耳机。耳机同时支持:
-
经典蓝牙 Classic / BR/EDR:用于音频播放、通话、媒体控制等系统级能力。
-
BLE / Bluetooth Low Energy:用于 App 内设备发现、连接、读取电量、读取固件版本、控制降噪模式、触发 OTA、同步自定义状态等。
这篇文章重点讲 iOS 作为 BLE Central 中心设备 的开发。换句话说:
iPhone 是 Central,耳机是 Peripheral。****
1. 先搞清楚:iOS 蓝牙开发到底能做什么?
很多人一上来会问:
我的 App 能不能直接连接蓝牙耳机,然后控制它播放声音、拿到音频流、走 SPP 串口通信?
答案要分场景。
在 iOS 上,蓝牙能力大致分三类:
| 能力 | App 能否直接控制 | 典型 API / 机制 |
|---|---|---|
| BLE 外设通信 | 可以 | CoreBluetooth |
| 经典蓝牙音频,如 A2DP/HFP | 不直接控制底层链路 | 系统音频栈、AVAudioSession |
| 经典蓝牙自定义数据,如 SPP/RFCOMM | 普通 App 通常不开放 | MFi + External Accessory |
Apple 官方对 Core Bluetooth 的定位是:它提供 App 与低功耗蓝牙 BLE 设备通信所需的类,同时文档中也提到 Core Bluetooth 涉及 LE 和 BR/EDR 设备,但面向 App 开发时,最常见、最稳定、最开放的路径仍然是 BLE。CBCentralManager 的职责就是扫描、发现、连接和管理外设。
对于我们假设的双模蓝牙耳机,合理架构通常是:
经典蓝牙 Classic:
- 音乐播放 A2DP
- 通话 HFP
- 播放控制 AVRCP
- 系统蓝牙设置里配对
- iOS 系统音频栈接管
BLE:
- App 扫描耳机
- App 连接耳机
- 读取设备信息
- 读取电池
- 控制 ANC / EQ / 触控设置
- 读取佩戴状态
- OTA 固件升级
- 自定义功能扩展
一句话:
在 iOS 上,耳机的“声音链路”通常归系统管;App 想和耳机做业务通信,通常走 BLE。****
2. 蓝牙基础:Classic 和 BLE 到底有什么区别?
蓝牙不是单一技术。现在常见的是两套体系:
| 类型 | 全称 | 设计目标 |
|---|---|---|
| 经典蓝牙 | Bluetooth Classic / BR/EDR | 持续连接、音频、较高吞吐 |
| BLE | Bluetooth Low Energy | 低功耗、短数据、广播、IoT、App 控制 |
对于耳机来说,双模蓝牙非常常见:
耳机同时支持:
Classic:连接系统音频
BLE:连接厂商 App
举个具体例子:
你打开 iPhone 设置,连接某款耳机。
这一步通常建立的是系统级蓝牙连接,音频走 Classic 或系统支持的音频能力。
然后你打开耳机厂商 App,App 里可以看到:
-
左右耳电量
-
充电盒电量
-
降噪模式
-
通透模式
-
EQ 设置
-
固件版本
-
查找耳机
-
OTA 升级
这些 App 内控制功能,多数是通过 BLE GATT 实现的。
3. iOS 蓝牙开发的核心框架:CoreBluetooth
iOS 做 BLE,核心框架是:
import CoreBluetooth
CoreBluetooth 里最重要的几个类:
| 类 | 作用 |
|---|---|
| CBCentralManager | 中心设备管理器,负责扫描、连接、管理外设 |
| CBPeripheral | 外设对象,比如一只耳机 |
| CBService | 服务,比如电池服务、设备信息服务、自定义控制服务 |
| CBCharacteristic | 特征值,比如电量、固件版本、降噪模式 |
| CBDescriptor | 特征值描述符,常见如 CCCD 通知开关 |
| CBUUID | 蓝牙 UUID 表示 |
| CBCentralManagerDelegate | 监听扫描、连接状态 |
| CBPeripheralDelegate | 监听服务发现、读写、通知回调 |
Apple 文档里 CBPeripheral 表示通过 Central Manager 发现的远端外设,外设通过 UUID 进行标识。CBCentralManagerDelegate 则负责监听 Central 状态变化、发现设备、连接结果等事件。
4. BLE 通信模型:Central、Peripheral、Service、Characteristic
初学者最容易卡在这些名词上。可以这样理解:
iPhone App = Central
耳机 = Peripheral
耳机暴露一组 Service
每个 Service 下有多个 Characteristic
App 通过 Characteristic 读、写、订阅数据
例如耳机的 BLE GATT 可能长这样:
Headphone Peripheral
├── Device Information Service
│ ├── Manufacturer Name
│ ├── Model Number
│ └── Firmware Revision
│
├── Battery Service
│ └── Battery Level
│
└── Custom Headphone Control Service
├── ANC Mode Characteristic
├── EQ Preset Characteristic
├── Wearing State Characteristic
├── Command Characteristic
└── Event Notify Characteristic
一个 Characteristic 通常支持若干操作:
| 操作 | 含义 | 例子 |
|---|---|---|
| Read | 读取值 | 读取当前 ANC 模式 |
| Write | 写入值,需要响应 | 设置 ANC 为通透模式 |
| Write Without Response | 写入值,不等响应 | 高频小数据下发 |
| Notify | 外设主动通知 App | 耳机入耳状态变化 |
| Indicate | 外设主动通知且需要确认 | 关键状态上报 |
5. 一个完整的 iOS BLE 中心角色流程
典型流程如下:
1. 创建 CBCentralManager
2. 等待蓝牙状态 poweredOn
3. 扫描指定 Service UUID 的外设
4. 发现目标耳机
5. 停止扫描
6. 连接外设
7. 设置 peripheral.delegate
8. 发现 Services
9. 发现 Characteristics
10. 订阅 Notify
11. Read / Write / Notify 进行业务通信
12. 处理断连和重连
看起来步骤多,但本质是一条状态机。
6. Info.plist 权限配置
iOS 访问蓝牙需要权限说明。常见配置:
<key>NSBluetoothAlwaysUsageDescription</key>
<string>用于连接耳机并同步电量、降噪模式和固件状态。</string>
如果需要后台 BLE 能力,还需要配置 Background Modes:
<key>UIBackgroundModes</key>
<array>
<string>bluetooth-central</string>
</array>
注意:
配置了后台模式,不代表 App 可以像前台一样无限扫描、无限连接、无限跑任务。****
Apple 文档明确说明,后台模式会让系统在相关事件发生时恢复或启动 App,但后台模式应谨慎使用,因为滥用会影响性能和电池寿命。CoreBluetooth 后台文档也说明,后台扫描行为会被系统调整,例如合并重复发现、降低扫描频率等。
7. 定义蓝牙 UUID
假设我们的耳机有一个自定义控制服务:
import CoreBluetooth
enum HeadphoneBLE {
static let controlService = CBUUID(string: "FFF0")
static let ancMode = CBUUID(string: "FFF1")
static let eqPreset = CBUUID(string: "FFF2")
static let command = CBUUID(string: "FFF3")
static let eventNotify = CBUUID(string: "FFF4")
static let batteryService = CBUUID(string: "180F")
static let batteryLevel = CBUUID(string: "2A19")
static let deviceInfoService = CBUUID(string: "180A")
static let firmwareRevision = CBUUID(string: "2A26")
}
说明:
-
180F 是标准 Battery Service。
-
2A19 是标准 Battery Level Characteristic。
-
180A 是标准 Device Information Service。
-
2A26 是 Firmware Revision String。
-
FFF0 ~ FFF4 是示例自定义 UUID,真实项目建议使用 128-bit UUID。
真实项目里不要滥用短 UUID,建议使用类似:
CBUUID(string: "12345678-1234-5678-1234-56789ABCDEF0")
8. 建立一个 BLE 管理器
下面写一个比较完整的 HeadphoneBluetoothManager。
import Foundation
import CoreBluetooth
final class HeadphoneBluetoothManager: NSObject {
enum ConnectionState: Equatable {
case idle
case waitingForBluetooth
case scanning
case connecting
case discoveringServices
case discoveringCharacteristics
case ready
case disconnected(Error?)
case failed(Error?)
}
private var centralManager: CBCentralManager!
private var headphonePeripheral: CBPeripheral?
private var ancModeCharacteristic: CBCharacteristic?
private var eqPresetCharacteristic: CBCharacteristic?
private var commandCharacteristic: CBCharacteristic?
private var eventNotifyCharacteristic: CBCharacteristic?
private var batteryLevelCharacteristic: CBCharacteristic?
private var firmwareRevisionCharacteristic: CBCharacteristic?
private(set) var state: ConnectionState = .idle {
didSet {
print("BLE State:", state)
}
}
override init() {
super.init()
centralManager = CBCentralManager(
delegate: self,
queue: DispatchQueue(label: "com.example.headphone.ble")
)
}
func start() {
switch centralManager.state {
case .poweredOn:
startScan()
case .unknown, .resetting:
state = .waitingForBluetooth
case .unsupported:
print("当前设备不支持 BLE")
case .unauthorized:
print("用户未授权蓝牙权限")
case .poweredOff:
print("蓝牙已关闭")
@unknown default:
print("未知蓝牙状态")
}
}
func stop() {
centralManager.stopScan()
if let peripheral = headphonePeripheral {
centralManager.cancelPeripheralConnection(peripheral)
}
headphonePeripheral = nil
state = .idle
}
private func startScan() {
state = .scanning
centralManager.scanForPeripherals(
withServices: [HeadphoneBLE.controlService],
options: [
CBCentralManagerScanOptionAllowDuplicatesKey: false
]
)
print("开始扫描耳机 BLE 服务")
}
}
这里有几个关键点:
- CBCentralManager 初始化后不会立刻可用,要等 centralManagerDidUpdateState。
- 扫描最好指定 Service UUID,不要无脑扫所有设备。
- 队列建议使用独立串行队列,避免蓝牙回调和主线程 UI 混杂。
- 状态机要从第一天开始设计,不然后期会变成一锅粥。
9. 处理 Central 状态和扫描结果
extension HeadphoneBluetoothManager: CBCentralManagerDelegate {
func centralManagerDidUpdateState(_ central: CBCentralManager) {
switch central.state {
case .poweredOn:
print("蓝牙可用")
startScan()
case .poweredOff:
print("蓝牙关闭")
state = .waitingForBluetooth
case .unauthorized:
print("蓝牙未授权")
state = .failed(nil)
case .unsupported:
print("设备不支持 BLE")
state = .failed(nil)
case .resetting:
print("蓝牙系统重置中")
state = .waitingForBluetooth
case .unknown:
print("蓝牙状态未知")
state = .waitingForBluetooth
@unknown default:
print("未知状态")
state = .waitingForBluetooth
}
}
func centralManager(
_ central: CBCentralManager,
didDiscover peripheral: CBPeripheral,
advertisementData: [String : Any],
rssi RSSI: NSNumber
) {
let localName = advertisementData[CBAdvertisementDataLocalNameKey] as? String
let serviceUUIDs = advertisementData[CBAdvertisementDataServiceUUIDsKey] as? [CBUUID]
print("""
发现设备:
name: (peripheral.name ?? localName ?? "unknown")
id: (peripheral.identifier)
RSSI: (RSSI)
services: (serviceUUIDs ?? [])
""")
guard isTargetHeadphone(
peripheral: peripheral,
advertisementData: advertisementData,
rssi: RSSI
) else {
return
}
central.stopScan()
headphonePeripheral = peripheral
peripheral.delegate = self
state = .connecting
central.connect(peripheral, options: nil)
}
private func isTargetHeadphone(
peripheral: CBPeripheral,
advertisementData: [String: Any],
rssi: NSNumber
) -> Bool {
// 示例策略:
// 1. 优先通过 Service UUID 过滤
// 2. 可结合厂商数据 manufacturer data
// 3. 可结合设备名,但不要只依赖设备名
// 4. 不建议依赖 MAC 地址,iOS 不暴露真实 BLE MAC
if RSSI.intValue < -90 {
return false
}
if let name = peripheral.name,
name.contains("MyHeadphone") {
return true
}
if let localName = advertisementData[CBAdvertisementDataLocalNameKey] as? String,
localName.contains("MyHeadphone") {
return true
}
return false
}
}
Apple 的 scanForPeripherals(withServices:options:) 文档里有一个重要细节:如果 App 要在后台扫描,必须在 serviceUUIDs 参数里明确指定一个或多个服务;后台扫描时某些扫描选项不会像前台一样生效。
所以实际项目里,不建议这样写:
centralManager.scanForPeripherals(withServices: nil)
尤其不建议在后台依赖全量扫描。
更推荐:
centralManager.scanForPeripherals(
withServices: [HeadphoneBLE.controlService],
options: nil
)
10. 连接外设
继续实现连接回调:
extension HeadphoneBluetoothManager {
func centralManager(
_ central: CBCentralManager,
didConnect peripheral: CBPeripheral
) {
print("连接成功:", peripheral.name ?? "unknown")
state = .discoveringServices
peripheral.discoverServices([
HeadphoneBLE.controlService,
HeadphoneBLE.batteryService,
HeadphoneBLE.deviceInfoService
])
}
func centralManager(
_ central: CBCentralManager,
didFailToConnect peripheral: CBPeripheral,
error: Error?
) {
print("连接失败:", error?.localizedDescription ?? "unknown")
state = .failed(error)
scheduleReconnect()
}
func centralManager(
_ central: CBCentralManager,
didDisconnectPeripheral peripheral: CBPeripheral,
error: Error?
) {
print("连接断开:", error?.localizedDescription ?? "normal")
state = .disconnected(error)
clearCharacteristics()
// 真实项目里要区分:
// 1. 用户主动断开
// 2. 系统断开
// 3. 外设断开
// 4. OTA 重启
// 5. 蓝牙关闭
scheduleReconnect()
}
private func clearCharacteristics() {
ancModeCharacteristic = nil
eqPresetCharacteristic = nil
commandCharacteristic = nil
eventNotifyCharacteristic = nil
batteryLevelCharacteristic = nil
firmwareRevisionCharacteristic = nil
}
private func scheduleReconnect() {
guard centralManager.state == .poweredOn else {
return
}
DispatchQueue.global().asyncAfter(deadline: .now() + 2.0) { [weak self] in
guard let self else { return }
if let peripheral = self.headphonePeripheral {
self.state = .connecting
self.centralManager.connect(peripheral, options: nil)
} else {
self.startScan()
}
}
}
}
一个容易忽略的点:connect(:options:) 的连接尝试不会自动超时;如果要取消挂起的连接,需要主动调用 cancelPeripheralConnection(:)。Apple 文档明确说明连接成功会回调 didConnect,失败会回调 didFailToConnect,挂起连接则需要显式取消。
实际项目里建议自己加连接超时保护:
private var connectTimeoutWorkItem: DispatchWorkItem?
private func connectWithTimeout(_ peripheral: CBPeripheral, timeout: TimeInterval = 10) {
state = .connecting
centralManager.connect(peripheral, options: nil)
connectTimeoutWorkItem?.cancel()
let item = DispatchWorkItem { [weak self, weak peripheral] in
guard let self, let peripheral else { return }
if self.state == .connecting {
print("连接超时,主动取消")
self.centralManager.cancelPeripheralConnection(peripheral)
self.state = .failed(nil)
}
}
connectTimeoutWorkItem = item
DispatchQueue.global().asyncAfter(deadline: .now() + timeout, execute: item)
}
11. 发现 Service 和 Characteristic
连接成功后,App 不能立刻读写。必须先发现服务和特征。
extension HeadphoneBluetoothManager: CBPeripheralDelegate {
func peripheral(
_ peripheral: CBPeripheral,
didDiscoverServices error: Error?
) {
if let error {
print("发现服务失败:", error)
state = .failed(error)
return
}
guard let services = peripheral.services else {
return
}
for service in services {
print("发现服务:", service.uuid)
switch service.uuid {
case HeadphoneBLE.controlService:
peripheral.discoverCharacteristics([
HeadphoneBLE.ancMode,
HeadphoneBLE.eqPreset,
HeadphoneBLE.command,
HeadphoneBLE.eventNotify
], for: service)
case HeadphoneBLE.batteryService:
peripheral.discoverCharacteristics([
HeadphoneBLE.batteryLevel
], for: service)
case HeadphoneBLE.deviceInfoService:
peripheral.discoverCharacteristics([
HeadphoneBLE.firmwareRevision
], for: service)
default:
break
}
}
state = .discoveringCharacteristics
}
func peripheral(
_ peripheral: CBPeripheral,
didDiscoverCharacteristicsFor service: CBService,
error: Error?
) {
if let error {
print("发现特征失败:", error)
state = .failed(error)
return
}
guard let characteristics = service.characteristics else {
return
}
for characteristic in characteristics {
print("发现特征:", characteristic.uuid, characteristic.properties)
switch characteristic.uuid {
case HeadphoneBLE.ancMode:
ancModeCharacteristic = characteristic
case HeadphoneBLE.eqPreset:
eqPresetCharacteristic = characteristic
case HeadphoneBLE.command:
commandCharacteristic = characteristic
case HeadphoneBLE.eventNotify:
eventNotifyCharacteristic = characteristic
peripheral.setNotifyValue(true, for: characteristic)
case HeadphoneBLE.batteryLevel:
batteryLevelCharacteristic = characteristic
peripheral.readValue(for: characteristic)
if characteristic.properties.contains(.notify) {
peripheral.setNotifyValue(true, for: characteristic)
}
case HeadphoneBLE.firmwareRevision:
firmwareRevisionCharacteristic = characteristic
peripheral.readValue(for: characteristic)
default:
break
}
}
checkReady()
}
private func checkReady() {
if ancModeCharacteristic != nil,
commandCharacteristic != nil,
eventNotifyCharacteristic != nil {
state = .ready
print("耳机 BLE 通道已就绪")
}
}
}
12. Read:读取耳机电量和固件版本
电量是最适合初学者理解的例子。
BLE 标准 Battery Level 是一个 UInt8,范围 0~100。
func readBatteryLevel() {
guard let peripheral = headphonePeripheral,
let characteristic = batteryLevelCharacteristic else {
return
}
peripheral.readValue(for: characteristic)
}
读取结果在 didUpdateValueFor:
extension HeadphoneBluetoothManager {
func peripheral(
_ peripheral: CBPeripheral,
didUpdateValueFor characteristic: CBCharacteristic,
error: Error?
) {
if let error {
print("读取/通知失败:", error)
return
}
guard let data = characteristic.value else {
return
}
switch characteristic.uuid {
case HeadphoneBLE.batteryLevel:
handleBatteryLevel(data)
case HeadphoneBLE.firmwareRevision:
handleFirmwareRevision(data)
case HeadphoneBLE.eventNotify:
handleEventNotify(data)
case HeadphoneBLE.ancMode:
handleANCMode(data)
default:
print("未知特征更新:", characteristic.uuid, data as NSData)
}
}
private func handleBatteryLevel(_ data: Data) {
guard let value = data.first else {
return
}
print("耳机电量:", value, "%")
}
private func handleFirmwareRevision(_ data: Data) {
let version = String(data: data, encoding: .utf8) ?? "unknown"
print("固件版本:", version)
}
private func handleANCMode(_ data: Data) {
guard let raw = data.first else {
return
}
let mode = ANCMode(rawValue: raw)
print("当前 ANC 模式:", String(describing: mode))
}
}
定义 ANC 模式:
enum ANCMode: UInt8 {
case off = 0x00
case noiseCancelling = 0x01
case transparency = 0x02
case adaptive = 0x03
}
13. Write:设置耳机降噪模式
假设耳机的 ANC Mode Characteristic 支持写入一个字节:
func setANCMode(_ mode: ANCMode) {
guard let peripheral = headphonePeripheral,
let characteristic = ancModeCharacteristic else {
print("ANC 特征未就绪")
return
}
let data = Data([mode.rawValue])
peripheral.writeValue(
data,
for: characteristic,
type: .withResponse
)
}
处理写入结果:
func peripheral(
_ peripheral: CBPeripheral,
didWriteValueFor characteristic: CBCharacteristic,
error: Error?
) {
if let error {
print("写入失败:", characteristic.uuid, error)
return
}
print("写入成功:", characteristic.uuid)
}
什么时候用 .withResponse,什么时候用 .withoutResponse?
| 写入方式 | 特点 | 适合场景 |
|---|---|---|
| .withResponse | 有系统级写入回调,更可靠但较慢 | 控制命令、配置修改、关键操作 |
| .withoutResponse | 吞吐更高,但无每包确认 | 大量数据、可容忍应用层重传的流式写入 |
设置 ANC 模式这种命令,建议用 .withResponse。
OTA 分包传输可能使用 .withoutResponse,但需要自己设计 ACK、序号、校验和重试。
14. Notify:接收耳机主动事件
耳机状态经常不是 App 主动读出来的,而是耳机主动通知。
比如:
-
入耳 / 摘下
-
充电盒打开
-
左耳电量变化
-
ANC 模式被物理按键切换
-
OTA 进度
-
耳机异常重启
假设 eventNotifyCharacteristic 通知格式如下:
Byte 0: Event Type
Byte 1...N: Payload
事件类型:
enum HeadphoneEventType: UInt8 {
case wearingStateChanged = 0x01
case batteryChanged = 0x02
case ancModeChanged = 0x03
case otaProgress = 0x04
case error = 0x7F
}
解析 Notify:
private func handleEventNotify(_ data: Data) {
guard let typeRaw = data.first,
let type = HeadphoneEventType(rawValue: typeRaw) else {
print("未知事件:", data as NSData)
return
}
let payload = data.dropFirst()
switch type {
case .wearingStateChanged:
handleWearingState(Data(payload))
case .batteryChanged:
handleBatteryChanged(Data(payload))
case .ancModeChanged:
handleANCModeChanged(Data(payload))
case .otaProgress:
handleOTAProgress(Data(payload))
case .error:
handleDeviceError(Data(payload))
}
}
private func handleWearingState(_ data: Data) {
guard data.count >= 2 else { return }
let leftInEar = data[0] == 1
let rightInEar = data[1] == 1
print("佩戴状态 left:", leftInEar, "right:", rightInEar)
}
private func handleBatteryChanged(_ data: Data) {
guard data.count >= 3 else { return }
let left = data[0]
let right = data[1]
let caseBattery = data[2]
print("电量 left:", left, "right:", right, "case:", caseBattery)
}
private func handleANCModeChanged(_ data: Data) {
guard let raw = data.first,
let mode = ANCMode(rawValue: raw) else {
return
}
print("ANC 模式变化:", mode)
}
private func handleOTAProgress(_ data: Data) {
guard let progress = data.first else {
return
}
print("OTA 进度:", progress, "%")
}
private func handleDeviceError(_ data: Data) {
let code = data.first ?? 0xFF
print("耳机错误码:", code)
}
15. 设计一个更专业的应用层协议
很多 BLE 项目会写成这样:
peripheral.writeValue(Data([0x01]), for: commandCharacteristic, type: .withResponse)
短期能跑,长期必炸。
真实项目里,建议设计应用层包格式。
例如:
Header:
Byte 0: Magic = 0xA5
Byte 1: Version
Byte 2: Command ID
Byte 3: Sequence
Byte 4: Flags
Byte 5: Payload Length
Byte 6...N: Payload
Last Byte: Checksum
Swift 编码示例:
struct HeadphonePacket {
let version: UInt8 = 0x01
let commandID: UInt8
let sequence: UInt8
let flags: UInt8
let payload: Data
func encode() -> Data {
var data = Data()
data.append(0xA5)
data.append(version)
data.append(commandID)
data.append(sequence)
data.append(flags)
data.append(UInt8(payload.count))
data.append(payload)
let checksum = data.reduce(UInt8(0)) { partial, byte in
partial &+ byte
}
data.append(checksum)
return data
}
static func decode(_ data: Data) throws -> HeadphonePacket {
enum PacketError: Error {
case tooShort
case invalidMagic
case invalidLength
case invalidChecksum
}
guard data.count >= 7 else {
throw PacketError.tooShort
}
guard data[0] == 0xA5 else {
throw PacketError.invalidMagic
}
let payloadLength = Int(data[5])
let expectedLength = 6 + payloadLength + 1
guard data.count == expectedLength else {
throw PacketError.invalidLength
}
let body = data.dropLast()
let checksum = body.reduce(UInt8(0)) { $0 &+ $1 }
guard checksum == data.last else {
throw PacketError.invalidChecksum
}
let payloadStart = data.index(data.startIndex, offsetBy: 6)
let payloadEnd = data.index(payloadStart, offsetBy: payloadLength)
let payload = data[payloadStart..<payloadEnd]
return HeadphonePacket(
commandID: data[2],
sequence: data[3],
flags: data[4],
payload: Data(payload)
)
}
}
发送命令:
private var sequence: UInt8 = 0
func sendCommand(commandID: UInt8, payload: Data) {
guard let peripheral = headphonePeripheral,
let characteristic = commandCharacteristic else {
return
}
sequence &+= 1
let packet = HeadphonePacket(
commandID: commandID,
sequence: sequence,
flags: 0x00,
payload: payload
)
peripheral.writeValue(
packet.encode(),
for: characteristic,
type: .withResponse
)
}
设置 EQ:
enum EQPreset: UInt8 {
case balanced = 0
case bassBoost = 1
case vocal = 2
case custom = 3
}
func setEQPreset(_ preset: EQPreset) {
sendCommand(
commandID: 0x20,
payload: Data([preset.rawValue])
)
}
这样设计的好处:
- 有协议版本,方便升级。
- 有命令 ID,方便扩展。
- 有序号,方便匹配请求和响应。
- 有长度,方便分包和解析。
- 有校验,方便发现传输或解析错误。
- 有 flags,后续可以支持 ACK、分片、加密标记等。
16. MTU、分包与大数据传输
BLE 不是 TCP,也不是串口。
Characteristic 单次传输大小有限,实际可用大小取决于 MTU 和平台策略。
iOS 里可以通过:
let maxLength = peripheral.maximumWriteValueLength(for: .withoutResponse)
获取当前无响应写入的最大长度:
func sendLargeData(_ data: Data) {
guard let peripheral = headphonePeripheral,
let characteristic = commandCharacteristic else {
return
}
let chunkSize = peripheral.maximumWriteValueLength(for: .withoutResponse)
var offset = 0
while offset < data.count {
let end = min(offset + chunkSize, data.count)
let chunk = data.subdata(in: offset..<end)
peripheral.writeValue(
chunk,
for: characteristic,
type: .withoutResponse
)
offset = end
}
}
但这段代码还不够专业。因为 .withoutResponse 写太快可能塞满系统缓冲。更稳的做法是监听:
func peripheralIsReady(toSendWriteWithoutResponse peripheral: CBPeripheral) {
print("可以继续发送 withoutResponse 数据")
}
设计一个发送队列:
final class BLEWriteQueue {
private var chunks: [Data] = []
private var isSending = false
func enqueue(_ data: Data, chunkSize: Int) {
chunks = stride(from: 0, to: data.count, by: chunkSize).map { offset in
let end = min(offset + chunkSize, data.count)
return data.subdata(in: offset..<end)
}
}
func next() -> Data? {
guard !chunks.isEmpty else {
return nil
}
return chunks.removeFirst()
}
var hasMore: Bool {
!chunks.isEmpty
}
}
在管理器里:
private let writeQueue = BLEWriteQueue()
func sendLargeDataSafely(_ data: Data) {
guard let peripheral = headphonePeripheral else { return }
let chunkSize = peripheral.maximumWriteValueLength(for: .withoutResponse)
writeQueue.enqueue(data, chunkSize: chunkSize)
drainWriteQueue()
}
private func drainWriteQueue() {
guard let peripheral = headphonePeripheral,
let characteristic = commandCharacteristic else {
return
}
while peripheral.canSendWriteWithoutResponse,
let chunk = writeQueue.next() {
peripheral.writeValue(
chunk,
for: characteristic,
type: .withoutResponse
)
}
}
func peripheralIsReady(toSendWriteWithoutResponse peripheral: CBPeripheral) {
drainWriteQueue()
}
如果你做 OTA,别只靠 BLE 自身可靠性。你还需要:
- 分片序号
- 分片 ACK
- 固件块 CRC
- 整包 Hash
- 签名校验
- 断点续传
- 低电量保护
- 回滚机制
17. iOS 后台蓝牙:能做,但别幻想太多
iOS 支持 BLE 后台模式,但它不是“后台常驻”。
典型限制包括:
-
后台扫描必须指定 Service UUID。
-
后台扫描频率会变低。
-
重复发现可能被合并。
-
App 被唤醒后只有有限时间处理事件。
-
系统会优先考虑电池和隐私。
-
不要期待后台像前台一样实时。
Apple 文档明确提到,使用 bluetooth-central 后台模式时,系统可以在特定蓝牙事件发生时唤醒 App;但后台扫描和广播会被系统优化以减少无线电使用。
所以 App 设计应该是:
前台:
- 扫描
- 连接
- 配置
- 实时交互
- OTA
后台:
- 维持必要连接
- 接收关键 Notify
- 快速处理后退出
- 不做长时间高频传输
对于耳机 App,通常不要在后台做 OTA。
用户锁屏、App 被挂起、BLE 限速、断连重启,都会让 OTA 体验变成薛定谔升级。
18. iOS 为什么限制 SPP?
SPP 是 Serial Port Profile,经典蓝牙里常见的“无线串口”。
Android 和很多嵌入式设备上,SPP 很常见:
手机 App <—— SPP/RFCOMM ——> MCU
但 iOS 普通 App 不能随便使用 SPP。自定义经典蓝牙配件通信通常要走 MFi 和 External Accessory。Apple 的蓝牙开发页面提到,如果硬件配件使用 External Accessory framework 或 Classic Bluetooth 技术,需要加入 MFi Program。
为什么?
1. SPP 太像裸通道
SPP 给 App 一条任意字节流。系统很难理解你传的是什么,也很难做权限最小化、隐私保护、后台管理和安全审查。
BLE GATT 则结构化得多:
Service
Characteristic
Read / Write / Notify
Permission
系统可以更好地管理。
2. SPP 容易导致后台常连和耗电
很多厂商会拿 SPP 当串口一直发心跳、一直保活。
这和 iOS 的后台模型冲突。
3. Apple 更倾向系统托管体验
音频走系统音频栈。
键盘鼠标走 HID。
智能家居走 HomeKit/Matter。
低功耗自定义控制走 BLE。
高可信经典蓝牙配件走 MFi。
这是一套受控生态,不是“把蓝牙栈全开放给 App”。
4. 未来方向也不是重新开放 SPP
iOS 18 引入的 AccessorySetupKit 就很能说明趋势。它用于以隐私保护的方式发现和配置蓝牙或 Wi-Fi 配件,让用户不必授予 App 过宽泛的蓝牙或 Wi-Fi 访问权限。
Apple 中文配件页面也提到,AccessorySetupKit 可以让 App 安全、无缝地与蓝牙配件配对,不需要访问附近所有蓝牙配件。
这说明 Apple 的方向是:
不是开放更多裸蓝牙能力,而是让配件发现、授权、配置更系统化、更隐私友好。****
19. 双模耳机的推荐架构
假设你在做一款耳机 App,我建议这样分层:
系统层:
- 用户在 iOS 设置里连接耳机音频
- 系统管理 A2DP/HFP/AVRCP
- App 使用 AVAudioSession 感知音频路由
BLE 控制层:
- App 扫描耳机自定义 BLE Service
- App 建立 BLE 连接
- 读取电量、固件、能力
- 写入 ANC/EQ/触控配置
- 订阅佩戴状态、电量变化、错误事件
业务协议层:
- 命令 ID
- 序号
- ACK
- 错误码
- 协议版本
- 分包/重组
- OTA 状态机
安全层:
- 绑定关系
- 加密读写
- 关键操作鉴权
- OTA 签名校验
- 恢复出厂清密钥
体验层:
- 连接状态 UI
- 失败原因提示
- 断线重连
- 多设备选择
- OTA 防中断提示
特别提醒:
不要假设 BLE 连接成功就代表系统音频也连上了。****
双模耳机里,Classic 音频连接和 BLE 控制连接可能是两条不同链路。
你可能遇到这些状态:
| 系统音频 | BLE 控制 | 用户看到 |
|---|---|---|
| 已连接 | 已连接 | 正常 |
| 已连接 | 未连接 | 能听歌,但 App 控制不可用 |
| 未连接 | 已连接 | App 能看到耳机,但声音不走耳机 |
| 未连接 | 未连接 | 完全未连接 |
所以 UI 要区分:
音频连接状态
BLE 控制连接状态
耳机盒/左右耳状态
不要只显示一个“已连接”。
20. 用 AVAudioSession 判断音频路由
虽然本文重点是 BLE,但耳机 App 经常需要知道当前音频是否走蓝牙耳机。
示例:
import AVFoundation
final class AudioRouteMonitor {
init() {
NotificationCenter.default.addObserver(
self,
selector: #selector(routeChanged(_:)),
name: AVAudioSession.routeChangeNotification,
object: nil
)
}
func currentBluetoothOutputs() -> [AVAudioSessionPortDescription] {
let route = AVAudioSession.sharedInstance().currentRoute
return route.outputs.filter { output in
switch output.portType {
case .bluetoothA2DP, .bluetoothHFP, .bluetoothLE:
return true
default:
return false
}
}
}
@objc private func routeChanged(_ notification: Notification) {
let outputs = currentBluetoothOutputs()
print("当前蓝牙音频输出:", outputs.map(.portName))
}
}
注意:
AVAudioSession 只能告诉你系统音频路由,不等于 CoreBluetooth 的 BLE 连接。
21. 常见踩坑 QA
Q1:为什么我扫描不到耳机?
常见原因:
-
耳机没有进入 BLE 广播模式。
-
App 扫描的 Service UUID 和耳机广播的不一致。
-
耳机只在未绑定或开盒时广播。
-
iOS 后台扫描必须指定 Service UUID。
-
广播间隔太长,用户等待时间太短。
-
耳机已经被其他手机连接,停止可连接广播。
-
你依赖设备名,但设备名没放在广播包里。
-
用户没授权蓝牙。
-
蓝牙系统状态不是 .poweredOn。
-
你在模拟器上测,模拟器不适合真实 BLE 开发。
建议先用 nRF Connect 之类工具确认耳机是否真的在广播。
Q2:为什么 iOS 拿不到蓝牙 MAC 地址?
因为 iOS 不向 App 暴露真实 BLE MAC 地址。你拿到的是:
peripheral.identifier
它是 iOS 给这个外设生成的 UUID,不是设备真实 MAC。
不要把 MAC 地址作为业务身份。
推荐使用:
- 设备序列号 Characteristic
- 绑定后分配的设备 ID
- 厂商数据里的匿名标识
- 账号体系绑定
- 安全密钥派生 ID
Q3:为什么连接成功后读不到 Characteristic?
可能原因:
-
没有调用 discoverServices。
-
没有调用 discoverCharacteristics。
-
Service UUID 写错。
-
Characteristic UUID 写错。
-
外设固件 GATT 表和 App 预期不一致。
-
iOS 缓存了旧 GATT 表。
-
该 Characteristic 权限要求加密或配对。
-
外设还没准备好,连接后需要延迟或等待状态。
如果固件改了 GATT 表,iOS 可能存在缓存问题。开发阶段可以尝试:
- 忘记设备
- 重启蓝牙
- 重启手机
- 修改 Service Changed 行为
- 提升固件 GATT 版本管理
Q4:为什么 Notify 没有回调?
检查:
-
Characteristic 是否支持 .notify 或 .indicate。
-
App 是否调用了 setNotifyValue(true, for:)。
-
是否收到 didUpdateNotificationStateFor 成功回调。
-
外设是否真的写 CCCD 成功。
-
外设是否发送 Notify。
-
Notify 数据是否过快导致阻塞。
-
App 是否进入后台后被系统限制。
-
是否连接了错误的耳机实例。
实现通知状态回调:
func peripheral(
_ peripheral: CBPeripheral,
didUpdateNotificationStateFor characteristic: CBCharacteristic,
error: Error?
) {
if let error {
print("订阅失败:", characteristic.uuid, error)
return
}
print("订阅状态:", characteristic.uuid, characteristic.isNotifying)
}
Q5:为什么
.withoutResponse
写入会丢数据?
因为它没有每包响应。
系统和外设都有缓冲区,写太快就可能堵。
解决:
- 使用 peripheral.canSendWriteWithoutResponse。
- 实现 peripheralIsReady(toSendWriteWithoutResponse:)。
- 做应用层 ACK。
- 做序号和重传。
- 控制发送窗口大小。
- 大数据分块传输,不要一口气 while 写到底。
Q6:为什么 App 后台后蓝牙变慢或不稳定?
这是 iOS 正常策略。
后台 BLE 是事件驱动,不是无限执行。
解决思路:
- 配置 bluetooth-central。
- 后台扫描必须指定 Service UUID。
- 降低后台实时性预期。
- 后台只处理关键事件。
- OTA、实时控制、高频数据尽量放前台。
- 被唤醒后快速处理,不要长时间占用。
Q7:为什么耳机能听歌,但 App 显示未连接?
因为音频链路和 BLE 控制链路不同。
可能是:
Classic 已连接
BLE 未连接
用户能听歌,但 App 没连上 BLE 控制服务。
反过来也可能:
BLE 已连接
Classic 未连接
App 能读到电量,但音频不走耳机。
UI 要分开表达。
Q8:为什么 iOS 不支持我想要的 SPP?
普通 App 不能直接使用经典蓝牙 SPP/RFCOMM。
如果你的需求是自定义数据通信,优先改成 BLE GATT。
如果必须经典蓝牙自定义通信,通常要考虑 MFi + External Accessory。
Q9:为什么同一套 BLE 代码 Android 正常,iOS 不正常?
因为两边蓝牙栈和系统策略差异很大。
典型差异:
| 问题 | Android | iOS |
|---|---|---|
| MAC 地址 | 部分场景可见 | 不暴露真实 MAC |
| 后台扫描 | 厂商差异大 | 系统强管控 |
| SPP | 常见 | 普通 App 不开放 |
| GATT 缓存 | 问题多 | 也可能缓存 |
| MTU | 可主动请求 | CoreBluetooth 抽象处理 |
| 扫描策略 | 更开放但碎片化 | 更受控更一致 |
不要以 Android 行为推断 iOS 行为。
Q10:为什么连接偶尔失败?
蓝牙连接失败很正常。
真实环境里有干扰、距离、系统调度、耳机状态、电量、其他手机抢连等因素。
你需要:
-
连接超时
-
重试机制
-
指数退避
-
用户主动取消
-
错误分类
-
断连恢复
-
日志上报
不要写成:
connect()
失败就弹窗“连接失败”
应该有完整状态机。
22. 推荐的 iOS BLE 状态机
一个比较靠谱的状态机:
idle
↓
waitingForBluetooth
↓
scanning
↓
connecting
↓
discoveringServices
↓
discoveringCharacteristics
↓
subscribingNotifications
↓
ready
↓
disconnected
↓
reconnecting
另外还要考虑:
permissionDenied
bluetoothOff
unsupported
userCancelled
otaMode
firmwareRebooting
factoryReset
状态机不要散落在 ViewController 里。
建议单独封装:
HeadphoneBluetoothManager
HeadphoneConnectionState
HeadphoneCommandTransport
HeadphoneProtocolCodec
HeadphoneOTAService
23. 一个更清晰的项目结构
推荐结构:
Bluetooth/
├── HeadphoneBluetoothManager.swift
├── HeadphoneBLEUUID.swift
├── HeadphoneConnectionState.swift
├── HeadphoneProtocolCodec.swift
├── HeadphoneCommand.swift
├── HeadphoneEvent.swift
├── HeadphoneOTAService.swift
├── BLEWriteQueue.swift
└── BluetoothLogger.swift
不要把所有 CoreBluetooth 回调都塞进 ViewController。
ViewController 只关心:
manager.connect()
manager.setANCMode(.transparency)
manager.readBatteryLevel()
而不应该关心:
didDiscoverCharacteristicsFor
didUpdateValueFor
didWriteValueFor
24. 日志:蓝牙开发的救命绳
蓝牙问题很难复现。一定要有日志。
建议记录:
时间戳
蓝牙状态
扫描开始/停止
发现设备 name / identifier / RSSI / advertisementData
连接开始/成功/失败/断开
服务发现结果
特征发现结果
订阅 Notify 结果
每条命令 commandID / sequence / payload length
每条响应 eventID / sequence / errorCode
App 前后台变化
音频路由变化
OTA 进度
断连原因
示例:
struct BLELog {
static func info(_ message: String) {
print("[BLE][INFO][(Date())] (message)")
}
static func error(_ message: String) {
print("[BLE][ERROR][(Date())] (message)")
}
static func data(_ prefix: String, _ data: Data) {
let hex = data.map { String(format: "%02X", $0) }.joined(separator: " ")
print("[BLE][DATA][(Date())] (prefix): (hex)")
}
}
发送命令时:
let encoded = packet.encode()
BLELog.data("TX command (commandID)", encoded)
peripheral.writeValue(encoded, for: characteristic, type: .withResponse)
收到通知时:
BLELog.data("RX notify (characteristic.uuid)", data)
没有日志的蓝牙调试,基本就是闭眼走迷宫,还得假装自己很有方向感。
25. 未来趋势:iOS 蓝牙开发会往哪里走?
几个方向很明确:
1. BLE 仍然是第三方配件主通道
耳机、穿戴、传感器、智能硬件,App 控制层仍会大量使用 BLE。
2. Apple 会继续强化隐私和授权
AccessorySetupKit 的出现说明 Apple 想让配件发现和授权更精细,而不是让 App 随便扫描附近所有设备。它支持隐私保护的蓝牙/Wi-Fi 配件发现和配置。
3. Classic 自定义通信不会变成主流开放方向
SPP 这种“裸串口”能力大概率仍然不会面向普通 App 放开。
4. 双模设备会越来越常见
尤其是耳机:
Classic / 系统音频:负责声音
BLE / App:负责控制
UWB / Find My / Nearby:负责查找和空间交互
5. App 要从“连接设备”升级为“管理设备体验”
未来优秀的蓝牙 App 不只是连上设备,而是提供:
- 快速发现
- 明确授权
- 稳定重连
- 低功耗
- 安全绑定
- OTA 可靠升级
- 多设备管理
- 系统体验融合
26. 最后总结
在 iOS 上做蓝牙开发,最重要的不是背 API,而是建立正确模型:
-
iOS App 做自定义蓝牙通信,优先考虑 BLE + CoreBluetooth。****
-
双模耳机里,音频链路和 BLE 控制链路通常是两回事。****
-
Classic 音频归系统管,App 不直接操作底层音频蓝牙链路。****
-
SPP/RFCOMM 普通 App 不开放,别拿 Android 经验硬套 iOS。****
-
BLE 的核心是 Central、Peripheral、Service、Characteristic。****
-
扫描要指定 Service UUID,后台尤其如此。****
-
连接后必须发现服务和特征,不能直接读写。****
-
Notify 不是无限推送通道,Write Without Response 也不是无限发送通道。****
-
大数据和 OTA 必须设计分包、序号、ACK、校验、断点续传。****
-
蓝牙开发一定要有状态机、重连策略和完整日志。****
-
iOS 后台蓝牙是受控事件模型,不是后台常驻。****
-
未来趋势是更隐私友好的配件授权、更系统化的配件体验,而不是开放裸蓝牙能力。
一句话收尾:
iOS 蓝牙开发的本质,是在 Apple 受控、隐私优先、低功耗优先的系统模型里,用 BLE 构建稳定、安全、可维护的设备通信层。连上只是第一步,长期稳定地连、正确地传、安全地升级,才是真正的蓝牙工程能力。
文章由 GPT5.5 AI生成