把安卓手机变成蓝牙虚拟鼠标和键盘(BluetoothHidDevice 实战)
项目地址:https://gitee.com/vivowushi/air-mouse
一、先看效果
最终成品是一个叫 AirMouse 的 App:
- 手机通过蓝牙被电脑识别为 "鼠标 + 键盘 + 多媒体控制"三合一 的 HID 设备
- 电脑端不需要装任何软件、不需要装驱动,Windows / macOS / Linux / 平板都能用
- 一键切换鼠标模式和键盘模式
手机端界面长这样:
- 鼠标模式:一只虚拟鼠标,手指按住左键区/右键区/滚轮区,分别对应真鼠标的左键、右键、滚轮;机身区域用来移动光标和拖拽
- 键盘模式:完整 QWERTY 五行键盘,带一次性修饰键、快捷键面板、多媒体键,横竖屏自适应
技术上没有任何取巧:完全基于 Android 系统级的 BluetoothHidDevice API,让手机反向扮演 蓝牙 HID 外设。


二、原理:手机怎么"变成"外设
平时我们习惯把手机连电脑,手机是 Central(中心设备) ,电脑是 Peripheral(从设备) 。但蓝牙规范里角色是可逆的,这个 App 做的事情是让手机切换成 Peripheral,对外声明"HID 设备"服务,电脑来连接它。
Android 为此提供了 BluetoothHidDevice(API 28+):
kotlin
val hidDevice: BluetoothHidDevice? =
context.getSystemService(BluetoothManager::class.java)?.hidDevice
拿到之后分两步:
第一步:注册应用,声明自己是什么设备
kotlin
val settings = BluetoothHidDeviceAppSdpSettings(
"AirMouse", // SDP 记录里的设备名
"AirMouse Bluetooth Mouse & Keyboard", // 描述
"YourCompany",
BluetoothHidDevice.SUBCLASS1_COMBO, // 复合 HID:鼠标+键盘+消费控制
HidDescriptor.combined // ← 重中之重:HID 报告描述符
)
val ok = hidDevice.registerApp(settings, null, null, mainExecutor, callback)
registerApp() 成功之后,系统才会向蓝牙栈注册一条 SDP 记录 (服务的"名片"),里面包含设备名、支持的服务、以及完整的 HID 报告描述符。此时电脑才能发现并连接这个 HID 设备。
第二步:连接并发送报文
kotlin
hidDevice.connect(device) // 等待 onConnectionChanged 回调
// 发一条鼠标移动报文
hidDevice.sendReport(
device,
HidDescriptor.REPORT_ID_MOUSE,
byteArrayOf(0, dx.toByte(), dy.toByte(), 0) // 按键状态, X, Y, 滚轮
)
Android 要求:所有报文都必须带 Report ID,因为一个 HID 设备可以同时有多种报表类型,主机靠 Report ID 区分。
整个交互流程可以概括为下面这张时序图:
电脑(Central / HID Host) 手机(Peripheral / HID 设备) 电脑(Central / HID Host) 手机(Peripheral / HID 设备) #mermaid-svg-DIm1tNCnPEscu1U1{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-DIm1tNCnPEscu1U1 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-DIm1tNCnPEscu1U1 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-DIm1tNCnPEscu1U1 .error-icon{fill:#552222;}#mermaid-svg-DIm1tNCnPEscu1U1 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-DIm1tNCnPEscu1U1 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-DIm1tNCnPEscu1U1 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-DIm1tNCnPEscu1U1 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-DIm1tNCnPEscu1U1 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-DIm1tNCnPEscu1U1 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-DIm1tNCnPEscu1U1 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-DIm1tNCnPEscu1U1 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-DIm1tNCnPEscu1U1 .marker.cross{stroke:#333333;}#mermaid-svg-DIm1tNCnPEscu1U1 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-DIm1tNCnPEscu1U1 p{margin:0;}#mermaid-svg-DIm1tNCnPEscu1U1 .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-DIm1tNCnPEscu1U1 text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-DIm1tNCnPEscu1U1 .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-DIm1tNCnPEscu1U1 .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-DIm1tNCnPEscu1U1 .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-DIm1tNCnPEscu1U1 .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-DIm1tNCnPEscu1U1 #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-DIm1tNCnPEscu1U1 .sequenceNumber{fill:white;}#mermaid-svg-DIm1tNCnPEscu1U1 #sequencenumber{fill:#333;}#mermaid-svg-DIm1tNCnPEscu1U1 #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-DIm1tNCnPEscu1U1 .messageText{fill:#333;stroke:none;}#mermaid-svg-DIm1tNCnPEscu1U1 .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-DIm1tNCnPEscu1U1 .labelText,#mermaid-svg-DIm1tNCnPEscu1U1 .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-DIm1tNCnPEscu1U1 .loopText,#mermaid-svg-DIm1tNCnPEscu1U1 .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-DIm1tNCnPEscu1U1 .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-DIm1tNCnPEscu1U1 .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-DIm1tNCnPEscu1U1 .noteText,#mermaid-svg-DIm1tNCnPEscu1U1 .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-DIm1tNCnPEscu1U1 .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-DIm1tNCnPEscu1U1 .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-DIm1tNCnPEscu1U1 .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-DIm1tNCnPEscu1U1 .actorPopupMenu{position:absolute;}#mermaid-svg-DIm1tNCnPEscu1U1 .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-DIm1tNCnPEscu1U1 .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-DIm1tNCnPEscu1U1 .actor-man circle,#mermaid-svg-DIm1tNCnPEscu1U1 line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-DIm1tNCnPEscu1U1 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} registerApp() 注册 HID 应用 loop 用户操作 注册 SDP 记录(含 HID 报告描述符) 1 扫描发现设备,发起配对 2 SDP 查询服务与报告描述符 3 返回设备名 / 服务 / 描述符 4 缓存描述符到设备驱动 5 connect() 建立 HID 通道 6 onConnectionChanged 回调(已连接) 7 sendReport(Report ID + 报文) 8 解析并执行(移动 / 按键 / 多媒体) 9
几个关键点:
registerApp()是第一步:它把 HID 报告描述符写进 SDP 记录,电脑只有在拿到这条记录后才知道"这是个键鼠设备、报文长什么样",也才能发起配对。- 描述符在配对那一刻被缓存:电脑通过 SDP 读取一次后就不再刷新,所以改完描述符必须让主机"删除设备后重新配对",否则改动不生效。
connect()建立的是 HID 通道 :系统设置里的"已连接"只是 ACL 链路,不等于 HID 已就绪;之后所有sendReport都带 Report ID,主机据此区分鼠标、键盘和多媒体报文。
三、核心:一个描述符搞定鼠标+键盘
HID 报告描述符(HID Report Descriptor)是一段二进制,它告诉主机:"我这几个报文分别是什么意思,每个字节是什么含义"。这是整个 HID 体系里最核心也最容易出错的地方。
AirMouse 用一个描述符声明了三种报表:
| Report ID | 类型 | 长度 | 内容 |
|---|---|---|---|
| 1 | 鼠标 | 4 字节 | 3 键 + X/Y 相对移动 + 滚轮 |
| 2 | 键盘 | 8 字节 | 8 位修饰键 + 保留字节 + 6 键无冲数组 + LED 输出 |
| 3 | 消费者控制 | 2 字节 | 16 位 Usage 的多媒体键 |
对应到"电脑设备管理器"里,它会显示为一个带复合功能键鼠的 HID 设备。
描述符源码(节选)
kotlin
object HidDescriptor {
const val REPORT_ID_MOUSE = 1
const val REPORT_ID_KEYBOARD = 2
const val REPORT_ID_CONSUMER = 3
val combined: ByteArray = bytes(
// ===== Report ID 1 · 鼠标 =====
0x05, 0x01, // Usage Page (Generic Desktop)
0x09, 0x02, // Usage (Mouse)
0xA1, 0x01, // Collection (Application)
0x85, 0x01, // Report ID (1)
0x09, 0x01, // Usage (Pointer)
0xA1, 0x00, // Collection (Physical)
0x05, 0x09, // Usage Page (Button)
0x19, 0x01, // Usage Minimum (Button 1)
0x29, 0x03, // Usage Maximum (Button 3)
0x15, 0x00, // Logical Minimum (0)
0x25, 0x01, // Logical Maximum (1)
0x95, 0x03, // Report Count (3)
0x75, 0x01, // Report Size (1)
0x81, 0x02, // Input (Data, Variable, Absolute) --- 3 个按键位
0x95, 0x01, // Report Count (1)
0x75, 0x05, // Report Size (5)
0x81, 0x03, // Input (Constant) --- 补齐到 1 字节
0x05, 0x01, // Usage Page (Generic Desktop)
0x09, 0x30, // Usage (X)
0x09, 0x31, // Usage (Y)
0x09, 0x38, // Usage (Wheel)
0x15, 0x81, // Logical Minimum (-127)
0x25, 0x7F, // Logical Maximum (127)
0x75, 0x08, // Report Size (8)
0x95, 0x03, // Report Count (3)
0x81, 0x06, // Input (Data, Variable, Relative) --- 相对移动
0xC0, // End Collection
0xC0, // End Collection
// ===== Report ID 2 · 键盘 =====
0x05, 0x01, // Usage Page (Generic Desktop)
0x09, 0x06, // Usage (Keyboard)
0xA1, 0x01, // Collection (Application)
0x85, 0x02, // Report ID (2)
0x05, 0x07, // Usage Page (Keyboard/Keypad)
0x19, 0xE0, // Usage Minimum (Left Control)
0x29, 0xE7, // Usage Maximum (Right GUI)
0x15, 0x00, 0x25, 0x01, 0x75, 0x01,
0x95, 0x08, // Report Count (8) --- 8 个修饰键位
0x81, 0x02, // Input (Data, Variable, Absolute)
0x95, 0x01, 0x75, 0x08,
0x81, 0x01, // Input (Constant) --- 保留字节
0x05, 0x08, // Usage Page (LEDs) ← 注意!用法页切到 LED 了
0x19, 0x01, 0x29, 0x05,
0x95, 0x05, 0x75, 0x01,
0x91, 0x02, // Output (Data, Variable, Absolute) --- LED 状态
0x95, 0x01, 0x75, 0x03,
0x91, 0x03, // Output (Constant) --- LED 填充位
0x05, 0x07, // Usage Page (Keyboard/Keypad) ← 【关键】切回键盘页!
0x95, 0x06, // Report Count (6)
0x75, 0x08, // Report Size (8)
0x15, 0x00, 0x25, 0x65,
0x19, 0x00, 0x29, 0x65,
0x81, 0x00, // Input (Data, Array) --- 6 键无冲数组
0xC0,
// ===== Report ID 3 · 多媒体(Consumer Control)=====
0x05, 0x0C, // Usage Page (Consumer)
0x09, 0x01, // Usage (Consumer Control)
0xA1, 0x01, // Collection (Application)
0x85, 0x03, // Report ID (3)
0x15, 0x00,
0x26, 0x3C, 0x02, // Logical Maximum (0x023C)
0x19, 0x00, 0x2A, 0x3C, 0x02,
0x75, 0x10, // Report Size (16)
0x95, 0x01, // Report Count (1)
0x81, 0x00, // Input (Data, Array) --- 16 位 Usage
0xC0
)
}
对应的键盘报文就是标准的 8 字节 Boot Keyboard 格式:
kotlin
private fun sendKeyboard(hid: BluetoothHidDevice, dev: BluetoothDevice, mods: Int, usage: Int) {
hid.sendReport(
dev,
HidDescriptor.REPORT_ID_KEYBOARD,
byteArrayOf(mods.toByte(), 0, usage.toByte(), 0, 0, 0, 0, 0)
)
}
按下再松开,就是一次完整的击键:
kotlin
fun keyStroke(usage: Int, mods: Int = Mod.NONE) {
if (!isConnected || usage == 0) return
enqueue { hid, dev ->
sendKeyboard(hid, dev, mods, usage) // 按下
Thread.sleep(25) // 主机需要 ≥ 一定间隔才能识别
sendKeyboard(hid, dev, mods, 0) // 松开(usage 归零)
}
}
四、踩坑记录(本文精华)
坑 1:Usage Page 没切回来,键盘完全失效
这是最难查的一个 bug,症状是:光标能动、键盘一点反应都没有。
出问题的描述符长这样:
kotlin
0x91, 0x03, // Output (Constant) --- LED 填充位
0x95, 0x06, // Report Count (6) ← 忘了切回键盘页!
0x75, 0x08,
0x15, 0x00, 0x25, 0x65,
0x19, 0x00, 0x29, 0x65,
0x81, 0x00, // Input (Data, Array) --- 6 键数组
问题在于:HID 的 Usage Page 是有状态的、持续生效的 。前面声明 LED 输出时执行了 0x05, 0x08 切到了"LED 页",之后没有切回来,于是 6 键数组被主机解析成 LED 用量 。而 LED 页只有 0x01(NumLock) ~ 0x05(Kana) 这几个有效值,按 'a'(0x04)、按空格(0x2C) 全都不存在 → 主机直接丢弃所有按键数据。
修复就是在 6 键数组前补一句:
kotlin
0x05, 0x07, // Usage Page (Keyboard/Keypad) ← 切回键盘页
0x95, 0x06,
经验法则 :HID 描述符里,切换 Usage Page 后要形成"进出配对",进去多少个 Item 就要成对切出来。写完最好用 hid-tools 或 USB Descriptor Tool 解析校验一遍。
坑 2:描述符被主机缓存,改了代码也不生效
修完 Usage Page,重新编译、安装,手机上一切正常------但键盘还是打不出字。
这是 HID 开发里第二个大坑:Windows(macOS 也有类似行为)在配对的那一刻通过 SDP 读取 HID 报告描述符,然后缓存进设备驱动,此后永不刷新。
于是就出现了极具迷惑性的现象:
- 主机缓存的是旧描述符(有 Usage Page bug 的那份)
- 鼠标的 Report ID 1 在新旧描述符里都是对的 → 光标正常移动 ✅
- 键盘的 Report ID 2 只有新版才是对的 → 按键全部被丢弃 ❌
"鼠标好的、键盘坏的"这种分裂现象,恰恰是描述符被缓存的铁证------如果描述符本身整体写错,通常鼠标也会跟着坏。
解决办法:必须在主机端删除设备后重新配对
⚠️ 只在 App 里点"断开/重新连接"是无效的,必须:
- 在电脑的蓝牙设置里删除这个设备
- 手机 App 重新扫描、重新连接(触发一次全新的配对)
由此提炼一条开发铁律:
只要改动了 HID 报告描述符,就必须让所有已配对过的主机"删除设备后重新配对",否则改动 100% 不生效。
AirMouse 后来把这个提示直接做进了 App 的连接面板里,避免用户和开发者反复踩坑。
坑 3:registerApp 失败------HID 槽位是全局唯一的
HID 应用注册失败:可能已被其它应用占用
Android 系统全局只允许一个 App 占用 HID Device 槽位 。registerApp() 返回 false 的两大原因:
- 上次进程被系统杀死/崩溃,残留注册没释放(最常见)
- 确实有别的键鼠模拟类 App 在跑(远程控制、演示工具等)
AirMouse 的恢复策略:
kotlin
// 1) 单飞锁,防止并发重复注册
private var registering = false
// 2) 注册失败时先清理残留再重试
private fun attemptRegister(hid: BluetoothHidDevice, allowRecovery: Boolean) {
val ok = runCatching { hid.registerApp(settings, null, null, mainExecutor, hidCallback) }
.getOrDefault(false)
if (!ok && allowRecovery) {
runCatching { hid.unregisterApp() } // 清残留
attemptRegister(hid, allowRecovery = false) // 只重试一次
return
}
// 3) 个别 ROM 会丢失 onAppStatusChanged 回调,加个 2.5s 兜底
if (ok) {
scope.launch {
delay(2500)
if (registering && !appRegistered) { registering = false; registerApp() }
}
}
}
UI 上再补一个「重试注册」按钮兜底。
坑 4:手指滑动,光标却卡住不动
这个坑很隐蔽,而且和"手指在手机上动,电脑光标纹丝不动"的现象高度相关。
根因 :现代手机触摸采样率高达 120~240Hz,而蓝牙 HID 报文的实际吞吐只有每秒几十条。最初的实现是"每个触摸事件发一条报文":
kotlin
// ❌ 有问题的实现
fun move(dx: Float, dy: Float) {
accX += dx; accY += dy
val ix = accX.toInt().coerceIn(-127, 127)
val iy = accY.toInt().coerceIn(-127, 127)
accX -= ix; accY -= iy
if (ix == 0 && iy == 0) return
enqueueMouse(ix, iy, 0) // ← 每秒压入上百条,队列无限堆积
}
enqueueMouse 走的是无界单线程队列 ,每条都要阻塞式跑一次蓝牙 sendReport。入队速度远超出队速度,队列越堆越长,光标远远落后于手指,最后表现为"完全不动",手指抬起后还要把积压的位移一条条补完。
修复:合并发送------同一时刻最多只有一条移动报文在途,期间的所有位移累积,发送时一次性取走最新的累计量:
kotlin
private val moveLock = Any()
private var pendingMoveX = 0f
private var pendingMoveY = 0f
private var moveScheduled = false // 是否已有在途发送
fun move(dx: Float, dy: Float) {
if (!isConnected) return
var schedule = false
synchronized(moveLock) {
// 小数余量保留,低速移动不丢精度
accX += dx; accY += dy
val ix = accX.toInt().coerceIn(-127, 127)
val iy = accY.toInt().coerceIn(-127, 127)
accX -= ix; accY -= iy
// 合并进待发送量
pendingMoveX += ix; pendingMoveY += iy
if (!moveScheduled) { moveScheduled = true; schedule = true }
}
if (schedule) sendExecutor.execute { drainMove() }
}
private fun drainMove() {
val hid = hidDevice ?: return
val dev = targetDevice ?: return
while (true) {
val mx: Int; val my: Int
synchronized(moveLock) {
mx = pendingMoveX.toInt().coerceIn(-127, 127)
my = pendingMoveY.toInt().coerceIn(-127, 127)
pendingMoveX -= mx; pendingMoveY -= my
if (pendingMoveX == 0f && pendingMoveY == 0f) moveScheduled = false
}
if (mx == 0 && my == 0) return
sendMouse(hid, dev, buttons, mx, my, 0)
Thread.sleep(1) // 让位,保证按键/点击任务能插队
}
}
效果:不管触摸是 60Hz 还是 240Hz,队列长度恒为 1,光标跟手且不丢位移;快速甩动时靠 ±127 分片自动拆分。
坑 5:系统显示"已连接",但鼠标键盘没反应
Windows 蓝牙设置里明明显示"已连接",App 里也显示已连接,光标却不动。
原因 :系统设置里的"已连接"指的是 ACL 配对链路 (音频 / 文件传输 / 免提),不等于 HID 配置文件已建立 。HID 的 SDP 记录只在 registerApp() 成功之后才存在,必须在 App 内主动 connect() 建立 HID 通道。
AirMouse 的三级自动连接策略:
kotlin
private fun maybeAutoConnect() {
// 1) 上次保存的主机
savedAddress?.let { connectTo(it); return }
// 2) 扫描已配对设备,找带 HID Host UUID 的
val HID_HOST_UUID = "00001124-0000-1000-8000-00805F9B34FB"
bondedDevices.firstOrNull { dev ->
dev.uuids?.contains(ParcelUuid.fromString(HID_HOST_UUID)) == true
}?.let { connectTo(it.address); return }
// 3) 都没有:触发一次 SDP 探测
// fetchUuidsWithSdp() + 广播 ACTION_UUID,等主机回报它的服务列表
triggerHidHostProbe()
}
坑 6:不是所有手机都支持
BluetoothHidDevice 并非所有 Android 设备都提供:
- ✅ 多数原生 Android(Google Pixel 等)支持
- ❌ 部分厂商 ROM(部分三星、小米澎湃、华为 HarmonyOS 等)已移除或阉割该服务
App 需要在启动时检测并友好提示:
kotlin
if (hidDevice == null) {
_state.update { it.copy(status = HidStatus.UNSUPPORTED) }
}
遇到这种情况只能换硬件方案(如 USB Gadget / BlueDucky 类硬件)。
五、UI 设计:让虚拟鼠标"手感等同真鼠标"
纯做一个矩形触控板,用户体验其实很糟糕:没有方向感,也不知道手指按的是左键还是右键。
AirMouse 的做法是画一只真的虚拟鼠标 ,并且让绘制和触摸检测共用同一套几何数据------这是从根源上杜绝"图形画在这里、手感却在别处"的关键设计:
kotlin
/** 虚拟鼠标的几何信息,绘制与命中检测共用 */
private class MouseGeometry(width: Float, height: Float, padding: Float) {
private val w = (width - padding * 2f).coerceAtLeast(1f)
private val h = (height - padding * 2f).coerceAtLeast(1f)
val bodyWidth = w * 0.75f // 机身宽度占比
val bodyHeight = h * 0.88f // 机身高度占比
val bodyLeft = padding + (w - bodyWidth) / 2f
val bodyTop = padding + (h - bodyHeight) / 2f
val centerX = bodyLeft + bodyWidth / 2f
val buttonZoneBottom = bodyTop + bodyHeight * 0.35f // 左右键区下边缘
val wheelHalfWidth = bodyWidth * 0.13f / 2f
val wheelTop = buttonZoneBottom + bodyHeight * 0.04f
val wheelBottom = wheelTop + bodyHeight * 0.18f
/** 命中测试:把触摸点映射到功能区 */
fun zoneAt(position: Offset): MouseZone {
val x = position.x; val y = position.y
val slack = wheelHalfWidth
// 机身之外当作机身区处理,避免出现"死区"
val insideBody = x >= bodyLeft - slack && x <= bodyLeft + bodyWidth + slack &&
y >= bodyTop - slack && y <= bodyTop + bodyHeight + slack
if (!insideBody) return MouseZone.PALM
// 滚轮优先
if (x >= centerX - wheelHalfWidth && x <= centerX + wheelHalfWidth &&
y >= wheelTop && y <= wheelBottom
) return MouseZone.WHEEL
// 上半部分按左右分键
if (y < buttonZoneBottom) {
return if (x < centerX) MouseZone.LEFT_BUTTON else MouseZone.RIGHT_BUTTON
}
return MouseZone.PALM
}
}
private enum class MouseZone { LEFT_BUTTON, RIGHT_BUTTON, WHEEL, PALM }
绘制的时候,机身轮廓用三次贝塞尔曲线画出"上圆下收"的鼠标造型,按键区、滚轮、托握区都直接从 MouseGeometry 取坐标,保证严丝合缝。
最关键的一点:真正的"按下/松开"语义
很多虚拟鼠标的实现是"抬手时补一个点击",这样按住拖动会失效 。AirMouse 在键区采用的是按下即按住:
kotlin
// 按在键区上:立刻按住不放
var heldButton = when (zone) {
MouseZone.LEFT_BUTTON -> MouseButton.LEFT
MouseZone.RIGHT_BUTTON -> MouseButton.RIGHT
else -> 0
}
if (heldButton != 0) buttonCb(heldButton, true) // ← 立即发 down
// ...移动逻辑:按住左键的同时可以自由移动...
// 抬手
if (heldButton != 0) buttonCb(heldButton, false) // ← 发出 up,完成点击/拖拽
这样"按住左键区直接把文件拖到另一个窗口"就是真的能拖了,和真鼠标完全一致。而机身区域仍然用"抬手判定点击"的老逻辑,避免快速点击时误判。
完整的交互映射:
| 区域 | 操作 | 实现 |
|---|---|---|
| 左键区 | 按下 → 移动 → 松开 | 按下即发 down,拖拽天然可用 |
| 右键区 | 按下 → 松开 | 同上,右键菜单 |
| 滚轮区 | 上下滑动 | 累积到阈值就发一格滚动 |
| 滚轮区 | 轻点 | 中键 |
| 机身区 | 滑动 | 光标相对移动 |
| 机身区 | 轻点 | 左键 |
| 机身区 | 长按 350ms 后滑动 | 按住左键拖拽 |
| 机身区 | 双指滑动 | 滚动 |
一个实现小坑:
awaitEachGesture { }的接收者AwaitPointerEventScope不是CoroutineScope,里面不能直接launch {}做长按定时器。想做长按,要么在事件循环里用时间戳判定,要么显式coroutineScope { }包一层。AirMouse 选了前者,少一次协程调度,也少一处cancel。
六、键盘的横竖屏自适应
键盘模式最直观的适配问题:横屏时上方那两行"快捷键/多媒体键"面板太占空间,导致按键被压扁。
处理方式很直接------横屏时直接隐藏面板,让键盘吃满整个屏幕:
kotlin
if (!landscape) {
LazyRow { /* 多媒体键 */ }
LazyRow { /* 快捷键 */ }
if (!enabled) Text("未连接主机,按键不会发送")
}
// 竖屏:弹性空白把键盘压到屏幕底部;横屏:键盘占满剩余空间
if (!landscape) Spacer(Modifier.weight(1f))
Column(
modifier = Modifier.fillMaxWidth()
.then(if (landscape) Modifier.weight(1f) else Modifier)
) {
val rowModifier = Modifier.fillMaxWidth()
.then(if (landscape) Modifier.weight(1f) else Modifier)
.padding(horizontal = 4.dp, vertical = if (landscape) 2.dp else 3.dp)
KeyboardRow(row0, shift, rowModifier, landscape, keyHeight, sendKey)
// ... 共 5 行
}
横屏时每行 weight(1f) 平分剩余高度,按键尺寸自动跟随手机实际可用空间,任何分辨率都合适;竖屏时用固定行高 + 弹性空白,让键盘稳稳贴在屏幕底部,方便单手盲打。
竖屏下的按键高度则按屏幕宽度动态计算,保证不同机型上比例一致:
kotlin
val keyHeight = if (landscape) {
(screenHeightDp / 7f).coerceIn(52f, 88f).dp
} else {
(screenWidthDp / 15f).coerceIn(52f, 70f).dp
}
一次性修饰键
Ctrl / Alt / Win / Shift 采用点一次点亮、再按普通键自动组合并熄灭的逻辑:
kotlin
val currentMods = (if (ctrl) Mod.LEFT_CTRL else 0) or
(if (alt) Mod.LEFT_ALT else 0) or
(if (gui) Mod.LEFT_GUI else 0) or
(if (shift) Mod.LEFT_SHIFT else 0)
val sendKey: (KeyDef) -> Unit = { def ->
when (def.kind) {
KeyKind.CTRL -> ctrl = !ctrl
KeyKind.NORMAL -> if (enabled) {
onKey(def.usage, currentMods)
if (currentMods != 0) { shift = false; ctrl = false; alt = false; gui = false }
}
// ...
}
}
这比"按住 Ctrl 再点 C"在触屏上舒服得多。
七、线程模型与工程要点
| 层 | 职责 |
|---|---|
| UI 层 | Compose 手势识别,触摸坐标 → MouseGeometry.zoneAt() → 功能区 |
| 发送层 | 单线程 Executor 串行化所有 sendReport,保证报文顺序;移动走合并队列 |
| 状态层 | StateFlow 聚合 HID 状态 + 本地 UI 状态,ViewModel 统一转发输入 |
几个必须注意的工程点:
- 所有报文串行发送 。蓝牙 HID 报文有严格顺序,用单线程 Executor 串行化,不要在 UI 线程直接调
sendReport。 - 按键之间要有间隔。按下/松开之间 sleep 20~30ms,主机才认得出这是一次完整的击键而不是抖动。
- 字节一律取低位 。
dx.toByte()会截断成 8 位,配合coerceIn(-127, 127)保证落在有效范围内。 - 移动报文发送时读到的
buttons要是最新值,这样"拖拽时按钮保持按下"才正确。
权限方面,minSdk = 28 需要同时处理新旧两套:
xml
<!-- Android 12 以下 -->
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" android:maxSdkVersion="30" />
<!-- Android 12 及以上 -->
<uses-permission android:name="android.permission.BLUETOOTH_SCAN"
android:usesPermissionFlags="neverForLocation" tools:targetApi="s" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
八、项目结构
app/src/main/java/com/litian/airmouse/
├── MainActivity.kt # 单 Activity + Compose,模式/方向控制
├── hid/
│ ├── HidController.kt # 核心:注册、连接、报表发送(合并/串行)
│ ├── HidDescriptor.kt # HID 报告描述符(改完必须重新配对!)
│ └── HidCodes.kt # HID Usage 常量(Key / Mod / Consumer)
└── ui/
├── AirMouseViewModel.kt # 状态聚合与输入转发
├── screens/
│ ├── MousePadScreen.kt # 虚拟鼠标(几何 + 分区命中 + 手势)
│ ├── KeyboardScreen.kt # 键盘(横竖屏自适应)
│ └── ConnectSheet.kt # 连接面板 / 故障排查提示
└── theme/ # Material3 主题
构建环境:JDK 11+(推荐 17/21)、compileSdk 35 / minSdk 28、Gradle 8.11.1、AGP 8.9.0、Kotlin 2.0.21、Compose BOM 2024.09.00。
九、总结
这个项目技术上并不复杂,核心 API 只有一个 BluetoothHidDevice,但真正花时间的是填平那些没人告诉你的坑:
- Usage Page 是有状态的,切了就得切回来
- 主机缓存描述符,改完必须重新配对
- HID 槽位全局唯一,注册失败要能自愈
- 触摸采样率 ≫ 蓝牙吞吐,必须合并发送
- ACL 连接 ≠ HID 连接,要有主动建链逻辑
- 不是所有 ROM 都支持,要先检测
如果你也想做类似的东西,记住一句话:HID 的坑几乎全在"描述符"和"生命周期管理"上,把这两块啃下来,剩下的都是常规 Android 开发。