杰理 SDK 文档中心
首页
首页
  • 快速开始

    • 环境要求与工程导入
    • 蓝牙权限与后台模式配置
  • GATT over BR/EDR 示例

    • 工程结构与核心组件
    • 中心设备:连接事件监听与设备列表
    • 外设交互:服务发现与特征读写

中心设备:连接事件监听与设备列表

本文档解析 CoreBluetoothClassicSample 示例中中心设备(Central)角色的完整实现:CentralViewController 如何创建 CBCentralManager、按系统蓝牙状态启动连接事件监听、维护外设设备列表,并在用户点击列表项时发起 GATT-over-BR/EDR 连接。

Purpose and Scope

本页聚焦于 gatt-over-bredr-sample 目录下「中心设备」一侧的连接生命周期管理与设备列表呈现,覆盖以下内容:

  • CBCentralManager 的创建时机与委托(delegate)设置
  • 系统蓝牙状态机(CBManagerState)各分支的处理方式
  • 连接事件监听:registerForConnectionEvents(options:) 的注册与 connectionEventDidOccur 回调
  • 外设设备列表 cbPeripherals 的增删逻辑与 UITableView 渲染细节
  • 用户点击列表项后通过 connect(_:options:) 主动发起连接的完整链路

以下主题不属于本页范围,由同仓库其他页面/示例覆盖:

  • 外设端实现(Peripheral 广播、CBPeripheralManager):见 PeripheralViewController 相关文档
  • 连接建立后的 GATT 操作:服务发现(discoverServices)、特征读写、通知订阅,属于 GATT 服务与数据收发页面的范畴
  • App 生命周期与窗口配置:本示例的 AppDelegate 仅保留最小 window 属性,不参与蓝牙逻辑

Overview

本示例演示了 iOS 使用 CoreBluetooth 框架与经典蓝牙(BR/EDR)设备进行 GATT 通信的完整流程。作为链路的一端,中心设备承担三类职责:

  1. 感知系统蓝牙状态:CBCentralManager 初始化后异步回调 centralManagerDidUpdateState,应用必须针对 poweredOn / poweredOff / unauthorized / unsupported / resetting 等状态做出反应,其中 poweredOn 是唯一允许发起扫描、连接、注册连接事件的合法状态。
  2. 监听连接事件:通过 registerForConnectionEvents(options:) 注册系统级连接事件监听。与 didConnect/didDisconnectPeripheral 这类「本 app 主动发起连接」的回调不同,连接事件(CBConnectionEvent)可以感知其他进程(例如系统设置或其他 App)对目标外设发起的连接与断开,这是本示例标题中「连接事件监听」的核心语义。
  3. 维护并呈现设备列表:使用 cbPeripherals 数组缓存事件中出现的外设对象,由 UITableView 呈现;点击行即调用 CBCentralManager.connect(_:options:) 发起连接,成功后在 didConnect 中跳转到 PeripheralViewController 进入数据交互阶段。

设计要点:示例刻意将「监听」与「主动连接」两条路径分离——连接事件被动更新列表,点击事件主动发起连接,二者共用同一个 cbPeripherals 数据源,从而保证列表内容与真实连接状态一致。

Architecture

flowchart TD
    subgraph sg_UI["UI 层 (UIKit)"]
        CentralVC["CentralViewController"]
        TableView["UITableView<br/>(peripheralCell)"]
    end

    subgraph sg_Controller["蓝牙控制层 (CoreBluetooth)"]
        CBManager["CBCentralManager<br/>(delegate: CentralViewController)"]
        DeviceList["cbPeripherals: [CBPeripheral]"]
    end

    subgraph sg_System["系统蓝牙栈"]
        BTSystem["CoreBluetooth / 系统蓝牙服务"]
        RemoteDevice["远端外设 (BR/EDR GATT Server)"]
    end

    subgraph sg_PeripheralPage["外设交互页"]
        PeripheralVC["PeripheralViewController"]
    end

    CentralVC -->|"初始化 & 赋值 delegate"| CBManager
    CBManager -->|"centralManagerDidUpdateState"| CentralVC
    CBManager -->|"connectionEventDidOccur"| DeviceList
    CBManager -->|"didConnect / didDisconnect"| CentralVC
    TableView -->|"didSelectRowAt → connect"| CBManager
    CBManager -->|"系统连接"| BTSystem
    BTSystem <-->|"GATT over BR/EDR"| RemoteDevice
    CentralVC -->|"present 跳转"| PeripheralVC
    PeripheralVC -->|"持有 cbManager / selectedPeripheral"| CBManager

