手机给平板当键盘?

把安卓手机变成蓝牙虚拟鼠标和键盘(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 里点"断开/重新连接"是无效的,必须:

  1. 在电脑的蓝牙设置里删除这个设备
  2. 手机 App 重新扫描、重新连接(触发一次全新的配对)

由此提炼一条开发铁律:

只要改动了 HID 报告描述符,就必须让所有已配对过的主机"删除设备后重新配对",否则改动 100% 不生效。

AirMouse 后来把这个提示直接做进了 App 的连接面板里,避免用户和开发者反复踩坑。


坑 3:registerApp 失败------HID 槽位是全局唯一的

复制代码
HID 应用注册失败:可能已被其它应用占用

Android 系统全局只允许一个 App 占用 HID Device 槽位 。registerApp() 返回 false 的两大原因:

  1. 上次进程被系统杀死/崩溃,残留注册没释放(最常见)
  2. 确实有别的键鼠模拟类 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 统一转发输入

几个必须注意的工程点:

  1. 所有报文串行发送 。蓝牙 HID 报文有严格顺序,用单线程 Executor 串行化,不要在 UI 线程直接调 sendReport。
  2. 按键之间要有间隔。按下/松开之间 sleep 20~30ms,主机才认得出这是一次完整的击键而不是抖动。
  3. 字节一律取低位 。dx.toByte() 会截断成 8 位,配合 coerceIn(-127, 127) 保证落在有效范围内。
  4. 移动报文发送时读到的 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,但真正花时间的是填平那些没人告诉你的坑:

  1. Usage Page 是有状态的,切了就得切回来
  2. 主机缓存描述符,改完必须重新配对
  3. HID 槽位全局唯一,注册失败要能自愈
  4. 触摸采样率 ≫ 蓝牙吞吐,必须合并发送
  5. ACL 连接 ≠ HID 连接,要有主动建链逻辑
  6. 不是所有 ROM 都支持,要先检测

如果你也想做类似的东西,记住一句话:HID 的坑几乎全在"描述符"和"生命周期管理"上,把这两块啃下来,剩下的都是常规 Android 开发。


相关推荐
李游Leo2 小时前
HarmonyOS 7 QuickDock 闪控窗开发实录 06:floatView × 回归验收:25轮场景回归、资源基线与发布前收口【鸿蒙心迹】
回归·kotlin·harmonyos
李游Leo2 小时前
HarmonyOS 7 DualCart 平行视界适配实录 06:Navigation × 多窗口回归:路由冲突、恢复一致性与性能验收【鸿蒙心迹】
回归·kotlin·harmonyos
ZealSinger16 小时前
Boot4挂起函数丢traceId怎么修
spring boot·kotlin·协程·可观测性
维克兜率天20 小时前
【维克】配对交易的季节性:哪些品种适合长拿?
android·开发语言·笔记·python·算法·kotlin·量化
墨天梦21 小时前
B06_XML控件布局与ViewBinding
android·kotlin
释厄6231 天前
BSD 简单真理循环论——简单真理 × 复杂循环=大一统
android·开发语言·kotlin
Android打工仔2 天前
Kotlin 协程源码解析:DispatchedContinuation 里的 Dispatcher 从哪里来?
android·kotlin
ZealSinger3 天前
Kotlin后端别再用GlobalScope
kotlin·协程·后端开发·结构化并发
mmsx4 天前
Android 上的 AI 对话链路:流式响应、SSE 解析与三级降级
android·kotlin