一文讲清楚在 iOS 上的蓝牙开发

5 阅读25分钟

面向:刚接触蓝牙、但有 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持续连接、音频、较高吞吐
BLEBluetooth 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 服务")
    }
}

这里有几个关键点:

  1. CBCentralManager 初始化后不会立刻可用,要等 centralManagerDidUpdateState。
  2. 扫描最好指定 Service UUID,不要无脑扫所有设备。
  3. 队列建议使用独立串行队列,避免蓝牙回调和主线程 UI 混杂。
  4. 状态机要从第一天开始设计,不然后期会变成一锅粥。

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:为什么我扫描不到耳机?

常见原因:

  1. 耳机没有进入 BLE 广播模式。

  2. App 扫描的 Service UUID 和耳机广播的不一致。

  3. 耳机只在未绑定或开盒时广播。

  4. iOS 后台扫描必须指定 Service UUID。

  5. 广播间隔太长,用户等待时间太短。

  6. 耳机已经被其他手机连接,停止可连接广播。

  7. 你依赖设备名,但设备名没放在广播包里。

  8. 用户没授权蓝牙。

  9. 蓝牙系统状态不是 .poweredOn。

  10. 你在模拟器上测,模拟器不适合真实 BLE 开发。

建议先用 nRF Connect 之类工具确认耳机是否真的在广播。


Q2:为什么 iOS 拿不到蓝牙 MAC 地址?

因为 iOS 不向 App 暴露真实 BLE MAC 地址。你拿到的是:

peripheral.identifier

它是 iOS 给这个外设生成的 UUID,不是设备真实 MAC。

不要把 MAC 地址作为业务身份。

推荐使用:

  • 设备序列号 Characteristic
  • 绑定后分配的设备 ID
  • 厂商数据里的匿名标识
  • 账号体系绑定
  • 安全密钥派生 ID

Q3:为什么连接成功后读不到 Characteristic?

可能原因:

  1. 没有调用 discoverServices。

  2. 没有调用 discoverCharacteristics。

  3. Service UUID 写错。

  4. Characteristic UUID 写错。

  5. 外设固件 GATT 表和 App 预期不一致。

  6. iOS 缓存了旧 GATT 表。

  7. 该 Characteristic 权限要求加密或配对。

  8. 外设还没准备好,连接后需要延迟或等待状态。

如果固件改了 GATT 表,iOS 可能存在缓存问题。开发阶段可以尝试:

  • 忘记设备
  • 重启蓝牙
  • 重启手机
  • 修改 Service Changed 行为
  • 提升固件 GATT 版本管理

Q4:为什么 Notify 没有回调?

检查:

  1. Characteristic 是否支持 .notify 或 .indicate。

  2. App 是否调用了 setNotifyValue(true, for:)。

  3. 是否收到 didUpdateNotificationStateFor 成功回调。

  4. 外设是否真的写 CCCD 成功。

  5. 外设是否发送 Notify。

  6. Notify 数据是否过快导致阻塞。

  7. App 是否进入后台后被系统限制。

  8. 是否连接了错误的耳机实例。

实现通知状态回调:

func peripheral(
    _ peripheral: CBPeripheral,
    didUpdateNotificationStateFor characteristic: CBCharacteristic,
    error: Error?
) {
    if let error {
        print("订阅失败:", characteristic.uuid, error)
        return
    }

    print("订阅状态:", characteristic.uuid, characteristic.isNotifying)
}

Q5:为什么

.withoutResponse

写入会丢数据?

因为它没有每包响应。

系统和外设都有缓冲区,写太快就可能堵。

解决:

  1. 使用 peripheral.canSendWriteWithoutResponse。
  2. 实现 peripheralIsReady(toSendWriteWithoutResponse:)。
  3. 做应用层 ACK。
  4. 做序号和重传。
  5. 控制发送窗口大小。
  6. 大数据分块传输,不要一口气 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 不正常?

因为两边蓝牙栈和系统策略差异很大。

典型差异:

问题AndroidiOS
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,而是建立正确模型:

  1. iOS App 做自定义蓝牙通信,优先考虑 BLE + CoreBluetooth。****

  2. 双模耳机里,音频链路和 BLE 控制链路通常是两回事。****

  3. Classic 音频归系统管,App 不直接操作底层音频蓝牙链路。****

  4. SPP/RFCOMM 普通 App 不开放,别拿 Android 经验硬套 iOS。****

  5. BLE 的核心是 Central、Peripheral、Service、Characteristic。****

  6. 扫描要指定 Service UUID,后台尤其如此。****

  7. 连接后必须发现服务和特征,不能直接读写。****

  8. Notify 不是无限推送通道,Write Without Response 也不是无限发送通道。****

  9. 大数据和 OTA 必须设计分包、序号、ACK、校验、断点续传。****

  10. 蓝牙开发一定要有状态机、重连策略和完整日志。****

  11. iOS 后台蓝牙是受控事件模型,不是后台常驻。****

  12. 未来趋势是更隐私友好的配件授权、更系统化的配件体验,而不是开放裸蓝牙能力。

一句话收尾:

iOS 蓝牙开发的本质,是在 Apple 受控、隐私优先、低功耗优先的系统模型里,用 BLE 构建稳定、安全、可维护的设备通信层。连上只是第一步,长期稳定地连、正确地传、安全地升级,才是真正的蓝牙工程能力。

文章由 GPT5.5 AI生成