在iOS平台上借助Core Bluetooth把一台iPhone或者iPad模拟成蓝牙遥控器,本质是利用BLE的GATT层对外暴露Human Interface Device服务。系统或其他主机设备扫描到该服务后,会按照HID规范解析报告描述符,从而把来自iOS设备的按键事件识别为键盘、消费类设备或者媒体控制指令。与直接做中心设备去连接现有遥控器不同,外设模拟要求开发者自己维护服务、特征值以及广播数据,并且正确处理连接参数与安全配对。

Core Bluetooth外设角色与HID服务搭建
使用Core Bluetooth扮演外设时,需要引入CBPeripheralManager并创建对应的CBMutableService。HID服务在蓝牙SIG中分配了固定的16位UUID,即0x1812,我们必须在服务列表中加入它,同时附带必备的特征值:Report Map、Report、Protocol Mode以及HID Information。Report Map特征值里存放的是HID报告描述符,这段数据采用二进制格式描述设备支持哪些按键、每个按键对应多少位的扫描码、报告长度是多少。很多初学者只放了Report特征值却忘记Report Map,导致主机设备识别成未知外设。
下面代码展示如何用Swift初始化一个最小可用的HID外设管理器,并添加基础服务骨架。注意Report Map的内容在真实项目中应根据USB HID Usage Tables生成,这里仅给出结构示例。
import CoreBluetooth
class HIDPeripheral: NSObject, CBPeripheralManagerDelegate {
private var manager: CBPeripheralManager!
private var hidService: CBMutableService!
override init() {
super.init()
manager = CBPeripheralManager(delegate: self, queue: nil)
}
func peripheralManagerDidUpdateState(_ peripheral: CBPeripheralManager) {
if peripheral.state == .poweredOn {
let serviceUUID = CBUUID(string: "1812")
hidService = CBMutableService(type: serviceUUID, primary: true)
let reportMapChar = CBMutableCharacteristic(
type: CBUUID(string: "2A4B"),
properties: [.read],
value: hidReportMapData(),
permissions: [.readable]
)
let reportChar = CBMutableCharacteristic(
type: CBUUID(string: "2A4D"),
properties: [.read, .notify],
value: nil,
permissions: [.readable]
)
hidService.characteristics = [reportMapChar, reportChar]
manager.add(hidService)
}
}
private func hidReportMapData() -> Data {
// 简化的HID报告描述符,真实项目需按规范拼接
return Data([0x05, 0x01, 0x09, 0x06, 0xA1, 0x01, 0x05, 0x07,
0x19, 0xE0, 0x29, 0xE7, 0x15, 0x00, 0x25, 0x01])
}
}
在广播阶段,应将HID服务的UUID放入CBAdvertisementDataServiceUUIDsKey,否则主机可能仅看到通用设备名而不主动查询HID能力。另外,iOS对后台外设广播有一定限制,若应用进入后台,广播间隔会变长甚至暂停,因此遥控器类应用通常需要申请特定的蓝牙后台模式,并在Info.plist中声明bluetooth-peripheral。即便如此,长时间无连接也可能被系统回收资源,所以还要设计重连与状态恢复逻辑。
按键扫描码映射与HID报告构造
HID协议并不直接传输“字母A”或“音量加”,而是传输Usage ID与Usage Page组成的扫描码。例如键盘页(0x01)下的按键A对应的Usage ID是0x04,而消费页(0x0C)下的播放暂停对应0xCD。我们在iOS端检测到屏幕按钮或者外接按键事件后,要把业务语义翻译成对应的扫描码,再填充进Report特征值所定义的报文格式中。典型的键盘报告包含1字节修饰键状态加若干字节按键数组,若只控制少量媒体键,也可以采用消费类输入报告,长度更短。
下面示例展示如何把一个自定义的“上方向”和“确认”键映射为消费类设备的Usage,并构造长度为2字节的报告数据。第一字节为Usage Page偏移,第二字节为具体Usage ID,实际项目应和Report Map中声明的一致。
func buildConsumerReport(for usageID: UInt8) -> Data {
// 消费页报告:第1字节保留,第2字节为Usage ID
var bytes: [UInt8] = [0x00, usageID]
return Data(bytes)
}
// 示例:音量加对应的Usage ID为0xE9
let volumeUpReport = buildConsumerReport(for: 0xE9)
// 通过已建立的Report特征值发送通知
if let char = reportCharacteristic {
peripheralManager.updateValue(volumeUpReport,
for: char,
onSubscribedCentrals: nil)
}
扫描码映射层的另一个问题是重复按键与组合键。当用户按住方向键移动焦点时,系统期望收到周期性的重复报告,而不是只发一次。我们可以在定时器里持续发送相同扫描码,直到检测到松开事件再发全零报告。组合键则要求在修饰字节里置位对应bit,比如Shift对应0x02,此时再发按键扫描码就能被主机解释为带修饰的输入。若映射表设计混乱,会出现某些Android电视盒不识别、macOS识别为不同功能等情况,因此建议集中维护一张语义到扫描码的对照表,并在真机上覆盖测试。
长按短按识别的状态机与去抖处理
物理遥控或屏幕遥控都会面临长短按区分。最简单做法是记录touchDown时间戳,在touchUp时计算差值,小于300毫秒视为短按,否则为长按。但在BLE遥控场景,这种方式容易受事件丢失和抖动影响,比如系统合并触摸事件、蓝牙通知延迟导致松开报告晚到,都会让时长统计失真。更稳健的方案是在应用层引入状态机:空闲、按下待确认、短按已发、长按触发、释放中,每个状态切换都结合硬件去抖窗口与软件计时。
以下代码给出一个基于状态机的识别逻辑,去抖窗口设为50毫秒,长按阈值为600毫秒,短按在释放后确认,长按则在达到阈值时立即发送一次并标记,避免重复。
enum KeyState {
case idle
case pressed(debounceStart: Date)
case shortConfirmed
case longTriggered
}
class KeyDetector {
private var state: KeyState = .idle
private let debounce: TimeInterval = 0.05
private let longThreshold: TimeInterval = 0.6
private var longTimer: Timer?
func onDown() {
guard case .idle = state else { return }
state = .pressed(debounceStart: Date())
longTimer = Timer.scheduledTimer(withTimeInterval: longThreshold,
repeats: false) { [weak self] _ in
self?.fireLong()
}
}
func onUp() {
if case let .pressed(start) = state {
let cost = Date().timeIntervalSince(start)
longTimer?.invalidate()
if cost >= debounce {
if cost < longThreshold {
state = .shortConfirmed
sendShort()
}
}
state = .idle
} else if case .longTriggered = state {
state = .idle
}
}
private func fireLong() {
state = .longTriggered
sendLong()
}
private func sendShort() { /* 发短按扫描码报告 */ }
private func sendLong() { /* 发长按扫描码报告 */ }
}
在实际遥控器固件或iOS模拟器中,按键信号可能存在接触抖动,即在几毫秒内出现多次通断。上面的去抖窗口会忽略过短的按下,避免误判为连击。对于需要区分“单击”“双击”“长按”的复杂遥控,还可以扩展状态机,引入上次释放时间与序列计数器。由于BLE连接本身有间隔,发送报告最好合并到同一连接事件里,减少因分包造成的时序错位。经过这样分层处理后,用户按下的每一次交互都能稳定映射为符合HID规范的扫描码与长短按语义。
配对安全与跨平台兼容要点
当iOS设备作为HID外设时,主机如电视或电脑通常会发起安全配对,以防范中间人攻击。Core Bluetooth在iPhone上并不允许第三方应用自定义配对PIN,而是依赖系统弹窗与用户确认。开发者需要确保HID服务声明了正确的安全需求,比如在GATT层级将Report特征值权限设为加密可读,否则部分主机拒绝绑定。另一个坑是部分Android设备对Report Map的解析较为严格,描述符里若缺少结束集合标记会导致整个键盘失效。
跨平台兼容还体现在扫描码页的选择上。Windows与macOS对消费页的媒体键支持较好,但某些智能电视只认键盘页的特定Usage。如果遇到按键无响应,可以先用通用键盘报告发送测试扫描码,确认链路通后再切到精简的消费页报告。通过抓包工具观察主机下发的Get Report请求,也能快速定位是描述符问题还是iOS端未响应读请求的问题。把这些细节在开发期做成自检清单,能显著降低上线后用户配对失败的反馈。
Core_BluetoothHID按键扫描码修改时间:2026-08-18 20:22:53