简介:Android平台USB读写通信Demo源码工程,面向需要实现USB主机模式外设控制的Android开发工程师,适合手机OTG外接设备、USB串口通信等场景;资源实现从多个USB设备中动态选择指定设备建立通信,并做了设备名称过滤处理,使用时将过滤项改为自己的USB外设名即可复用,工程经测试通过,曾作为公司项目重要组件,性能稳定可靠。包体共518个文件,压缩包约12.92MB,以Gradle工程结构为主,包含Java源码、Android资源文件(xml/json/flat)、编译产物dex/class、签名APK及7个jar依赖库等,可直接导入Android Studio运行验证,也可拆解学习USB设备枚举、权限请求与收发通信的完整流程,另有app-debug.apk可直接安装体验,已有916人学习下载。对于正在做USB外设控制或需要多路设备选择过滤的开发者,这份Demo提供完整可运行的工程与现成APK,能较快上手并迁移至自身项目,省去从零搭建USB通信框架的时间。
1. 从一堆 USB 设备里选中目标外设:这个 Demo 把通信链路拆清楚了
做 Android 外设通信最怕的不是不会写读写函数,而是设备列表里明明躺着好几个 USB 外设,代码却不知道该跟谁说话。这个 USB 读写 Demo 解决的就是这条完整链路:从 UsbManager 枚举所有设备,到申请用户授权,再到定位目标接口和端点,最后用 bulkTransfer 和 controlTransfer 建立一条像网络通信一样的数据通道,把指令发出去、把响应读回来。适合刚接手 USB Host 开发的 Android 应用层工程师,也适合做硬件协议对接、想快速验证外设逻辑的开发者。Demo 的代码结构不复杂,但把最容易踩坑的几个环节都串起来了,照着跑一遍,能少走很多弯路。
2. 理解 USB Host 通信模型:设备、接口、端点的三层结构
2.1 为什么说权限是 USB 通信的第一道门槛
先厘清一个反直觉的事实:UsbManager.getDeviceList() 不需要任何权限就能拿到设备列表,但当你调用 openDevice() 试图建立 UsbDeviceConnection 时,系统会强制要求用户授权。这个设计和网络 socket 完全不同——socket 只要申请到网络权限就能连接,USB 设备却是每次插上都要用户点一下确认弹窗。
原因在 Android 的安全模型里:USB 设备被看作一个会给系统带来潜在风险的外部实体,内核的 USB Host 栈把设备访问权限委托给应用层管理。系统不会信任任何一个应用能自主决定"我要操作哪台外设",必须有用户确认的授权动作存在。所以权限请求的代码要放在打开连接之前,而且要把授权结果回调处理好,否则用户点"取消"之后,你后面的 open 大概率拿到 null。
Demo 里的权限申请流程是标准的三步:先调用 usbManager.hasPermission(device) 检查是否已有授权,没有就构造一个 PendingIntent 并调用 requestPermission(),最后由注册好的 BroadcastReceiver 接收授权结果。这里有个细节:Receiver 的注册建议用 context.registerReceiver() 动态注册,不要写在 AndroidManifest.xml 里静态注册。因为 USB 权限广播是隐式广播,Android 8.0 以后对隐式广播做了限制,静态注册在部分机型上根本收不到结果,表现就是授权弹窗点了同意,代码里却没有任何回调。
2.2 从 getDeviceList 到目标设备:枚举链路每一步返回什么
UsbManager.getDeviceList() 返回的是一个 HashMap<String, UsbDevice>,key 是系统分配的设备名,value 是 UsbDevice 对象。这个设备名不是什么友好的厂商名称,而是类似 /dev/bus/usb/001/002 这样的总线路径,在多个设备同时插着的时候,肉眼根本分不清谁是谁。所以枚举之后最关键的一步是读取 UsbDevice 的四个属性:vendorId(VID)、productId(PID)、deviceClass、interfaceCount。
VID 和 PID 是识别设备的硬件 ID,厂商在 USB 认证的时候会申请,同一个型号的产品通常有相同的 VID/PID。deviceClass 表示设备大类,比如 Android 手机通常会被识别为 0xFF(厂商特殊类)。interfaceCount 是这个设备上暴露的接口数量,一个物理设备可能有多个接口,比如同时包含音频和 HID 功能的外设,每个接口对应一种功能。
Demo 里枚举后做过滤的逻辑,常见做法是先按 VID/PID 筛掉不相关的设备,再遍历剩下设备的 UsbInterface 列表,找到包含 bulk 端点或 interrupt 端点的那个接口。这一步很关键:你后面做的所有读写操作,针对的都是某个接口下的某个端点,而不是整台设备。如果跳过接口遍历,直接拿着设备去 open,即使连接成功了,也不知道指令应该从哪个端点发出去。
val usbManager = getSystemService(Context.USB_SERVICE) as UsbManager val deviceList: HashMap<String, UsbDevice> = usbManager.deviceList deviceList.values.forEach { device -> for (i in 0 until device.interfaceCount) { val usbInterface = device.getInterface(i) for (j in 0 until usbInterface.endpointCount) { val endpoint = usbInterface.getEndpoint(j) // 取端点类型和方向,后面选择读写端点要用 val type = endpoint.type // USB_ENDPOINT_XFER_BULK / INTERRUPT / ISOCHRONOUS val direction = endpoint.direction // USB_DIR_OUT / USB_DIR_IN Log.d("UsbDemo", "device=${device.deviceName}, if=$i, endpoint=$j, type=$type, dir=$direction") } } }这段代码不做任何逻辑判断,只是把三层的结构完整打出来:设备下面有接口,接口下面有端点。跑一遍打印,你能直观看到目标外设的拓扑。实际开发中,我需要根据打印结果决定过滤条件:如果外设只有一个 bulk 输入和一个 bulk 输出端点,那么遍历条件就写死 endpoint.type == UsbConstants.USB_ENDPOINT_XFER_BULK,再按方向拆成输入输出两个通道。
2.3 权限请求与广播接收:一段可以直接抄的 Kotlin 骨架
权限请求的代码每个项目都要写,写法却五花八门。Demo 里的实现是可用的最小骨架,我把关键部分拆出来讲。
private val USB_PERMISSION_ACTION = "com.usb.demo.USB_PERMISSION" private val usbManager = getSystemService(Context.USB_SERVICE) as UsbManager private var permissionReceiver: BroadcastReceiver? = null // 检查并请求权限 fun ensurePermission(device: UsbDevice): Boolean { if (usbManager.hasPermission(device)) { return true } registerUsbPermissionReceiver() val intent = Intent(USB_PERMISSION_ACTION).setPackage(packageName) val pi = PendingIntent.getBroadcast( this, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE ) usbManager.requestPermission(device, pi) return false // 授权结果异步返回 } private fun registerUsbPermissionReceiver() { if (permissionReceiver == null) { permissionReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { val device = intent.getParcelableExtra<UsbDevice>(UsbManager.EXTRA_DEVICE) val granted = intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false) if (granted) { onUsbDeviceAuthorized(device) } else { onUsbPermissionDenied(device) } } } registerReceiver(permissionReceiver, IntentFilter(USB_PERMISSION_ACTION)) } }逻辑说明:ensurePermission 先做同步检查,已经授权就直接返回 true;否则注册 Receiver 再发起异步授权。requestPermission 的参数是设备引用(不是设备名)和 PendingIntent,系统弹窗确认后把结果广播到 PendingIntent 指向的意图上。
参数说明:FLAG_UPDATE_CURRENT 让同一个 PendingIntent 在重复请求时替换数据,避免多次插拔后拿到旧设备引用;FLAG_IMMUTABLE 是 Android 12 之后 PendingIntent 必须携带的标记,不写会在 API 31 上直接抛异常。setPackage(packageName) 是为了把广播锁定在应用内部,防止外部应用拦截授权结果。
一个容易忽略的点:授权结果是异步回调,UI 线程上不能立刻拿到连接。Demo 里用了回调函数把结果抛给调用方,我一般会再加一个挂起函数包装,用 suspendCancellableCoroutine 把回调转成协程风格,后续流程写起来就顺很多。但这个属于优化,基础版先用回调兜底。
3. 多设备选择策略:VID/PID 过滤、序列号与端点匹配
3.1 三种过滤策略的边界:什么时候只用 VID/PID 不够
多设备选择的坑在"多个"这两个字上。Demo 面对的场景是根设备上同时插了好几台 USB 外设,你得把目标那台挑出来。最直观的过滤条件是 vendorId + productId,这个组合能区分不同型号的设备,但区分不了同型号的两台设备。比如两台同样 VID/PID 的传感器同时插在 OTG 扩展坞上,过滤出来的就是两个 UsbDevice,你还是不知道哪个对应物理位置上的那一台。
解决同型号区分有两条路:第一是读 UsbDeviceConnection.getSerial() 拿序列号,这个序列号是设备出厂时写入的,每台都不一样,是真正意义上的唯一标识。但有个前置条件——你必须先拿到权限并 openDevice(),才能调用 getSerial()。这就构成了一个循环依赖:没有权限读不到序列号,没有序列号确定不了要授权哪台设备。
第二是用系统分配的 deviceName 或 bus 上的端口位置。deviceName 形如 /dev/bus/usb/001/004,前面的 001 是总线号,后面的 004 是设备地址。如果外设接在同一个扩展坞的固定端口上,总线号通常是稳定的,可以用它做粗粒度区分。这个方法不依赖任何厂商信息,但换 USB 口就会变,属于没有序列号时的无奈选择。
端点匹配是第三种策略:遍历每个 candidate 设备的接口和端点,检查是否包含指定类型的端点。有些设备 VID/PID 是通用芯片的,比如某款 FTDI 串口芯片,做成了不同产品外观但 VID/PID 完全一样,这时候只能靠端点特征来区分功能接口。
3.2 Demo 里的设备筛选:组合条件而不是单条件
看 Demo 的筛选实现,它把上面三种策略组合成了一个过滤器,按优先级从强到弱排列。
data class UsbDeviceMatch( val vid: Int = -1, val pid: Int = -1, val serial: String? = null, val requireBulkIn: Boolean = false, val requireBulkOut: Boolean = false ) fun findTargetDevice(devices: Collection<UsbDevice>, match: UsbDeviceMatch): UsbDevice? { val candidates = devices.filter { device -> var ok = true if (match.vid != -1) ok = ok && device.vendorId == match.vid if (match.pid != -1) ok = ok && device.productId == match.pid ok } if (candidates.isEmpty()) return null // 序列号过滤需要打开连接,尽量只在候选集压缩到 1 个时使用 var target: UsbDevice? = null if (match.serial != null) { for (device in candidates) { val conn = usbManager.openDevice(device) val serial = conn?.serial conn?.close() if (match.serial == serial) { target = device break } } } else if (candidates.size == 1) { target = candidates.first() } else { // 多个同型号设备,且没有 serial 条件:尝试用端点特征筛选 target = candidates.firstOrNull { device -> var hasBulkIn = !match.requireBulkIn var hasBulkOut = !match.requireBulkOut for (i in 0 until device.interfaceCount) { val intf = device.getInterface(i) for (j in 0 until intf.endpointCount) { val ep = intf.getEndpoint(j) if (ep.type == UsbConstants.USB_ENDPOINT_XFER_BULK) { if (ep.direction == UsbConstants.USB_DIR_IN) hasBulkIn = true if (ep.direction == UsbConstants.USB_DIR_OUT) hasBulkOut = true } } } hasBulkIn && hasBulkOut } } return target }逻辑说明:先按 VID/PID 把候选集压缩到最小范围,这是成本最低的操作,不涉及权限和连接。然后处理三种情况——指定了序列号就逐个 open 对比,候选集只有一个就直接返回,多个同型号无法用序列号区分时再走端点特征匹配。
参数说明:UsbDeviceMatch 是一个纯 Kotlin 数据类,vid 默认 -1 表示不过滤。requireBulkIn 和 requireBulkOut 表示目标外设必须拥有 bulk 输入和输出端点,这正好对应大多数数据型外设的通信方式。注意序列号匹配里 open 完立刻 close,这个连接只是为了读序列号,不能长期持有。
这个实现有一个明显的取舍:序列号匹配要逐个 open,如果候选集里有多个设备且序列号分布在很后面,速度会慢一些,但我们在 Open 过程中没做任何 I/O 操作,所以实际耗时在可接受范围内。对于 USB 通信这种低频交互场景,这里做全量扫描没有性能压力。
3.3 插拔动态刷新:把选择逻辑挂在设备热插拔事件上
外设不会永远插着,用户可能在界面打开的时候拔掉设备再重新插上一个新的,选择逻辑必须在设备列表变化时重新执行。Android 提供了两个和 USB 设备插拔相关的广播:UsbManager.ACTION_USB_DEVICE_ATTACHED(设备插入)和 UsbManager.ACTION_USB_DEVICE_DETACHED(设备拔出)。这两个广播不需要任何权限就能接收,直接在代码里动态注册即可。
Demo 的刷新逻辑放在一个简单的数据回调接口里:设备插入时重新调用 findTargetDevice,找到了就用 Handler 回主线程更新 UI;设备拔出时判断拔掉的设备是不是当前选中的那台,如果是就清空当前连接并把界面状态复位。
private val usbDeviceReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { when (intent.action) { UsbManager.ACTION_USB_DEVICE_ATTACHED -> { val device = intent.getParcelableExtra<UsbDevice>(UsbManager.EXTRA_DEVICE) Log.d("UsbDemo", "device attached: ${device?.deviceName}") refreshDeviceSelection() } UsbManager.ACTION_USB_DEVICE_DETACHED -> { val device = intent.getParcelableExtra<UsbDevice>(UsbManager.EXTRA_DEVICE) if (device == currentDevice) { closeConnection() currentDevice = null updateUiToIdle() } } } } }逻辑说明:收到 ATTACHED 广播后不能把新设备直接当目标设备用,因为广播里的 EXTRA_DEVICE 只是告诉你哪个设备插了,不一定就是你要通信的那个。正确的做法是重新跑一遍 findTargetDevice,让过滤条件重新评估。收到 DETACHED 广播时判断设备引用是否等于当前通信对象,相等就立即关闭连接。这个场景最容易出 bug:拔出设备后 UsbDeviceConnection 可能还处于半打开状态,不显式 close 的话,重新插入同型号设备再授权,会拿不到连接或权限回调异常。
常见做法是在 onResume 里注册 ATTACHED/DETACHED 接收器,在 onPause 里注销,并把 Recevier 的 IntentFilter 同时包两个 action。这里面有个细节:放在 manifest 里静态注册也能收到 DETACHED,但收不到 ATTACHED,所以动态注册是唯一稳定方案。
4. 数据读写通道:bulkTransfer 和 controlTransfer 的参数细节
4.1 端点的方向判断:读端点用错方向,返回值永远是 -1
拿到目标设备之后,下一步是从接口里挑出具体的读写端点。UsbEndpoint 有两个关键属性:type 和 direction。type 决定传输模式,Android 里最常见的是 bulk(USB_ENDPOINT_XFER_BULK,用于大数据量块传输)、interrupt(USB_ENDPOINT_XFER_INTERRUPT,用于小数据量周期传输)、control(USB_ENDPOINT_XFER_CONTROL,用于控制命令);direction 只有两个值:USB_DIR_OUT(对应 OUT 端点,向设备发数据)和 USB_DIR_IN(对应 IN 端点,从设备读数据)。
写数据必须用 OUT 端点,读数据必须用 IN 端点,方向选错 bulkTransfer 会直接返回 -1。注意 bulkTransfer 的返回值和网络 socket 的 write 返回语义不同:socket 的 write 返回写入字节数且不保证全部写成功,USB bulkTransfer 要么返回实际传输的字节数,要么返回 -1 表示传输失败,它不会做部分成功这种折中。所以返回值是否等于你传入的长度,可以直接当作传输是否完整的判断依据。
Demo 里读端点和写端点的选取,我一般写成一个遍历接口下所有端点的函数,用 direction + type 组合过滤,避免手动硬编码端点下标。
fun findEndpoint(usbInterface: UsbInterface, direction: Int, type: Int): UsbEndpoint? { for (i in 0 until usbInterface.endpointCount) { val ep = usbInterface.getEndpoint(i) if (ep.direction == direction && ep.type == type) { return ep } } return null }逻辑说明:这个函数接受接口引用、方向和类型三个参数,返回匹配的端点,找不到返回 null。调用时分开获取目标接口、批量输出端点、批量输入端点,后续所有读写操作都围绕这两个端点展开。
参数说明:direction 取值用 UsbConstants.USB_DIR_OUT 或 UsbConstants.USB_DIR_IN,type 用 UsbConstants.USB_ENDPOINT_XFER_BULK。这里有玄学:有些设备固件里把端点的方向标志和类型标志组合得乱七八糟,按照标准枚举读不到想要的端点,这时候先看设备的 Linux lsusb 输出确认真的存在该端点。
4.2 bulkTransfer 读写:缓冲区大小、超时时间与分包边界
bulk 传输是最常用的数据通道。读和写都要用它,但参数上的注意点不太一样。先看写操作。
val connection: UsbDeviceConnection = ... val endpointOut: UsbEndpoint = ... val byteArray = byteArrayOf(0x01, 0x03, 0x00, 0x00, 0xFF.toByte()) val buffer = ByteArray(endpointOut.maxPacketSize).also { System.arraycopy(byteArray, 0, it, 0, byteArray.size) } val transferred = connection.bulkTransfer(endpointOut, buffer, byteArray.size, 1000) if (transferred != byteArray.size) { // 写入不完整,按失败处理 }逻辑说明:先把指令放入缓冲区,缓冲区大小用 endpoint.maxPacketSize 对齐,然后调用 bulkTransfer。方法签名是 bulkTransfer(endpoint, buffer, length, timeout),endpoint 是传输目标,length 是要发送的字节长度,timeout 单位是毫秒。返回实际传输的字节数。这里的 length 我用的是 byteArray.size,而不是 buffer.size,原因是命令报文通常比 maxPacketSize 小,传 buffer 会把后面的空字节也发出去,多余的数据会污染设备侧的解析逻辑。
参数说明:maxPacketSize 是端点的最大包长度,bulk 端点一般是 64 字节或 512 字节。你的发送缓冲区最好按它对齐,一次传输不要超过 maxPacketSize 的整数倍,避免跨包时设备固件处理缓冲不足。timeout 我一般给 1000-2000 毫秒,桌面端常用 5000,但 Android 端太长会让用户感觉到卡顿。如果写超时,优先检查指令格式和目标设备是否处于可接收状态。
再看读操作,读的情况比写复杂:你不一定知道设备什么时候有数据回来,而且数据长度也是未知的。所以读缓冲区要按 maxPacketSize 分配,并且要做循环读取,把短读的数据累计拼接。
fun readBulk(connection: UsbDeviceConnection, endpointIn: UsbEndpoint, maxRead: Int = 4096): ByteArray { val chunkSize = endpointIn.maxPacketSize val buffer = ByteArray(chunkSize) val output = ByteArrayOutputStream() var totalRead = 0 while (totalRead < maxRead) { val count = connection.bulkTransfer(endpointIn, buffer, buffer.size, 500) if (count > 0) { output.write(buffer, 0, count) totalRead += count // 小于一个包说明设备侧暂时没更多数据了,先跳出循环 if (count < chunkSize) break } else { break // 返回 -1 或 0,本轮读取结束 } } return output.toByteArray() }逻辑说明:循环读取直到累计数据量达到 maxRead 或者一次读取得到短包。短包判断是关键的边界逻辑,通常意味着设备当前这一轮响应已经传输完毕。如果 count 小于 chunkSize 还不跳出,下一次调用大概率阻塞到超时返回 -1,白白浪费 500 毫秒。
参数说明:timeout 在这里设 500 毫秒比较合理,因为轮询模式下你不希望界面卡死等待设备响应;如果设备响应有固定周期,比如 100 毫秒上报一次,timeout 可以缩短到 100-200 毫秒,短包的判断逻辑依然有效。maxRead 是防止设备持续发送大块数据把内存撑爆的总上限,4KB 作为默认值是合格的从业者最常用的折中选择。
4.3 controlTransfer:让外设进入工作模式的开关
bulk 通道负责搬运数据,但很多设备在进入正常工作状态之前,需要主机先通过 control 端点发一串配置指令。controlTransfer 的参数比 bulk 多,很多开发者在这里翻车。
val data = byteArrayOf(0x02, 0x00) val ret = connection.controlTransfer( UsbConstants.USB_TYPE_VENDOR or UsbConstants.USB_DIR_OUT or UsbConstants.USB_RECIP_DEVICE, 0xA0, // request 0x0001, // value 0, // index data, // payload data.size, 1000 )逻辑说明:controlTransfer 的完整签名是 controlTransfer(requestType, request, value, index, buffer, length, timeout)。requestType 是位掩码,由 USB_TYPE_VENDOR(厂商自定义类型)、USB_DIR_OUT(方向)和 USB_RECIP_DEVICE(接收者是设备级)三个值按位或组成。request 是设备固件定义的请求码,不同的请求码对应不同的操作。value 和 index 是辅助参数,具体含义完全由厂商协议决定。返回值为实际传输字节数,-1 表示失败。
参数说明:USB_TYPE_VENDOR 告诉底层这个请求不在标准 USB 规范定义范围内,让设备固件走自定义处理逻辑。direction 位要从主机的视角理解——USB_DIR_OUT 表示数据从主机发往设备,USB_DIR_IN 表示从设备读回状态。这里 0xA0 是我在某个传感器外设协议里看到过的请求码,Demo 里保留了它作为示例,实际使用时一定要换成本项目设备的协议文档里的值,不能照搬。
控制传输是理解 Android USB 通信的最后一个关键点:bulk 通道是高速公路,control 通道是收费站。设备不认你的协议指令时,先走 control 通道确认固件版本或工作模式,再用 bulk 发业务数据,这是我调试外设时的固定顺序。跳过 control 直接写 bulk,相当一部分设备会返回 -1 或者干脆没有任何反应。
5. 避坑:USB 通信最容易翻车的四个场景与排查
5.1 授权弹窗没弹出来,openDevice 返回 null
现象:requestPermission() 调用后没有任何反应,系统弹窗不出现,openDevice() 拿不到连接对象。
原因:最常见的原因是 PendingIntent 没有设置 FLAG_IMMUTABLE,Android 12 及以上系统直接拒绝对可变 PendingIntent 的隐式广播调用。另一个场景是 Receiver 静态注册了但 Manifest 里没有声明 setPackage,导致广播被系统拦截,授权结果回调丢失。还有一种情况是 requestPermission 传的 PendingIntent 是全局唯一 ID,但前一次授权流程还没走完就重复调用,系统把后一次请求吞掉了。
解决:PendingIntent 使用 FLAG_UPDATE_CURRENT or FLAG_IMMUTABLE,确保每次授权使用新的 Intent 实例。Receiver 一律动态注册,并且 action 字符串要写全,包含应用包名前缀。如果是重复请求导致的吞弹窗,做一个 inFlight 标志位,只有上次授权回调返回后才允许发起下一次请求。
5.2 bulkTransfer 写数据返回 -1,设备侧收不到任何数据
现象:地址选择、连接状态都正常,但 bulkTransfer 一次都没成功过,返回 -1。
原因:这个问题的排查要按概率排序。第一,端点方向选错,拿着 IN 端点当 OUT 端点用,这种情况最常见。第二,写缓冲区长度超过端点 maxPacketSize,跨包导致设备固件 buffer 溢出。第三,设备处于错误的控制状态,它还没准备好接收业务数据,主机先发了 bulk 写。第四,线材问题——粗看一样的 USB 线,可能只有充电能力没有数据能力,这个坑在 USB 通信里极其隐蔽,换一根线就好了。
解决:先用 lsusb 或系统 dmesg 确认端点存在且方向正确;然后把每次读写长度打印出来,和 maxPacketSize 对比,超了就分段发;确认设备协议要求先进行 controlTransfer 握手;最后换一根短而粗的数据线试试。按这个顺序排查,绝大多数 -1 都能找到根因,不要上来就怀疑内核驱动。
5.3 设备断开重连后,授权状态和连接状态全乱套
现象:外设拔掉再插上,界面还显示已连接,但读写全部失败,重新 requestPermission 也没弹窗。
原因:设备拔出的时候 UsbDeviceConnection 没有显式 close,连接对象的文件描述符在内核里还指向着已失效的 usb device。Android 系统层面认为该设备的授权记录仍然有效,但因为设备节点已经变了,重连后拿到的 UsbDevice 是另一个对象,旧连接指向的地址已经无效,新连接又被残留状态卡住。
解决:监听 ACTION_USB_DEVICE_DETACHED 广播,收到后立即调用 connection.close(),把当前设备引用置空。重插后不能复用旧的 UsbDevice 对象,必须从 getDeviceList 重新枚举再走完整授权流程。在 Demo 里,我把这套逻辑写在 refreshDeviceSelection() 函数里,新设备到达后清空旧状态再走 findTargetDevice,避免任何残留状态影响新连接。
5.4 两个完全相同的外设,VID/PID 过滤后的集合里还是分不清谁是谁
现象:两台一模一样的外设插在同一个扩展坞上,过滤条件全对,但选中之后通信对象可能是任意一台,无法精确定位。
原因:VID/PID 是产品型号级别的标识,两台同型号设备拥有完全相同的值,总线地址又每次插拔都可能重新分配,所以从枚举信息上看它就是两个"相同的设备"。
解决:第一步尝试 UsbDeviceConnection.getSerial() 拿序列号,产品出厂时会写入这个字段,每台设备应该唯一。如果设备没烧录序列号,getSerial() 会是 null 或空字符串,那就只能退而求其次记录该设备在扩展坞上的端口路径。我一般会记录 device.getDeviceName() 里的总线号,并提示用户"请把目标设备插在固定的 USB 口上"。如果产品允许,另一个根治办法是给设备加一个厂商自定义的控制请求,通过 value/index 参数区分实例号,但这就要求改固件了,Demo 里只做到序列号这一层。注意 getSerial() 必须成功 open 之后才能调用,调用后立刻 close,千万不要忘了关连接——忘记关的话,走了一遍循环后所有连接都还挂着,后续 open 全部失败。
6. 进阶:把 Demo 重构为可复用的 USB 通信模块
Demo 跑通之后,下一步建议把它从 Activity 里的散落逻辑收敛成一个 UsbChannel 类,用接口把设备枚举、权限、读写四个环节封装起来。我通常会定义一个 UsbTransport 接口,暴露三个方法:open(device)、write(data)、read(size),内部实现细节全部隐藏。上层业务代码只需要拿到这个接口,调用方式就和操作一个网络流差不多了。好处是切换外设型号时只需要改过滤条件,不用动业务逻辑。
验证这个模块是否可靠,我的习惯是三步走。第一步做回环测试:如果外设有自检模式,用 USB 分析仪或者串口转接板把设备的 IN/OUT 短接,发一段递增序列,读回的数据应该完全相同。第二步做连续吞吐测试:循环发 1000 包数据,统计失败率和超时次数,这个数据能直接反映 timeout 设得合不合理。第三步做异常恢复测试:在读写过程中反复插拔设备,观察模块是否能通过 DETACHED 广播自动清理状态并在重新插入后恢复通信。这三步跑完,模块才能算合格。
从那以后,我每次写完 USB 通信代码都会强制走一遍异常恢复测试,尤其是"设备断开重连后授权状态是否干净"这一条,因为它最隐蔽,表现也很像玄学——本地跑得好好的,真机一插,莫名其妙就卡死在连接状态。现在我的习惯是开一个日志开关,把每个关键动作(枚举、授权、open、write、read、close)都打印到本地文件,排查问题的时候回放日志比对着代码猜快得多。这个 Demo 就是按这个思路组织的,字符串搜索、协议指令、缓冲区分配都有迹可循。希望这些踩过的坑帮你提前避掉,把外设通信这层透明天花板捅破。
本文还有配套的精品资源,点击获取