架构说明:

  • CentralViewController 同时扮演三个角色:视图控制器(UIViewController)、表格数据源(UITableViewDataSource)、表格代理(UITableViewDelegate)以及 CBCentralManagerDelegate。这种「多协议合体」在示例类中简化了接线,但代价是职责集中,扩展时需要按协议扩展拆分。
  • cbPeripherals 是连接事件与表格渲染之间的共享数据源:connectionEventDidOccur 负责写(append/remove),UITableViewDataSource 负责读,二者都在主线程执行,因此无需额外同步。
  • 设备列表仅由连接事件驱动(.peerConnected 追加、default 分支按身份移除),而非由扫描结果(didDiscover)驱动——本示例未实现扫描,列表中的设备来自系统级连接事件,这是理解整个页面行为的关键前提。
  • 连接成功后 CentralViewController 将 cbManager 与选中的 selectedPeripheral 注入 PeripheralViewController,后续 GATT 交互完全移交该页面,中心控制器不再干预。

相关源码:CentralViewController.swift、PeripheralViewController.swift

核心实现解析

1. 常量定义:GATT 服务与特征 UUID

struct BTConstants {
    static let sampleServiceUUID = CBUUID(string: "AE00")
    static let writeCharacteristicUUID = CBUUID(string: "AE01") // 用于写入数据
    static let readCharacteristicUUID = CBUUID(string: "AE02")  // 读取和通知数据
}

Source: CentralViewController.swift

BTConstants 是全局共享的协议常量表,被中心端与外设端共同引用:中心端用它作为连接事件匹配的服务 UUID(AE00),外设端 PeripheralViewController 在 discoverServices([BTConstants.sampleServiceUUID]) 中用它限定服务发现范围。AE01/AE02 两个特征 UUID 用于连接建立后的写入与读取/通知,属于 GATT 数据交互阶段。

2. 控制器状态与视图初始化

class CentralViewController: UIViewController {
    private var tableView: UITableView!
    private var cbManager: CBCentralManager!
    private var cbState = CBManagerState.unknown
    private var cbPeripherals = [CBPeripheral]()

    override func viewDidLoad() {
        super.viewDidLoad()
        title = "Peripherals"

        // 1. 创建 tableView
        tableView = UITableView()
        tableView.translatesAutoresizingMaskIntoConstraints = false
        tableView.dataSource = self
        tableView.delegate = self
        tableView.register(UITableViewCell.self, forCellReuseIdentifier: "peripheralCell")
        view.addSubview(tableView)

        // 2. 添加 Auto Layout 约束
        NSLayoutConstraint.activate([...])

        // 3. 初始化 CBCentralManager
        cbManager = CBCentralManager(delegate: self, queue: nil)
    }
}

Source: CentralViewController.swift

要点分析:

  • cbManager = CBCentralManager(delegate: self, queue: nil):queue: nil 表示委托回调在主队列派发。这直接保证了 connectionEventDidOccur、didConnect 等回调与 UITableView 的刷新(tableView.reloadData())运行在同一线程,避免数据竞争。这是示例中无需加锁的根本原因。
  • cbState 属性被声明但未在后续状态分支中使用(各分支直接读取回调参数 central.state),属于遗留的冗余状态,可作为清理项。
  • 表格复用标识符 "peripheralCell" 与 register(_:forCellReuseIdentifier:) 配对注册的是系统 UITableViewCell,仅使用 textLabel 展示外设名称,未做自定义单元格。

3. 系统蓝牙状态机处理(centralManagerDidUpdateState)

