中心设备:连接事件监听与设备列表
本文档解析 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 通信的完整流程。作为链路的一端,中心设备承担三类职责:
- 感知系统蓝牙状态:
CBCentralManager初始化后异步回调centralManagerDidUpdateState,应用必须针对poweredOn/poweredOff/unauthorized/unsupported/resetting等状态做出反应,其中poweredOn是唯一允许发起扫描、连接、注册连接事件的合法状态。 - 监听连接事件:通过
registerForConnectionEvents(options:)注册系统级连接事件监听。与didConnect/didDisconnectPeripheral这类「本 app 主动发起连接」的回调不同,连接事件(CBConnectionEvent)可以感知其他进程(例如系统设置或其他 App)对目标外设发起的连接与断开,这是本示例标题中「连接事件监听」的核心语义。 - 维护并呈现设备列表:使用
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 常量表中,无外部配置文件:
| 常量 | 类型 | 默认值 | 说明 |
|---|---|---|---|
sampleServiceUUID | CBUUID | "AE00" | 示例 GATT 服务 UUID,用于连接事件匹配与 discoverServices 过滤 |
writeCharacteristicUUID | CBUUID | "AE01" | 写入特征 UUID(连接后向外设写数据) |
readCharacteristicUUID | CBUUID | "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不会被清空,设备在恢复前的连接事件可能已丢失,导致列表与实际状态短暂不一致。
扩展点
- 加入扫描流程:当前列表完全由连接事件驱动。若要支持"发现并连接任意设备",可在
.poweredOn分支增加scanForPeripherals(withServices:)并实现centralManager(_:didDiscover:advertisementData:rssi:),将发现的外设并入同一列表。 - 断开管理:在
didDisconnectPeripheral中同步更新cbPeripherals或外设连接状态标记,实现"断开即移除/置灰"。 - 自定义单元格:将
"peripheralCell"替换为自定义UITableViewCell,展示外设名称、UUID、RSSI、连接状态等多列信息。 - 错误用户提示:为
.poweredOff、.unauthorized、didFailToConnect增加 UIAlert 提示,替代仅日志输出。 - 特征交互层抽象:
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 — 示例工程结构