1. 别急着改代码,先分清这三种“无权限”
做过 Android USB Host 开发的人都有一份共同的记忆:UsbManager、requestPermission、bulkTransfer 这几个 API 的名字熟得不能再熟,可一旦现场反馈“设备写不进数据”,翻遍搜到的老代码还是解决不了。我这几年接过的项目里,扫码枪、热敏打印模块、串口桥接板、工业采集盒都踩过同一类坑。最让人头疼的地方在于,Android 9.0 到 14 之间,同一句“USB 设备写数据无权限”,背后可能是四五种完全不同的原因:有的是权限对话框压根没弹出来,有的是弹了也点了允许但广播里的 extras 是空的,有的是 openDevice 直接返回 null,还有的是连接拿到了、接口也 claim 了,可 bulkTransfer 一直返回 -1。
这几种现象在日志上看起来都像“权限不足”,处理手段却完全不一样。你要是不先做分型,直接去改申请权限那几行代码,很可能改了三版还在原地打转。所以这篇文章我打算换个顺序来讲:先把现象切开,再往下拆权限链路,最后给到能直接抄的封装和排查命令。不管你是刚接第一台 USB 外设的新人,还是被“换个机型就跑不起来”折磨过的老手,下面这些内容应该都能帮你省点时间。文中涉及的设备、项目全部做了脱敏处理,只讲通用规律。
1.1 现象分型:三种失败位置,三套排查路径
第一种,权限申请阶段就失败了。表现是调用requestPermission之后什么都没发生,既没有系统弹窗,也没有广播回调,代码里等在那儿超时。这种情况在 Android 11 之后比较常见,原因大多和 PendingIntent 的可变性标志、动态广播注册方式有关,Android 12 和 14 各加了一道强制校验,漏了就直接抛异常或者静默收不到。
第二种,授权拿到手了,但打开设备失败。表现是广播收到了、EXTRA_PERMISSION_GRANTED也是 true,可紧接着usbManager.openDevice(device)返回 null。这种情况八成不是权限问题,而是设备对象本身已经失效——设备在你申请权限的过程中被拔出重新枚举了,或者你手里握着的 UsbDevice 是从缓存里拿的旧对象。Android 的 UsbDevice 是快照式对象,重新插拔后getDeviceList()里会是一个新的实例,拿旧的去 open 必然失败。
第三种,设备打开了,接口也 claim 了,写数据失败。表现是bulkTransfer返回 -1,或者返回的字节数小于预期。这一层和“权限”两个字关系最远,但用户和测试同学反馈时统一都会说“没权限”。真正的原因可能是端点方向搞反了、超时时间给得太短、设备侧缓冲区满、内核已经有 class driver 占着这个接口,甚至是你自己的另一个进程也在抢同一个 interface。
我在实际项目里养成了一个习惯:只要看到“无权限”三个字,先让对方把这三层的返回值都打出来——requestPermission 有没有回调、openDevice 是不是 null、bulkTransfer 返回什么。三个数字一出来,问题基本就定位到某一层了,比盲猜快得多。
注意:
bulkTransfer的 -1 只是一个笼统的失败信号,它不告诉你 errno。想知道具体原因,要么用UsbRequest异步方式配合requestWait拿状态,要么在 native 层用 ioctl 直接读返回值,这两条路后面都会讲到。
1.2 为什么 9.0 到 14 不能一套代码打天下
很多人以为 USB 权限这套东西十年没变,其实变了不少。Android 9 时代,随便一个动态注册的广播接收器就能收到权限回调,PendingIntent 也不讲究什么可变性。到了 Android 12,PendingIntent必须显式声明FLAG_IMMUTABLE或FLAG_MUTABLE,不写直接崩,这是硬性的运行时检查,跟 targetSdk 关系不大。
Android 13 开始,动态注册广播接收器要指定导出标志;Android 14 把这条要求变成了强制,漏写就抛SecurityException。与此同时,Android 14 对前台服务类型做了严格声明要求,如果你的写数据逻辑跑在后台 Service 里,类型没声明对,服务根本起不来,表现出来又是“连上了但写不了”。
再叠加各家的定制 ROM:有的把权限对话框做成了静默通过,有的在省电模式下干脆不弹窗,有的对 USB 附件的默认处理做了改写。所以我的做法是,所有和权限相关的代码路径都留一条“兜底分支”——超时之后主动重试一次、重试还不行就重新获取设备列表再申请一遍。这套逻辑看着笨,但在真实设备上救过我好几次。
2. USB Host 权限链路拆到底层
2.1 从 requestPermission 到 openDevice 的完整链路
想把问题解决干净,得知道这一路都发生了什么。应用调用UsbManager.requestPermission(device, pendingIntent),这个调用跨进程进到系统服务,系统服务先查缓存:当前用户、当前包名、这个设备,之前是不是已经授过权。有就直接发广播告诉你 granted,没有才弹对话框。
用户点允许之后,系统服务把这个授权记录在内存里,然后通过你传进去的 PendingIntent 发一条广播,广播里带两个关键字段:UsbManager.EXTRA_DEVICE和UsbManager.EXTRA_PERMISSION_GRANTED。你的接收器拿到 granted 为 true,再去调openDevice。openDevice内部会向系统申请打开这个设备的文件节点,成功后返回一个UsbDeviceConnection,这个连接对象里封装着一个指向/dev/bus/usb/xxx/yyy的文件描述符。
关键点在这里:普通应用从来没有直接访问/dev/bus/usb/节点的权限。那个节点的属主是 root 或者特定的系统组,权限位通常是 0666 之外的组合,应用进程根本打不开。你之所以能读写,完全是因为系统服务替你打开了节点,把 fd 通过 Binder 传给了你的进程。理解了这一点,后面很多“玄学问题”就通了——比如为什么有些 native 库非要去 open 节点路径,永远失败;比如为什么把 fd 传给 native 层就能正常工作。
2.2 权限的缓存与失效时机
这个缓存机制值得单独说,因为它解释了太多“上次明明能用”的现场。授权记录活在系统服务的内存里,键值大致是“用户 + 包名 + 设备标识”。在同一次插入周期内,你的应用被系统杀掉重启,重新调requestPermission往往不会弹窗,直接回调 granted,因为记录还在。但只要出现下面这几种情况,记录就没了:
- 设备被拔出再插入,设备标识重新生成,需要重新授权;
- 手机重启,内存里的记录清空;
- 应用被卸载重装,包名对应的记录被清理;
- 切换用户或者工作资料,用户维度变了;
- 部分定制 ROM 在应用长时间驻留后台后主动回收权限。
所以我的代码里从来不做“已经授过权了就不用再申请”这种优化。每次建立连接之前老老实实申请一遍,反正有缓存的话系统会立刻回调,成本极低,但能避免大量莫名其妙的失败。
注意:有些开发者喜欢把授权结果存进 SharedPreferences,下次启动直接读出来用。这个做法在真机上是危险的,因为系统侧的记录可能已经失效,而你的本地标记还写着 true,结果就是跳过申请直接 open,拿回一个 null,然后在别的地方找不到原因。
2.3 Android 9 到 14 的行为差异对照
把这几年的变化整理成一张表,排查的时候对着看会快很多。下面这些是我在实际项目中验证过的差异,不同 ROM 可能有细微出入,但大方向是一致的。
| 版本节点 | 变化点 | 对“写数据无权限”的影响 |
|---|---|---|
| Android 9 | 动态广播注册无额外约束 | 老代码基本能跑,问题多出在设备对象失效 |
| Android 10 | 后台启动界面受限 | 后台场景下权限弹窗可能不出现,需要切前台 |
| Android 11 | 包可见性收紧 | 若用queryIntentActivities判断处理方,需声明 queries |
| Android 12 | PendingIntent 必须声明可变性 | 漏写直接抛异常,表现为申请阶段崩溃或无响应 |
| Android 13 | 动态注册需导出标志 | 漏写时部分场景收不到回调 |
| Android 14 | 导出标志强制、前台服务类型强制 | 服务起不来或接收器注册抛异常,链条断在中间 |
这张表最实用的地方在于:当你在 Android 14 上遇到“授权广播收不到”,先别怀疑系统 bug,八成是注册接收器的时候少写了导出标志,或者你自己的接收器被当成了非导出广播而被过滤掉。
3. 授权流程的落地写法与三个版本坑
3.1 PendingIntent 加动态广播的标准组合
先把最稳的一套写法摆出来。这套代码我在 Android 9 到 14 的机器上都跑过,覆盖了主流 ROM。核心是三件事:自定义一个 action、注册接收器时带上导出标志、构造 PendingIntent 时显式声明可变性。
private const val ACTION_USB_PERMISSION = "com.example.app.USB_PERMISSION" private val permissionReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { if (intent.action != ACTION_USB_PERMISSION) return val device: UsbDevice? = intent.getParcelableExtra(UsbManager.EXTRA_DEVICE) val granted = intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false) val key = device?.deviceName if (key == null) { // 拿到空设备,说明 extras 没带过来,走兜底重试 onPermissionResult(null, false) return } onPermissionResult(device, granted) } } fun registerPermissionReceiver(context: Context) { val filter = IntentFilter(ACTION_USB_PERMISSION) ContextCompat.registerReceiver( context, permissionReceiver, filter, ContextCompat.RECEIVER_NOT_EXPORTED ) }RECEIVER_NOT_EXPORTED这个标志的意义在于告诉系统:我这个接收器不接受其他应用发来的广播。USB 权限广播是系统进程直接发给你这个包的定向广播,不走导出路径,所以用非导出标志完全能收到,还能少一个安全提示。用ContextCompat.registerReceiver的好处是它内部会按版本自动适配,Android 13 以下不会因为多了个参数而报错,一套代码通吃。
PendingIntent 的构造是另一个坑点,写法如下:
fun requestPermission(context: Context, device: UsbDevice) { val intent = Intent(ACTION_USB_PERMISSION).apply { setPackage(context.packageName) // 收敛作用域,避免广播外泄 } val flags = PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE val pending = PendingIntent.getBroadcast( context, device.deviceId, intent, flags ) val usbManager = context.getSystemService(Context.USB_SERVICE) as UsbManager usbManager.requestPermission(device, pending) }FLAG_IMMUTABLE是 Android 12 之后必须二选一里的推荐项。有些同学会担心加了不可变之后收不到 extras,这里可以放心,系统是通过PendingIntent.send把设备信息注入进去的,这个动作不受不可变标志影响。我在多台机器上验证过 extras 完整。唯一需要留意的是个别深度定制 ROM,如果确实出现 extras 为空,先把setPackage加上再退到FLAG_MUTABLE做对照实验。
device.deviceId当 requestCode 是个小技巧,能保证不同设备对应不同的 PendingIntent,避免多条申请互相覆盖。
3.2 三个必踩的适配坑:可变性、注册标志、服务类型
第一个坑,PendingIntent 可变性。Android 12 之前随便写,12 之后不写就崩。崩溃栈通常指向PendingIntent.getBroadcast,很多人第一反应是权限清单问题,实际上是标志位没给。修复方式就是上面代码里的FLAG_IMMUTABLE。要注意的是,如果你的项目里有多处创建 PendingIntent 的地方(通知、快捷方式、桌面组件),每一处都得改,漏一处就是一个线上崩溃。
第二个坑,广播注册的导出标志。Android 14 上 targetSdk 升到 34 之后,动态注册接收器不指定导出标志会抛SecurityException。这个异常信息写得比较直白,会告诉你需要指定。麻烦的是有些老项目把注册逻辑封在工具类里,异常被吞掉了,表现成“接收器没反应”。升级 targetSdk 的时候,建议全局搜一遍registerReceiver,逐个确认。
第三个坑,前台服务类型。如果你的写数据逻辑跑在一个后台 Service 里,Android 14 要求foregroundServiceType必须声明具体类型并申请对应权限,否则启动直接抛MissingForegroundServiceTypeException。USB 外设通信没有一个专属类型,实际项目中通常声明成connectedDevice,配合FOREGROUND_SERVICE_CONNECTED_DEVICE权限。这个改动看着和权限无关,但服务起不来,你的连接自然建不起来,最终反馈到用户那里还是“写不了数据”。
3.3 兜底:超时重试与失败分支怎么设计
系统弹窗这件事,你不能假设它一定会出现。省电模式、后台状态、某些 ROM 的静默策略,都可能让弹窗消失,而你的代码还在傻等回调。所以我的做法是给每次申请挂一个超时,比如 8 秒。超时之后先检查一遍usbManager.hasPermission(device),如果返回 true 说明权限其实已经有了,只是广播丢了,直接往下走;返回 false 就重新拉一次设备列表,用新的设备对象再申请一次。
重试最多两次,两次都失败就上报一个明确的错误码给业务层,让 UI 提示用户“请重新插拔设备”。这个提示看着土,但解决率相当高——重新插拔会触发设备重新枚举,权限状态和连接状态全部重置,很多卡死的情况一扫而空。
还有一点容易被忽略:hasPermission返回 true 不代表openDevice一定成功。这两个是不同层面的检查,前者查的是授权记录,后者查的是能不能真正拿到文件描述符。所以失败分支里要分别处理,日志里把两个结果都打出来,排查时能省很多事。
4. 特殊场景实操:无界面设备与 native 层写数据
4.1 工控、车机这类没有界面的场景怎么授权
带屏幕的手机做 USB 通信是简单模式,因为系统弹窗能正常显示。麻烦的是没有交互界面的场景,比如工控主板、自助终端、车载设备,用户根本点不到那个对话框。这类项目我在两个场景里遇到过,处理思路有共性。
思路一,走设备默认授权。系统设置里有针对 USB 设备的默认处理配置,部分 ROM 允许把某个 VID/PID 的设备设为默认允许。这条路依赖系统能力,不同 ROM 差异很大,不能当成通用方案,只能作为特定机型上的优化。
思路二,让应用在系统层面获得豁免。这属于系统集成范畴,需要预置配置,普通应用做不到。如果你的项目是整机交付,可以和系统侧协商,但不能指望这个方法在通用应用市场上生效。
思路三,也是我实际用得最多的,把授权动作前置到有人值守的时刻。设备首次接入时由现场人员点一次允许,之后只要不拔插、不重启,授权记录一直在。配合“开机自检”流程,在开机时把权限申请一遍,弹窗出现时正好有人在现场。这套方法的前提是业务上允许人工介入一次,多数工控场景是接受的。
如果以上都不满足,那就只能回到前台:把应用切到前台再发起申请。实测下来,前台状态下弹窗成功率明显高于后台,这跟 Android 10 之后的后台界面限制有直接关系。
4.2 把 fd 交给 native 层,别去 open 节点路径
这个坑我踩得最惨。项目里用了一个 native 库做协议解析,库的接口设计是传设备路径进去,内部自己 open。在桌面 Linux 上跑得好好的,一到 Android 上永远返回权限不足。原因前面说过:应用进程根本打不开/dev/bus/usb/下的节点。
正确做法是把 Java 层拿到的文件描述符传给 native 层,用 fd 直接做 bulk 传输。Java 侧UsbDeviceConnection.getFileDescriptor()能拿到这个整型 fd,通过 JNI 传下去。native 侧不要用write,usbfs 的批量传输走的是 ioctl:
#include <linux/usbdevice_fs.h> #include <sys/ioctl.h> #include <string.h> #include <errno.h> int usb_bulk_write(int fd, unsigned char endpointAddr, const unsigned char *data, int len, int timeoutMs) { struct usbdevfs_bulktransfer bulk; memset(&bulk, 0, sizeof(bulk)); bulk.ep = endpointAddr; // 端点地址,注意含方向位 bulk.len = len; // 本次传输长度 bulk.timeout = timeoutMs; // 毫秒 bulk.data = (void *) data; int ret = ioctl(fd, USBDEVFS_BULK, &bulk); if (ret < 0) { // errno 才是真正的原因:EACCES / EPIPE / ETIMEDOUT / ENODEV return -errno; } return ret; // 实际传输字节数 }这段代码的价值在于:errno会告诉你真实原因。EACCES是权限确实没给到位,EPIPE是端点被 stall 了需要清,ETIMEDOUT是设备没在超时内响应,ENODEV是设备已经不在了。有了这个信息,排查效率比 Java 层那个光秃秃的 -1 高出一个数量级。我现在的项目里,Java 层只负责授权和打开设备,真正的数据收发全部下沉到 native,日志里直接打 errno 名字。
用 fd 还有一个好处:UsbDeviceConnection关闭时会关掉这个 fd,native 层不能自己 close,否则会 double close 导致不可预期的行为。传下去的时候只读使用,生命周期跟着 Java 对象走。
4.3 bulkTransfer 返回 -1 的逐步定位
即便权限没问题,写数据失败依然常见。我一般按下面这个顺序排:
第一步,确认端点方向。UsbEndpoint.getDirection()返回 0 是 OUT(主机到设备),返回 128 是 IN。写数据必须用 OUT 端点。这个错误新手犯得最多,因为接口描述里端点是按顺序列的,第一个不一定是 OUT。而且端点地址本身带方向位,OUT 端点的地址通常是 0x01、0x02 这种,IN 端点则是 0x81、0x82。
第二步,确认接口已 claim。claimInterface(usbInterface, true)的第二个参数是 force,传 true 会强制从内核驱动手里抢。如果你要操作的接口被系统驱动占着,不 force 就 claim 失败。claim 失败的时候bulkTransfer自然也不会成功。
第三步,检查超时时间。默认给 1000 毫秒在低速设备上经常不够,尤其是打印类设备,机械动作本身就要时间。我一般起步给 3000,慢设备给 5000。超时太短的表现是偶发失败,第一次成功第二次失败,很容易被误判成“设备不稳定”。
第四步,看单次传输长度。bulk 传输单次不宜过大,超过 16KB 在部分设备上会被拒。我的做法是按 4KB 到 8KB 切块,循环发送,每块之间判断返回值,短包要补发。
第五步,检查设备侧状态。有些设备在缓冲区满的时候会 stall 端点,主机侧看到的就是连续失败。这时候需要发一个clearHalt或者控制传输复位端点,再重试。
fun writeAll(conn: UsbDeviceConnection, ep: UsbEndpoint, payload: ByteArray, chunk: Int = 4096): Boolean { var offset = 0 while (offset < payload.size) { val len = minOf(chunk, payload.size - offset) val buf = payload.copyOfRange(offset, offset + len) val sent = conn.bulkTransfer(ep, buf, len, 3000) if (sent < 0) { // 先清 halt,再重试一次 conn.controlTransfer(0x02, 0x01, 0, ep.endpointNumber, null, 0, 1000) val retry = conn.bulkTransfer(ep, buf, len, 3000) if (retry < 0) return false offset += retry } else { offset += sent } } return true }这段里controlTransfer(0x02, 0x01, ...)就是标准的 CLEAR_FEATURE(ENDPOINT_HALT),请求类型 0x02 表示方向为主机到设备、类型为标准、接收者为端点。这个补救动作在真实设备上命中率不低,值得加进去。
4.4 内核驱动已经占了设备的情况
这一类最容易被误判成权限问题。表现是:权限申请成功、openDevice也返回了对象,但claimInterface失败,或者 claim 成功之后写数据毫无反应。
原因在于 Android 内核里带了一批 USB class driver。当插入的设备属于存储类、音频类、HID 类时,内核驱动会先一步把接口绑走。U 盘插上会被挂载,USB 音箱插上会被识别成音频输出,键盘插入会直接变成输入设备——这些都不是应用能抢的。你就算 force claim 成功,内核那边可能还在并行操作同一个端点,数据自然乱套。
判断方法很直接:看这个设备的接口类。存储类是 0x08,音频类是 0x01,HID 是 0x03。如果你的设备是复合设备(比如一个带存储功能的采集盒),可能会出现“vendor 接口能用、存储接口抢不到”的情况,这时候只 claim 自己需要的那个接口,别碰其他接口。
还有一个隐蔽的情况:同一个应用的两个进程同时打开同一台设备。授权是按包名给的,两个进程都能通过openDevice,但同一个接口在同一时间只能被一个连接 claim。第二个进程 claim 时就会失败,而且错误信息不明显。多进程架构的项目要特别注意,尽量把 USB 通信收敛到单进程,或者自己做进程间互斥。
5. 常见问题速查与排查命令
5.1 症状、原因、处置对照表
这张表是我这些年攒下来的,基本上现场反馈的那几种说法都能对上号。
| 现象 | 最可能的原因 | 处置方式 |
|---|---|---|
| 申请权限后无任何回调 | 接收器未注册成功或标志位缺失 | 检查registerReceiver的导出标志,用兼容 API |
| 收到广播但设备为 null | PendingIntent 的可变性或作用域问题 | 加setPackage,验证FLAG_IMMUTABLE |
| 广播 granted 为 true 但 open 返回 null | 设备对象已失效 | 重新getDeviceList取新对象 |
| bulkTransfer 一直 -1 | 端点方向错、超时短、接口未 claim | 逐项核对,加 clearHalt 重试 |
| 首次可用,重插后不可用 | 授权记录随插拔清空 | 每次连接前重新申请 |
| 换台机器就不行 | ROM 差异或内核驱动占用 | 打印接口类,检查是否被系统接管 |
| 服务里写数据直接崩溃 | Android 14 前台服务类型未声明 | 声明connectedDevice类型与权限 |
用这张表的时候有个技巧:先看“有没有回调”,这一步能砍掉一半的分支。有回调说明注册和 PendingIntent 都没问题,问题在后面;没回调,那就是注册链路的事,往下查注册代码就够了。
5.2 adb 与日志排查的实用套路
命令行工具用得熟,排查能快很多。下面这几条是我常用的:
# 看系统识别的 USB 设备列表(需要相应权限,部分机型可用) adb shell dumpsys usb # 持续抓取 USB 相关日志,插拔一次看一轮 adb logcat -c && adb logcat | grep -iE "usb|UsbService|UsbHostManager" # 查看设备节点权限(确认应用是否真的没有直接访问权) adb shell ls -l /dev/bus/usb/001/ # 查看内核侧 USB 枚举过程 adb shell dmesg | grep -i usbdumpsys usb的输出里能看到当前已连接设备的 VID/PID、接口信息,以及部分机型的权限授予记录。dmesg里能看到内核把这个设备识别成了什么类、绑定了哪个驱动,这一步对判断“是不是被系统抢走了”特别关键。
还有一个小技巧:抓日志的时候把应用侧的关键返回值也打出来,用统一的 tag。我习惯用USB_PERM作为 tag,把申请开始、广播回调、granted 值、openDevice 结果、claim 结果、每次 bulkTransfer 的返回值全打一行。出问题时让现场同事抓一段日志发过来,对着时间轴一看就知道断在哪一步。
注意:
dmesg在部分新版本系统上普通 shell 权限受限,拿不到完整输出。这种情况下可以退而求其次,用dumpsys usb加上应用侧日志,配合判断。
6. 工程化封装:扛住插拔的权限管理模块
6.1 类设计与核心方法
零散地写权限代码,维护起来非常痛苦。我现在的做法是抽一个独立的权限管理类,对外只暴露两个方法:ensurePermission(device, callback)和release()。内部管三件事:接收器的注册与反注册、PendingIntent 的构造、超时与重试。
class UsbPermissionManager(private val context: Context) { private val usbManager = context.getSystemService(Context.USB_SERVICE) as UsbManager private val listeners = ConcurrentHashMap<Int, (Boolean) -> Unit>() private var registered = false fun ensurePermission(device: UsbDevice, callback: (Boolean) -> Unit) { if (usbManager.hasPermission(device)) { callback(true) return } listeners[device.deviceId] = callback registerIfNeeded() doRequest(device, retry = 0) } private fun doRequest(device: UsbDevice, retry: Int) { // 构造 PendingIntent 并申请,挂超时 // 超时后:hasPermission 复查 -> 重新取设备 -> 再申请 } fun release() { if (registered) { context.unregisterReceiver(receiver) registered = false } listeners.clear() } }这个类有两个设计要点。第一,用deviceId做 key 管理回调,避免多个设备同时申请时回调串台。第二,ensurePermission的第一步永远是hasPermission快查,命中就直接回调,减少一次系统交互。这个快查在onResume、页面切换这类高频调用场景下很有用,能让体验顺畅不少。
release里必须反注册接收器,否则会泄漏。我见过项目里在 Activity 的 onDestroy 里漏了反注册,长时间使用后出现多个接收器同时响应同一条广播的问题,表现是回调被调用两三次,业务逻辑重复执行,排查了好久才发现。
6.2 上线前必须跑一遍的自测清单
这套东西上线前,我固定会跑下面这些场景,一个都不能少:
- 冷启动后首次插入设备,授权弹窗是否正常出现,点允许后能否立刻写数据;
- 授权后拔掉再插上,不重启应用,能否重新授权成功;
- 授权后杀掉应用进程再启动,能否正常连接(验证缓存生效);
- 手机重启后再插设备,首次授权流程是否正常;
- 两台同型号设备同时插入,能否分别授权、分别通信;
- 设备插入时应用在后台,切回前台后授权能否成功;
- 在 Android 9、11、13、14 各找一台真机跑一遍完整流程;
- targetSdk 升级后重新跑一遍,重点看注册和服务启动相关的异常。
最后这条特别重要。targetSdk 的升级会激活一批之前被兼容模式屏蔽的严格检查,我遇到过升级之后才发现前台服务类型没声明的情况,功能测试全过,只在 Android 14 真机上崩。所以版本升级的时候,别只跑单元测试和模拟器,必须上真机,而且要在最新系统版本上过一遍。
我在实际项目里踩过的坑,大部分不是 API 有多难,而是版本之间的行为差异加上各家 ROM 的小改动,让你以为代码没问题。每次接新机型、每次升 targetSdk,都把上面这套流程走一遍,比事后救火舒服得多。