Android USB Host通信Demo:从设备枚举到数据读写全链路指南

发布时间:2026/10/14 3:33:12
Android USB Host通信Demo:从设备枚举到数据读写全链路指南 简介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() 返回的是一个 HashMapString, UsbDevicekey 是系统分配的设备名value 是 UsbDevice 对象。这个设备名不是什么友好的厂商名称而是类似 /dev/bus/usb/001/002 这样的总线路径在多个设备同时插着的时候肉眼根本分不清谁是谁。所以枚举之后最关键的一步是读取 UsbDevice 的四个属性vendorIdVID、productIdPID、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: HashMapString, 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.getParcelableExtraUsbDevice(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: CollectionUsbDevice, 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.getParcelableExtraUsbDevice(UsbManager.EXTRA_DEVICE) Log.d(UsbDemo, device attached: ${device?.deviceName}) refreshDeviceSelection() } UsbManager.ACTION_USB_DEVICE_DETACHED - { val device intent.getParcelableExtraUsbDevice(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 里最常见的是 bulkUSB_ENDPOINT_XFER_BULK用于大数据量块传输、interruptUSB_ENDPOINT_XFER_INTERRUPT用于小数据量周期传输、controlUSB_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_INtype 用 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_IMMUTABLEAndroid 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 就是按这个思路组织的字符串搜索、协议指令、缓冲区分配都有迹可循。希望这些踩过的坑帮你提前避掉把外设通信这层透明天花板捅破。本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询