func centralManagerDidUpdateState(_ central: CBCentralManager) {
    switch central.state {
    case .resetting:
        os_log("与系统服务的连接暂时丢失,即将更新")
    case .unsupported:
        os_log("平台不支持蓝牙低能耗中心/客户端角色")
    case .unauthorized:
        switch central.authorization {
        case .restricted:
            os_log("此设备的蓝牙被限制使用")
        case .denied:
            os_log("该应用没有授权使用蓝牙低能耗角色")
        default:
            os_log("发生了未知错误,正在清理 cbManager")
        }
    case .poweredOff:
        os_log("蓝牙当前处于关闭状态")
    case .poweredOn:
        os_log("启动 cbManager")
        let matchingOptions = [CBConnectionEventMatchingOption.serviceUUIDs: [BTConstants.sampleServiceUUID]]
        cbManager.registerForConnectionEvents(options: matchingOptions)
    default:
        os_log("清理 cbManager")
    }
}

Source: CentralViewController.swift

设计意图:CoreBluetooth 要求 App 对 central.state 的每个取值都有明确行为。示例将所有分支收敛为日志输出(os_log),唯一的业务动作放在 .poweredOn——注册连接事件监听。注意 CBManagerState.unknown 落入 default 分支,对应系统服务刚启动时的过渡态。

authorization 子状态区分 .restricted(设备级限制,如 MDM 管控)与 .denied(用户拒绝应用授权),便于运维排查"无法连接"的根因。

4. 连接事件监听注册(registerForConnectionEvents)

let matchingOptions = [CBConnectionEventMatchingOption.serviceUUIDs: [BTConstants.sampleServiceUUID]]
cbManager.registerForConnectionEvents(options: matchingOptions)

Source: CentralViewController.swift

  • registerForConnectionEvents(options:) 是 iOS 13+ 提供的系统级连接事件订阅 API,使用 CBConnectionEventMatchingOption.serviceUUIDs 按服务 UUID(AE00)过滤事件来源,仅当蓝牙处于 .poweredOn 时注册。
  • 与 centralManager(_:didConnect:) 的区别:didConnect 只反映本 App 通过 connect(_:options:) 发起的连接结果;连接事件则能感知系统或其他进程建立/断开到匹配外设的连接(例如用户去系统设置里连接设备,或后台配对的耳机),这正是"连接事件监听"名称的由来。
  • 示例没有在 centralManagerDidUpdateState 中处理事件注销;iOS 会在状态离开 .poweredOn 时自动停止派发,应用无需显式注销。

5. 连接事件处理与设备列表增删(connectionEventDidOccur)

func centralManager(_ central: CBCentralManager, connectionEventDidOccur event: CBConnectionEvent, for peripheral: CBPeripheral) {
    switch event {
    case .peerConnected:
        os_log("peerConnected for peripheral: %@", peripheral)
        cbPeripherals.append(peripheral)
    case .peerDisconnected:
        os_log("peerDisconnected for peripheral:%@", peripheral)
    default:
        if let idx = cbPeripherals.firstIndex(where: { $0 === peripheral }) {
            cbPeripherals.remove(at: idx)
        }
    }
    tableView.reloadData()
}

Source: CentralViewController.swift

实现细节与边界:

  • .peerConnected 将外设追加到数组尾部;.peerDisconnected 仅记录日志(表意是"该设备断开连接");default 分支覆盖其余事件(如 .peerDisconnected 之外的其他 CBConnectionEvent 取值),按对象身份(===,引用相等)从数组中移除对应外设。
  • 用 === 而非 == 是刻意的:CBPeripheral 实例由系统复用/重建,引用相等能精确匹配"当前列表中的同一实例",避免误删名称相同的不同设备。
  • 每次事件后无条件 tableView.reloadData(),即使 default 分支未找到匹配项(firstIndex 返回 nil)也会刷新——轻微的性能浪费,但对示例规模无影响。

6. 设备列表呈现(UITableViewDataSource / Delegate)

func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
    return cbPeripherals.count
}

