简介:一套针对Android OTG U盘读写场景的工程包,解决受限系统下无法直接获得外接存储路径的问题。资料面向已有一定基础、希望掌握外设存储访问的移动开发人员,完整演示运行时权限申请、使用反射读取MediaScannerConnection的sExternalStorageVolumeMap字段,遍历SparseArray并依据VolumeInfo类型筛选出TYPE_SD_CARD或TYPE_USB对应的U盘路径,再通过FileInputStream/FileOutputStream完成读写。实现中特别处理了Android 6.0以上动态权限、KitKat后外部存储目录变化,以及反射私有API在后续版本可能失效、需配合异常处理与版本适配,并给出文件读写前后关闭流、异常捕获等细节。压缩包共110个文件、约518KB,包含64个xml布局与配置、10个png图标、8个bin缓存、6个properties工程属性、3个kt与2个java源码,以及gradle脚本和构建辅助文件,结构清晰便于导入或对照学习,关键路径注释清晰,便于直接移植或二次改造。资源已有1355人学习,适合需要理解OTG机制、反射调用系统私有接口或拓展外设功能的Android开发者。
1. OTG U盘读写为何绕不开反射:公开API与挂载点的距离
OTG线插上U盘,自带文件管理器里能看到内容,但你写的应用遍历存储卷,返回列表里却没有U盘。这不是代码逻辑错了,而是Android公开的存储卷接口和真实U盘挂载点之间有一层不透明的系统实现差异。这个问题在部分品牌设备、工业主板上尤其常见。想在代码里拿到U盘路径,最稳妥的办法是绕过公开API,用反射把StorageManager里的隐藏方法调出来,再通过私有字段读出挂载点。本文把这套流程拆开讲:原理、反射实现、读写落盘、常见坑和验证方法。适合做数据采集终端、信息发布屏、医疗设备等需要OTG U盘做数据交换的开发者。整套方案整理成了可复用的工具类,下面的代码和调试思路可以直接贴合项目使用。
2. 从StorageVolume源码看U盘路径:哪些字段值得反射
2.1 公开API看不见OTG挂载点,问题不出在权限
很多开发者第一反应是权限没给。实际上把READ_EXTERNAL_STORAGE、WRITE_EXTERNAL_STORAGE都申请一遍,getStorageVolumes()返回的列表里依然没有U盘。这是因为公开API拿到的卷列表由系统服务计算生成,对可移动卷的登记策略在不同ROM之间并不一致。有的系统对OTG卷延迟登记,有的干脆不往返回列表里放,应用层没有任何线索知道U盘挂载在哪里。
要看透这个问题,得回到vold与StorageManagerService的协作。U盘插入后,内核扫描到块设备,vold在/storage/下建立挂载点,同时向系统服务注册一个卷对象。应用层调用StorageManager接口时,实际走的Binder通道拿到的是系统服务维护的卷列表。如果这个列表的维护逻辑没把U盘卷放进去,应用层就算权限齐全也拿不到路径。
所以常规做法就变成:不依赖公开列表,直接用反射调StorageManager.getVolumeList()。这个方法在较新API里是非SDK接口,但在大量设备上仍然保留,返回的是StorageVolume[]数组,里面会带上OTG挂载点。拿到数组后用反射读取对象字段来判断哪个是U盘。这套做法是目前做外设读写的通用兜底方案。
另一个常见的反射入口是StorageManager.getVolumePaths(),它直接返回String[]路径数组,省去了遍历对象的步骤。不过它不区分内部存储和可移除卷,需要自己配合卷状态过滤。我一般首选getVolumeList(),因为它能拿到更完整的对象信息,路径、可移除标志、卷ID都能一次反射出来,后续扩展判断空间大。
注意:这两种方法都属于非SDK接口,在API 29以上设备可能被hidden-api policy限制,抛NoSuchMethodException。遇到这种情况需要先看项目的targetSdk是否高于28,再决定是否走白名单豁免,细节在第5章展开。
2.2 反射前需要认识的StorageVolume关键字段
反射取值前先搞清楚每个字段的性质。mPath是挂载路径,U盘通常是/storage/XXXX-XXXX格式,这个XXXX-XXXX是vold分配的设备标识,不是文件系统卷标。mRemovable是布尔值,表示是否可移除,这是过滤U盘的关键依据,但部分ROM把这个字段写错,不能单独依赖。mState是卷状态,常量值在不同版本上不一致,硬编码数字容易翻车。mStorageId是卷ID,多分区时可以用来区分同一个U盘的多个分区。mDescriptionId关联卷标字符串,如果U盘带卷标,这里能读出名称信息。
实际操作中我一般把mPath作为主判据,mRemovable作为辅助,mState只在老版本设备上参考。原因是路径特征最稳定,/storage/开头的可移除挂载点基本就是U盘;mState在不同固件里的枚举顺序不一样,靠它判断挂载状态经常出错。下表是字段间的分工:
| 字段 | 类型 | 作用 | 过滤建议 |
|---|---|---|---|
| mPath | String | 挂载点路径 | 必须,排除/storage/emulated |
| mRemovable | boolean | 是否可移除 | 参考,不能单独作为判断 |
| mState | int | 挂载状态 | 慎用,不同版本常量值不一致 |
| mStorageId | int | 卷ID | 多分区去重时使用 |
| mDescriptionId | int | 卷标描述资源ID | 输出设备名称时使用 |
为了拿到这些字段,需要两个反射动作:反射方法拿卷对象,反射字段拿属性。第一段代码从方法反射开始:
// 反射调用 StorageManager.getVolumeList() private static Object[] reflectGetVolumeList(StorageManager sm) throws Exception { Class<?> clazz = StorageManager.class; Method method = clazz.getMethod("getVolumeList"); method.setAccessible(true); return (Object[]) method.invoke(sm); }逻辑说明:getVolumeList()是隐藏方法,正常编译期无法直接调用,用getMethod()按方法名取出,setAccessible(true)放开访问限制,再invoke执行。返回的是StorageVolume[]数组的Object形式,后面逐个元素做字段反射。
参数说明:sm是从context.getSystemService拿到的StorageManager实例,没有系统版本限制。如果目标系统API太新,getVolumeList()被策略拦截,会抛NoSuchMethodException,此时要捕获后回退到getStorageVolumes()遍历,或者干脆走StorageVolume.getDirectory()方式。
取字段的代码:
private static boolean readRemovableFlag(Object volume) { try { Class<?> clazz = volume.getClass(); Field field = clazz.getDeclaredField("mRemovable"); field.setAccessible(true); Object value = field.get(volume); return value instanceof Boolean ? (Boolean) value : "true".equals(String.valueOf(value)); } catch (Exception e) { return true; // 字段缺失时默认可移除,放宽过滤 } }这里有细节值得注意:getDeclaredField只查当前类声明的字段,StorageVolume本身就有这些字段,不需要遍历父类。个别ROM会对这些字段改名,或者做代码混淆,字段找不到时返回默认值,避免整个工具类崩溃。
2.3 从UsbDevice到MountService:一条完整的链路
反射只是手段,理解U盘路径从哪来才算真正掌握。U盘插入后,USB Host控制器发现新设备,UsbManager通过getDeviceList()能拿到UsbDevice对象,这是应用层能看到的第一层信息。但UsbDevice只描述USB设备属性,比如VID、PID、接口类,不包含文件系统路径。接下来内核块设备驱动注册一个/dev/block/sda节点,vold负责格式化挂载,生成/storage/XXXX-XXXX目录并把块设备挂上去。
U盘读写的实际写入目标是挂载点下的文件,不是块设备节点。所以反射的目的是把vold这一层创建的挂载路径暴露给应用。这个链路里任何一环出问题都会导致路径拿不到:内核没识别到U盘时getDeviceList()为空,vold挂载失败时没有挂载点,系统服务没登记卷时反射返回空数组。排查时先看哪一环断了,比盲目改反射代码更有效率。
3. 反射获取U盘路径的完整实现:过滤、去重与可用性探测
3.1 完成一个可直接使用的路径查找器
把上一章的反射动作封装成工具类,调用方传Context,返回可写U盘路径列表。完整实现如下:
public class UdiskPathFinder { private static final String SDCARD_PREFIX = "/storage/emulated"; public static List<String> getUsbWritablePaths(Context context) { List<String> candidates = new ArrayList<>(); List<String> results = new ArrayList<>(); StorageManager sm = (StorageManager) context.getSystemService(Context.STORAGE_SERVICE); if (sm == null) return results; try { Object[] volumes = reflectGetVolumeList(sm); if (volumes == null) return results; for (Object volume : volumes) { String path = readFieldString(volume, "mPath"); boolean removable = readFieldBoolean(volume, "mRemovable"); if (path == null || path.isEmpty() || path.startsWith(SDCARD_PREFIX)) { continue; } if (!path.startsWith("/storage/")) { continue; } if (removable || looksLikeUsbPath(path)) { candidates.add(path); } } } catch (Exception e) { e.printStackTrace(); } for (String path : candidates) { File dir = new File(path); if (dir.exists() && dir.canWrite()) { results.add(path); } } return results; } private static boolean looksLikeUsbPath(String path) { String lower = path.toLowerCase(); return lower.contains("usb") || lower.contains("otg") || lower.matches(".*[a-f0-9]{4}-[a-f0-9]{4}.*"); } }逻辑说明:三段式过滤。第一段排除/storage/emulated,这是内部存储的软链接地址,永远不该进U盘列表;第二段按路径前缀过滤,U盘挂载点几乎都在/storage/下;第三段用可移除标志或路径特征双保险,避免遇到ROM把removable字段写反的情况。最后用exists()和canWrite()做落地校验,确保返回的路径真的能写。
参数说明:looksLikeUsbPath里的正则匹配XXXX-XXXX形态,这是vold给FAT32/exFAT卷分配挂载目录的常见格式。有的设备挂载点是/storage/usb0、/storage/otg,所以要加关键词匹配。如果项目跑在特定工控设备上,U盘挂载点固定在/mnt/usbhost/,就把/storage/前缀改成配置项,不要写死在代码里,方便适配不同现场。
3.2 多分区U盘的去重和分区筛选
一个物理U盘可能有多个分区,FAT32加exFAT再加一个EFI分区,系统会给每个分区都创建挂载点,看起来像多个独立的存储卷。如果项目只需要读写第一个数据分区,得先把这些路径按物理设备归组。StorageVolume里有个mDiskId字段,同一物理设备上的分区共享同一个disk id,反射出来后用它做分组是最可靠的办法。
private static String readDiskId(Object volume) { if (volume == null) return null; try { Field field = volume.getClass().getDeclaredField("mDiskId"); field.setAccessible(true); Object value = field.get(volume); return value == null ? null : String.valueOf(value); } catch (Exception ignored) { return null; } }拿到disk id后按Map分组,每组选一个可写的路径作为主分区。需要注意如果某台设备的ROM不维护mDiskId,返回的是null,这时回退到按路径排序取第一个可写的。多分区场景下还有一个常被忽略的点:U盘上用ext4做分区时,部分老内核设备只识别第一个FAT分区,应用层无法绕开这个限制,只能在说明文档里提示U盘格式化成单分区FAT32更稳。
3.3 卷标读取与设备识别增强
有的项目需要在界面上显示“U盘已连接:SanDisk 32GB”这样的文案,这就要读取卷标。mDescriptionId是资源ID,直接反射拿不到字符串值,需要再走一层context.getResources().getString()。但要注意这个资源ID指向的是com.android.internal.R.string,用应用自己的Resources去取可能崩溃,常规做法是反射Resources.getText()配合系统上下文。
想绕过资源ID的麻烦,可以直接用StorageVolume.getDescription(context)这个公开方法,在API 24之后它是public的,U盘卷标会通过它返回。但这个方法在部分ROM实现上返回的是“可移动存储”这种通用文案,而不是真实卷标。如果需要真实卷标,可以反射getUserVisibleLabel()或mDescriptionId匹配,不同版本路径不同,代码里要做多级try-catch兜底。
4. U盘读写落地:权限叠加、大文件缓冲与强制落盘
4.1 USB设备权限与存储权限的叠加关系
拿到路径不等于可以打开文件。OTG链路上有两道权限:第一道是USB设备访问权限,通过UsbManager申请;第二道是文件系统层面的读写权限,由运行时权限和分区存储策略控制。两个都满足才有完整的写入能力。
UsbManager usbManager = (UsbManager) context.getSystemService(Context.USB_SERVICE); HashMap<String, UsbDevice> devices = usbManager.getDeviceList(); if (devices.isEmpty()) { // 没有插入任何USB设备,直接返回 } UsbDevice usbDevice = null; for (UsbDevice device : devices.values()) { if (device.getDeviceClass() == UsbConstants.USB_CLASS_MASS_STORAGE || device.getInterface(0) != null && device.getInterface(0).getInterfaceClass() == UsbConstants.USB_CLASS_MASS_STORAGE) { usbDevice = device; break; } } if (usbDevice != null && !usbManager.hasPermission(usbDevice)) { PendingIntent intent = PendingIntent.getBroadcast( context, 0, new Intent(ACTION_USB_PERMISSION), PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE); usbManager.requestPermission(usbDevice, intent); }逻辑说明:先把设备列表拉出来,按USB_CLASS_MASS_STORAGE过滤出U盘设备。有些U盘主控把接口类声明成别的值,也可以用VID/PID白名单兜底,但VID/PID需要提前采集设备样本,不通用。拿到设备对象后检查hasPermission,没有就发requestPermission,授权结果通过广播回调返回。注意PendingIntent的FLAG_IMMUTABLE在API 31之后不加会直接抛异常,这是适配新设备时最容易忽视的一点。
参数说明:ACTION_USB_PERMISSION是自定义广播Action,需要在Manifest里注册对应的BroadcastReceiver。回调里检查EXTRA_DEVICE和EXTRA_PERMISSION_GRANTED两个值,前者确认设备对象一致,后者确认授权结果。注册接收器这块经常被漏掉,漏了的话调用requestPermission会一直等不到回调,界面卡死。
文件系统层面的权限,Android 6以上要运行时申请存储权限,API 30以上分区存储对包外路径的写入有额外限制。反射拿到的路径大多是/storage/XXXX-XXXX这种外部卷路径,普通应用在分区存储模式下可能无法直接写。API 30以上设备我一般先尝试直接写,如果抛FileNotFoundException或EACCES,再改用StorageVolume.createAccessIntent()拉系统授权对话框,让用户确认整卷访问。这个差异在避坑章详细展开。
4.2 大文件写入:缓冲大小、flush时机与fsync
U盘写入比内部存储更需要注意落盘时机。内部存储掉电数据丢失的概率低,但U盘是外设,突然拔出、供电不稳,文件系统很容易只剩半截数据。写入代码要主动控制缓冲和同步。
File targetFile = new File(usbPath + "/sensor_log.bin"); byte[] buffer = new byte[128 * 1024]; try (FileOutputStream fos = new FileOutputStream(targetFile); BufferedOutputStream bos = new BufferedOutputStream(fos)) { while (hasNextChunk(buffer)) { int readLen = readNextChunk(buffer); if (readLen <= 0) break; bos.write(buffer, 0, readLen); } bos.flush(); fos.getFD().sync(); } catch (IOException e) { e.printStackTrace(); }逻辑说明:BufferedOutputStream默认缓冲8KB,但U盘这种块设备,一次写128KB和写8KB,写放大差异明显,性能能差好几倍。flush()把缓冲区的数据交给文件描述符,但此时数据还在内核页缓存;fos.getFD().sync()才把脏页真正刷到U盘存储介质。日志数据在sync()之前拔掉U盘,文件长度可能停留在0字节或旧状态,这是U盘写入最常见的丢数据原因。
参数说明:缓冲大小建议根据U盘主控的块大小调整,64KB到256KB是主流区间,128KB比较通用。如果项目是边采样边写,每轮数据不足128KB,不要手动频繁flush,让缓冲层积攒到接近满块再落盘,能显著减少写次数。但跨小时持续写入时,必须每隔一段时间做一次sync(),防止日志丢失窗口拉太长。
fos.getFD().sync()有个前提:文件必须用FileOutputStream打开,FileWriter拿不到文件描述符。另一个细节是sync()会阻塞调用线程,U盘写入慢的时候一次可能卡几十到几百毫秒,不要在UI线程直接调用,放到工作线程,写入完成后通过Handler回调状态。
5. 避坑实录:U盘路径与写入的五个常见翻车点
5.1 把内部存储的软链接误判成U盘
现象:getUsbWritablePaths()返回了/storage/emulated/0,应用把日志写进了内部存储,U盘路径完全没被使用。原因:某些系统把内部存储卷的mRemovable字段错误置为true,路径过滤又只看单个条件时就会中招。解决:过滤条件里必须排除/storage/emulated前缀,再用路径特征做第二道校验,双保险之后基本能挡掉。这类问题最坑的是不报错,数据写入成功但落在错误设备上,排查起来要逐条对比路径列表才能发现。
5.2 canWrite()返回true但写入仍然失败
现象:new File(usbPath).canWrite()为true,FileOutputStream构造成功,但执行write()抛IOException: Permission denied。原因:SELinux的neverallow规则限制了应用对外部卷的直接访问,canWrite()检查的是文件系统权限位,不包含SEAndroid策略判断。解决:先看logcat里有没有avc: denied关键字,有的话基本可以确定是SELinux拦截。常规做法是改用SAF引导用户授权整个U盘目录树,或者申请MANAGE_EXTERNAL_STORAGE权限,但注意应用市场对这项权限有审核要求,要提前准备说明材料。
5.3 拔掉U盘再插入,原路径失效
现象:第一次插入拿到的路径是/storage/3A98-4F2B,写入正常,拔掉再插入后同一个路径下文件不可读,或者路径直接变成了另一串标识。原因:vold重新挂载时,如果上一次卸载不干净,会在同一个路径上重建;如果块设备标识被新设备抢占,就会换新路径。应用层缓存了旧路径,自然对不上。解决:不要在内存里缓存U盘路径,每次插入都重新跑一遍反射扫描;同时注册ACTION_MEDIA_MOUNTED和ACTION_MEDIA_UNMOUNTED监听,在回调里强制刷新路径列表。热插拔场景下,写文件前最好再检查一次dir.exists(),降低竞态概率。
5.4 exFAT格式U盘在部分设备上没有挂载点
现象:U盘插上后getUsbWritablePaths()返回空列表,系统自带文件管理器也看不到内容。原因:内核没有编译exFAT模块,vold无法挂载exFAT卷,这属于设备硬件和系统能力边界,不是应用层反射能解决的。解决:给用户明确提示当前U盘为exFAT格式、本设备不支持,请格式化为FAT32后重试。如果产品能控制生产环境,直接要求U盘统一用FAT32格式化,省去无用适配。但FAT32对单文件有4GB上限,单文件超过4GB时要在应用层拆分成多个分段文件再写入,这个逻辑要在写文件的入口处做判断。
5.5 API 30以上反射路径批量写入被分区存储拦截
现象:targetSdk 30以上的应用,在Android 11设备上写/storage/XXXX-XXXX路径时报ENOENT或EACCES,同样的代码在Android 10设备上正常。原因:分区存储强化后,应用对外部卷的隐式写权限被收回,反射拿到的路径在系统层面被隔离。解决:把targetSdk降到28以下不是长久之计。常规做法是判断SDK_INT大于等于30时,用StorageVolume.createAccessIntent()拉整卷授权;如果目标设备无法控制文件路径,就用ActivityResultContracts.OpenDocumentTree让用户选择U盘根目录,拿到Uri后用DocumentFile读写。反射路径方案在API 29及以下仍是首选,API 30以上只做兜底。
6. 验证一条龙:CRC32校验与多分区U盘批量适配
写完文件不代表数据真的完整落盘了。这里给出一个轻量级的验证方法:写入完成后把文件读回来计算CRC32,和写入前原始数据的特征值对比,相等才算成功。这个步骤能同时暴露两个问题:缓冲区有没有漏写、sync有没有真正把数据刷下去。
public static String crc32OfFile(File file) throws IOException { Checksum checksum = new CRC32(); try (BufferedInputStream in = new BufferedInputStream( new FileInputStream(file), 128 * 1024)) { byte[] buffer = new byte[128 * 1024]; int len; while ((len = in.read(buffer)) != -1) { checksum.update(buffer, 0, len); } } return Long.toHexString(checksum.getValue()); }逻辑说明:分块读入,每块更新一次校验对象,避免一次性把大文件载入内存。返回的十六进制字符串可以存到SharedPreferences里做对比。如果新算出来的值等于写入前记录的值,说明文件内容完整;不相等则说明写入过程有数据丢失,需要检查缓冲和sync链路。
参数说明:CRC32不是加密算法,只做完整性校验,适合日志搬运、固件升级包这些场景。如果数据需要防篡改,就把校验算法换成MD5或SHA-256,接口形式不变。要注意U盘的写入校验可能耗时较长,放在后台线程跑,界面上提示“正在校验”。
多分区适配可以这样批量处理:getUsbWritablePaths()返回多个路径后,依次执行写文件、校验、删除校验文件三步,任何一个分区写失败都不影响其他分区的处理,最后汇总整体状态。批量写入时尤其要注意第5.1节的问题,别把内部存储路径混进批量列表。
以前我在某项目的数据采集终端上,为了省事写完没调sync直接返回成功,现场断电测试丢了一批日志,被客户投诉后才彻底改了流程。从那以后每次在U盘上写完数据,我都强制走一遍flush + sync + CRC32校验,这个习惯帮我避开了不少数据完整性事故。这套反射路径查找和写入校验工具,下载包里有完整的UdiskPathFinder类和对应权限配置,拿到后先在你自己的设备上跑一次getUsbWritablePaths(),确认过滤规则符合实际场景再接进业务代码。希望帮到你。
本文还有配套的精品资源,点击获取