func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    let cell = tableView.dequeueReusableCell(withIdentifier: "peripheralCell", for: indexPath)
    let index = cbPeripherals.count - (indexPath.row + 1)
    cell.textLabel?.text = "\(cbPeripherals[index].name ?? "CBPeripheral")"
    return cell
}

func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
    let peripheral = cbPeripherals[indexPath.row]
    cbManager.connect(peripheral, options: nil)
}

Source: CentralViewController.swift

值得注意的倒序展示逻辑:cellForRowAt 用 count - (row + 1) 计算索引,使最新加入(数组尾部)的设备显示在列表最顶端,符合"最近连接事件优先可见"的 UX 意图。但 didSelectRowAt 直接使用 indexPath.row 访问数组——与展示索引不一致:当列表长度 > 1 时,点击第 0 行实际选中的是数组第 0 个(最早加入)设备,与用户看到的"最新设备"错位。这是一个真实的潜在缺陷,扩展时建议统一索引换算(例如用 count - (row + 1) 或改为正序展示)。

7. 连接结果回调与页面跳转

func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
    os_log("peripheral: %@ connected", peripheral)
    let peripheralVC = PeripheralViewController()
    peripheralVC.cbManager = cbManager
    peripheralVC.selectedPeripheral = peripheral
    present(peripheralVC, animated: true, completion: nil)
}

func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) {
    os_log("peripheral: %@ failed to connect", peripheral)
}

func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) {
    os_log("peripheral: %@ disconnected", peripheral)
}

Source: CentralViewController.swift

  • didConnect 中采用构造函数注入方式把 cbManager 与 selectedPeripheral 交给 PeripheralViewController;后者在 viewDidLoad 中 discoverServices([BTConstants.sampleServiceUUID]),进入 GATT 服务发现阶段(见 PeripheralViewController.swift)。
  • didFailToConnect 与 didDisconnectPeripheral 目前仅记录日志,未做 UI 提示或列表状态更新——生产环境通常需要在断开时同步刷新设备列表状态(如标记"已断开"或从列表移除)。

核心流程

启动与状态就绪

sequenceDiagram
    participant App as CentralViewController
    participant CB as CBCentralManager
    participant OS as 系统蓝牙栈
    participant EV as 连接事件源 (系统/其他进程)

    App->>CB: CBCentralManager(delegate: self, queue: nil)
    CB-->>App: centralManagerDidUpdateState(.poweredOn)
    App->>CB: registerForConnectionEvents(serviceUUIDs: [AE00])
    EV-->>OS: 外设 AE00 被连接/断开
    OS-->>CB: 派发 CBConnectionEvent
    CB-->>App: connectionEventDidOccur(.peerConnected)
    App->>App: cbPeripherals.append / remove
    App->>App: tableView.reloadData()

主动连接链路

sequenceDiagram
    participant U as 用户
    participant TV as UITableView
    participant App as CentralViewController
    participant CB as CBCentralManager
    participant P as PeripheralViewController

    U->>TV: 点击列表行
    TV->>App: didSelectRowAt(indexPath)
    App->>CB: connect(peripheral, options: nil)
    CB-->>App: didConnect(peripheral)
    App->>P: 注入 cbManager + selectedPeripheral
    App->>P: present(animated: true)
    P->>P: discoverServices([AE00])

两条路径的关系:连接事件路径(被动、系统驱动)负责填充与校正设备列表;主动连接路径(用户驱动)负责发起连接并移交交互页。二者以 cbPeripherals 为共享数据源,事件路径的增删保证列表始终反映真实的系统连接状态,点击路径则把列表项转化为一次实际的连接尝试。

使用示例

示例 1:注册系统连接事件监听(仅 poweredOn 时)

case .poweredOn:
    os_log("启动 cbManager")
    let matchingOptions = [CBConnectionEventMatchingOption.serviceUUIDs: [BTConstants.sampleServiceUUID]]
    cbManager.registerForConnectionEvents(options: matchingOptions)

Source: CentralViewController.swift

用途:订阅系统级连接事件,使设备列表能感知其他进程对匹配外设的连接/断开,而不仅限于本 App 发起的连接。

示例 2:按引用身份从列表中移除设备

default:
    if let idx = cbPeripherals.firstIndex(where: { $0 === peripheral }) {
        cbPeripherals.remove(at: idx)
    }

Source: CentralViewController.swift

用途:以对象身份(===)精确匹配列表中的外设实例并移除,避免名称相同的外设被误删。

示例 3:连接成功后的页面移交

func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
    os_log("peripheral: %@ connected", peripheral)
    let peripheralVC = PeripheralViewController()
    peripheralVC.cbManager = cbManager
    peripheralVC.selectedPeripheral = peripheral
    present(peripheralVC, animated: true, completion: nil)
}

Source: CentralViewController.swift

用途:连接成功后立即切换到 PeripheralViewController,将 cbManager 与外设实例注入,由该页面执行 discoverServices 及后续 GATT 交互。

配置选项

本页面的可配置项全部集中在 BTConstants 常量表中,无外部配置文件:

常量类型默认值说明
sampleServiceUUIDCBUUID"AE00"示例 GATT 服务 UUID,用于连接事件匹配与 discoverServices 过滤
writeCharacteristicUUIDCBUUID"AE01"写入特征 UUID(连接后向外设写数据)
readCharacteristicUUIDCBUUID"AE02"读取/通知特征 UUID(接收外设数据)

其他隐式配置:CBCentralManager(queue: nil) 使用主队列派发回调;registerForConnectionEvents 的匹配选项为 [.serviceUUIDs: [AE00]];表格复用标识符固定为 "peripheralCell"。

API 参考

CBCentralManager(delegate:queue:)

  • 参数:delegate 为 CBCentralManagerDelegate?,示例传入 self;queue 为 DispatchQueue?,传入 nil 表示主队列。
  • 说明:初始化后异步回调 centralManagerDidUpdateState,在此之前不应调用任何扫描/连接/注册 API。

registerForConnectionEvents(options:)

  • 参数:options 为 [CBConnectionEventMatchingOption : Any],示例仅使用 serviceUUIDs 键,值为 [CBUUID] 数组。
  • 说明:注册后,系统在匹配外设发生连接事件时回调 centralManager(_:connectionEventDidOccur:for:);仅在 .poweredOn 状态下调用有效。

centralManager(_:connectionEventDidOccur:for:)

  • 参数:event(CBConnectionEvent,.peerConnected 或 .peerDisconnected)、peripheral(CBPeripheral)。
  • 行为:.peerConnected 追加到 cbPeripherals;其余事件按引用相等移除;随后 reloadData()。

connect(_:options:)

  • 参数:peripheral(CBPeripheral)、options([String : Any]?,示例传 nil)。
  • 结果:异步回调 didConnect、didFailToConnect 或 didDisconnectPeripheral。

centralManager(_:didConnect:) / didFailToConnect / didDisconnectPeripheral

  • 参数:central(CBCentralManager)、peripheral(CBPeripheral)、可选 error(Error?)。
  • 行为:didConnect 中创建并注入 PeripheralViewController 后 present;失败与断开仅记录日志。

失败模式、边界情况与并发

状态与授权失败

  • 蓝牙未开启(.poweredOff):仅日志记录,registerForConnectionEvents 不会执行,列表保持为空。生产实现应在此处提示用户开启蓝牙并禁用交互入口。
  • 授权被拒(.unauthorized + .denied):用户拒绝 NSBluetoothAlwaysUsageDescription 对应的权限,任何连接操作都会静默失败。示例仅记日志,未引导用户前往设置页。
  • 设备级限制(.unauthorized + .restricted):如家长控制或 MDM 策略,应用无法自行解除,只能提示。
  • 平台不支持(.unsupported):多见于不支持蓝牙低能耗的模拟器/旧设备。
  • .resetting:系统服务临时中断,回调可能随状态恢复再次触发,应用不应在此时清理已缓存的外设引用(系统会复用实例)。

列表一致性与数据边界

  • 倒序索引不一致(潜在缺陷):cellForRowAt 使用 count - (row + 1) 倒序取数,而 didSelectRowAt 直接用 indexPath.row 正序取数。当列表含多个设备时,点击展示行与连接的目标外设可能错位。修复方向:点击回调同样换算索引,或统一为正序展示。
  • 重复添加:cbPeripherals.append 未做去重;若同一外设在连接事件中重复上报 .peerConnected(例如先断开再连接),列表会出现重复行。由于 CBPeripheral 名称可为 nil(cell 显示为 "CBPeripheral"),重复行难以从 UI 分辨。
  • 未匹配移除:default 分支 firstIndex(where:) 未命中时静默跳过,列表可能残留已失效设备,仅在下次同设备事件时被清理。

并发与线程

  • 单一主线程模型:CBCentralManager(queue: nil) 与 UIKit 回调均在主队列执行,cbPeripherals 的读写天然串行,无数据竞争。若将 queue 改为后台队列,则 reloadData() 与数组访问都需要切换到主线程,示例未做此类处理,扩展时需注意。
  • 事件风暴:系统级连接事件可能在短时间内密集到达(多设备同时配对),每次事件都触发 reloadData(),在设备数量大时存在 UI 抖动与性能开销。可考虑用 reloadRows 局部刷新或合并刷新(DispatchQueue.main.async 合并)。

性能与运维注意

  • reloadData() 全量刷新表格,设备列表规模小时无感知;若扩展为长列表,建议改为增量更新(insertRows/deleteRows)。
  • os_log 统一使用系统统一日志,可在 Console.app 中按子系统过滤观察蓝牙状态流转与连接事件,是排查"列表不更新/连接无反应"的首选手段。
  • 蓝牙状态从 .poweredOff 恢复到 .poweredOn 时,centralManagerDidUpdateState 会再次回调并重新注册连接事件;cbPeripherals 不会被清空,设备在恢复前的连接事件可能已丢失,导致列表与实际状态短暂不一致。

扩展点

  1. 加入扫描流程:当前列表完全由连接事件驱动。若要支持"发现并连接任意设备",可在 .poweredOn 分支增加 scanForPeripherals(withServices:) 并实现 centralManager(_:didDiscover:advertisementData:rssi:),将发现的外设并入同一列表。
  2. 断开管理:在 didDisconnectPeripheral 中同步更新 cbPeripherals 或外设连接状态标记,实现"断开即移除/置灰"。
  3. 自定义单元格:将 "peripheralCell" 替换为自定义 UITableViewCell,展示外设名称、UUID、RSSI、连接状态等多列信息。
  4. 错误用户提示:为 .poweredOff、.unauthorized、didFailToConnect 增加 UIAlert 提示,替代仅日志输出。
  5. 特征交互层抽象:PeripheralViewController 直接内联 writeValue/setNotifyValue 等操作,可抽象为 PeripheralService 类,便于复用与单元测试。

测试

仓库未包含针对 CentralViewController 的单元/UI 测试文件。可观察到的可测行为包括:

  • centralManagerDidUpdateState(.poweredOn) 后应调用 registerForConnectionEvents(可用 CBCentralManager 的 mock 子类验证)。
  • connectionEventDidOccur(.peerConnected) 后 cbPeripherals.count 增 1、reloadData 被调用;default 事件后按引用移除。
  • didSelectRowAt 触发 connect(_:options:) 调用(注意索引换算缺陷的回归用例)。
  • didConnect 后 PeripheralViewController 的 selectedPeripheral 与 cbManager 注入正确。

Related Links

  • 外设交互页:PeripheralViewController — 连接建立后的服务发现与 GATT 数据交互(写特征、读特征、通知),属「GATT 服务与数据收发」页面范畴
  • AppDelegate.swift — 最小化 App 入口,不含蓝牙逻辑
  • 示例根目录:UsingCoreBluetoothClassic — 示例工程结构
Prev
工程结构与核心组件
Next
外设交互:服务发现与特征读写