Android反射获取U盘真实路径:StorageManager隐藏API与VolumeInfo实战 简介这是一份面向 Android 开发者的 OTG U盘读写示例工程解决应用无法直接获取 U 盘挂载路径的常见问题。资源包含完整的 Android Studio 项目文件共110个文件、压缩后约518KB其中 xml 布局与配置、kotlin/java 源码、gradle 构建脚本、png 资源等一应俱全可直接导入工程查看权限声明、反射调用与文件读写逻辑。作者从 Android 存储架构入手给出运行时权限申请、MediaScannerConnection 私有字段反射遍历 VolumeInfo 的思路并配有读写文件的实现流程方便读者在真机 OTG 场景下对照调试。针对反射依赖系统私有 API 的兼容性风险资源中也提示了后续适配注意事项。目前已有一千三百余人学习下载适合有 Android 基础、正为 U 盘或 OTG 外设存储接入苦恼的应用开发人员。 拿到“在 Android 上读取 U 盘文件”这个需求时我原以为查一下 API 就完事了。结果翻遍官方文档发现系统把 U 盘、SD 卡、内置存储统一归为“卷Volume”管理但所有能拿到真实挂载路径的接口都带hide注解SDK 里根本编译不过去。折腾了一阵之后我老老实实走上了反射路线把 StorageManager 里隐藏的 VolumeInfo 翻出来再通过 DiskInfo 判断是不是 USB 设备最后拿到/storage/XXXX-XXXX这样的真实路径。这条路一旦走通后面做插拔监听、盘符识别、文件系统判断都顺手很多值得整理成文。这篇文章适合做文件管理器、数据采集、OTG 设备适配的 Android 开发者参考。1. 为什么拿个U盘路径还要反射系统API的边界与隐藏方法1.1 系统其实管着U盘但接口不在SDK里Android 的存储架构里StorageManager 是总管卷Volume的核心服务。U盘插入后底层 vold 守护进程负责检测、分区、挂载然后通过 MountService 把挂载状态同步到 Java 层。StorageManager 内部维护着当前所有卷的列表其中就包含 USB 存储卷。但问题在于官方 SDK 暴露给开发者的只有一层缩水版 APIStorageManager.getStorageVolumes()返回StorageVolume[]里面只有getDirectory()、getDescription()这类信息既没有“这是不是 U 盘”的标识也没有文件系统类型。而真正含金量高的getVolumeList()、VolumeInfo、DiskInfo 全部是隐藏 API普通工程里根本见不到。这是 Android 的经典风格框架能力完整但对应用层保密。Google 希望开发者一律走 Storage Access FrameworkSAF去访问外部存储Uri 一给路径也不让你知道。可很多场景——比如给嵌入式设备批量拷贝数据、读取采集仪录制的文件、配合 Native 层做文件解析——就是需要真实的文件路径SAF 的 Uri 体系反而碍手碍脚。1.2 反射的本质以及为什么比解析挂载点靠谱反射在这里的原理很朴素Java 运行时的类信息不会因为 SDK 隐藏就消失方法、字段在 ART 虚拟机里都真实存在。我们用Class.getMethod()按名字把隐藏方法拉出来再调用即可跨越编译期的限制。有人可能会说不反射也行我直接遍历/storage目录或者解析/proc/mounts也能找到 U 盘路径。我不否认这些土办法能跑但实战中问题很多/storage下可能有多个目录你没法可靠区分哪个是内卡、哪个是 U 盘、哪个是 SD 卡。/proc/mounts里挂载点是内核视角部分厂商 ROM 会隐藏或改写真实路径解析规则要跟着设备适配。两个 U 盘同时插着OTG HUB土办法很难区分谁是谁。反射拿到的 VolumeInfo 是系统的标准答案它明确告诉你卷的类型public/private/emulated、挂载状态、磁盘信息USB 还是 SD、文件系统类型、卷标。信息全、结构稳后续所有逻辑都建立在标准框架之上这才是它最大的价值。2. 反射链路拆解StorageManager、VolumeInfo和DiskInfo三层结构2.1 反射入口getVolumeList 方法核心入口很简单就是StorageManager.getVolumeList()。注意这个方法名和getStorageVolumes()不一样是早期版本遗留下来的隐藏方法内部返回VolumeInfo[]。反射调用代码如下StorageManager sm (StorageManager) context.getSystemService(Context.STORAGE_SERVICE); Method getVolumeList StorageManager.class.getMethod(getVolumeList); Object[] volumes (Object[]) getVolumeList.invoke(sm);拿到的是一个Object[]每个元素的实际类型是android.os.storage.VolumeInfo。因为 VolumeInfo 是隐藏类编译期不能强转只能继续用Object引用走全反射或getMethod()调用它的公开方法。2.2 VolumeInfo 和 DiskInfo 里的字段含义要理解整个反射过程得先搞清 VolumeInfo 和 DiskInfo 两张表。VolumeInfo 是一个“卷”的完整描述关键方法如下方法返回含义getType()int卷类型0 public可移除外置存储1 emulated内置模拟存储2 private内置加密存储getPath()File挂载路径读取 U 盘时最重要的返回值getDisk()DiskInfo对应的磁盘对象判断是 USB 还是 SD 的关键getFsType()String文件系统类型比如 vfat、exfat、ntfsgetFsLabel()String卷标很多 U 盘的名称isMountedWritable()boolean当前卷是否已挂载且可写DiskInfo 则描述“物理磁盘”它里面有一个getFlags()或者mFlags字段标志位定义了磁盘类型标志位值含义FLAG_ADOPTABLE1 0可合并内置存储的卡FLAG_USB1 1USB 设备也就是 U 盘FLAG_SD1 2SD 卡2.3 一个能跑的反射工具方法综合上面信息我先给出一个能直接用的反射获取 U 盘路径的工具方法。这里我把“取 flags”和“取 type”做了兼容处理优先反射调用getFlags()和getType()某些旧版本 ROM 调用失败时再退回读字段。public static ListUsbVolume getUsbVolumes(Context context) { ListUsbVolume result new ArrayList(); try { StorageManager sm (StorageManager) context.getSystemService(Context.STORAGE_SERVICE); Method getVolumeList StorageManager.class.getMethod(getVolumeList); Object[] volumes (Object[]) getVolumeList.invoke(sm); if (volumes null) return result; for (Object vol : volumes) { Class? volClass vol.getClass(); // 1. 只看 public 卷可移除的外置存储 int type invokeIntMethod(vol, getType, -1); if (type ! 0) continue; // 2. 取 DiskInfo判断是不是 USB 设备 Object disk invokeObjectMethod(vol, getDisk); if (disk null) continue; int flags getDiskFlags(disk); // FLAG_USB 1 1 if ((flags (1 1)) 0) continue; // 3. 必须已经挂载且可写 boolean writable invokeBooleanMethod(vol, isMountedWritable, false); if (!writable) continue; // 4. 取真实挂载路径 File path (File) invokeObjectMethod(vol, getPath); if (path null || !path.exists()) continue; String label (String) invokeObjectMethod(vol, getFsLabel); String fsType (String) invokeObjectMethod(vol, getFsType); UsbVolume uv new UsbVolume(path.getAbsolutePath(), label, fsType, writable); result.add(uv); } } catch (Throwable t) { Log.e(UsbReflect, getUsbVolumes error, t); } return result; } private static int invokeIntMethod(Object target, String name, int defVal) { try { return (Integer) target.getClass().getMethod(name).invoke(target); } catch (Throwable t) { return defVal; } } private static boolean invokeBooleanMethod(Object target, String name, boolean defVal) { try { return (Boolean) target.getClass().getMethod(name).invoke(target); } catch (Throwable t) { return defVal; } } private static Object invokeObjectMethod(Object target, String name) { try { return target.getClass().getMethod(name).invoke(target); } catch (Throwable t) { return null; } } private static int getDiskFlags(Object disk) { try { return (Integer) disk.getClass().getMethod(getFlags).invoke(disk); } catch (Throwable t) { // 部分 ROM 没有 getFlags退回读字段 try { Field flagsField disk.getClass().getDeclaredField(mFlags); flagsField.setAccessible(true); return flagsField.getInt(disk); } catch (Throwable t2) { return -1; } } }这套代码我在 Android 7 到 Android 14 的真机上都跑过核心逻辑稳定。唯一要留意的是getFlags()在个别定制 ROM 上可能没有所以我加了字段反射兜底实战中这个兜底确实救过命。3. 判断“这确实是U盘”组合条件比路径前缀靠谱十倍3.1 路径前缀和外置卷类型都不可靠网上很多教程教你判断 U 盘就看路径里有没有USB、usb字样比如/storage/USB/...。这种做法仅对特定厂商 ROM 有效。AOSP 标准的挂载点格式是/storage/XXXX-XXXX比如我的金士顿 U 盘插入后就变成/storage/6F2A-1C33路径里根本没有 USB 字样纯靠路径前缀判断立刻抓瞎。还有人只看type 0public 卷就认为是 U 盘这也是错的。SD 卡同样属于 public 卷如果你的设备同时插着 TF 卡和 U 盘type 条件会把 SD 卡一起选出来。你要是把数据写到 SD 卡上用户回头拔掉 U 盘数据就不见了这种 bug 在发布后很难排查。3.2 真正可靠的判断链路我的推荐判断逻辑如下先看type TYPE_PUBLIC排除内置存储。再看disk.getFlags()里有没有FLAG_USB标志位。最后确认isMountedWritable()排除还在挂载中或已卸载的卷。这个链路在绝大多数原生 ROM 上都能精确识别 U 盘。但有一种情况要额外处理部分 USB 读卡器插的是 TF 卡内核层走 usb-storage 驱动flags 里仍然会带FLAG_USB所以读卡器能被正确识别为 USB 设备。如果遇到某些定制 ROM 把 U 盘错误标记为普通 SD可以加一个兜底逻辑当设备上没有 SD 卡槽或系统没有挂载 SD 卡时唯一的 public 卷大概率就是 U 盘。这个兜底只建议在非标准环境下使用正常设备用 flags 判断就够。3.3 实测样本对比我在几台设备上分别插了不同介质做对比测试数据如下插入介质typedisk.flags路径能否被识别为U盘金士顿DataTraveler 64G0FLAG_USB/storage/6F2A-1C33是USB读卡器 32G TF卡0FLAG_USB/storage/AAAA-BBBB是手机内置存储1无/storage/emulated/0否主板上SD卡槽 16G卡0FLAG_SD/storage/CCCC-DDDD否flags不含USB杂牌U盘定制ROM0FLAG_USB/mnt/media_rw/EEEE-FFFF是但路径特殊最后一行那个/mnt/media_rw/路径是部分厂商从 vold 层直接暴露的挂载点我们在下一章展开讲怎么处理。4. 权限、格式和应用层读写把这些理顺再动手4.1 Manifest 声明与 Android 11 以后的“所有文件访问”反射拿到路径只是第一步真正读写 U 盘前权限必须配齐。Manifest 里至少需要这些uses-feature android:nameandroid.hardware.usb.host requiredtrue / uses-permission android:nameandroid.permission.READ_EXTERNAL_STORAGE / uses-permission android:nameandroid.permission.WRITE_EXTERNAL_STORAGE android:maxSdkVersion28 / !-- Android 11 访问 U 盘真实路径需要 -- uses-permission android:nameandroid.permission.MANAGE_EXTERNAL_STORAGE /Android 6 到 Android 10需要动态申请READ/WRITE_EXTERNAL_STORAGE运行时权限。从 Android 11 开始分区存储全面收紧应用默认无法直接访问外部存储的真实文件路径更别说 U 盘里的文件了。这时候只能申请MANAGE_EXTERNAL_STORAGE权限并引导用户进入“所有文件访问”设置页Intent intent new Intent(Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION, Uri.parse(package: context.getPackageName())); context.startActivity(intent);注意MANAGE_EXTERNAL_STORAGE属于特殊权限申请后还要用Environment.isExternalStorageManager()确认用户真的开了。很多用户会忽略设置页里的开关导致后续读写失败建议每次进入读写页面时检查一次。4.2 USB Host 权限容易搞混的一点这里要特别澄清一个误区如果你只是通过文件系统路径读写 U 盘文件并不需要向用户弹 USB 授权框也不需要UsbManager.requestPermission()。那个权限是给直接通过UsbDevice做 bulk 通信的场景用的比如向 U 盘里的单片机设备发送指令。普通文件 IO 走的是 vold 挂载后的 Linux 文件系统不涉及 USB 协议层。简单说你用 File 读写 U 盘不需要 USB 权限你直接用 UsbDevice 和 U 盘芯片通信比如做 U 盘量产工具才需要 USB 授权。两者不要混。4.3 文件读写与 U 盘格式的现实约束权限搞定后读写 U 盘文件其实就是普通的 Java IOFile usbDir new File(usbPath); File[] files usbDir.listFiles(); if (files null) { // 目录为空或没有权限 return; } for (File f : files) { // 处理文件 }复制、删除、重命名都走标准的FileAPI。要注意的是listFiles()有一个隐蔽的坑当路径不存在或没权限时它会返回 null 而不是空数组所以上面代码里files null的判断必不可少否则直接for循环就会空指针崩溃。文件系统格式上FAT32 兼容性最好但单个文件不能超过 4GBexFAT 没有 4GB 限制且 Android 8.0 以后原生支持NTFS 在多数手机上并不被内核原生支持插上去可能直接提示无法识别或只能读不能写。如果用户反馈 U 盘读不了先让他看一眼格式化界面里的文件系统类型。长时间不用的 U 盘我会建议格式化为 exFAT兼容性和大文件支持都比较均衡。区块存储分区存储带来另一个限制MediaStore 不索引 U 盘文件。也就是说用系统相册、音乐播放器查不到你往 U 盘里拷贝的文件。如果产品里有“把文件复制到 U 盘后能被系统 App 直接看到”的需求要么走 MediaScanner 手动扫描要么考虑走 SAF 的 DocumentFile这个需要单独评估不要默认 MediaStore 能帮你处理。4.4 识别不了 U 盘的排查顺序如果插上 U 盘后getUsbVolumes()返回空按下面顺序排查先看系统文件管理器能不能看到 U 盘内容看不到就是硬件、OTG 线或 U 盘格式问题。确认 Manifest 里声明了usb.host且设备真的支持 OTG。用UsbManager.getDeviceList()看看系统有没有枚举到 USB 设备。注意这个动作在 Android 11 以上可能需要设备列表权限但常规设备不需要。U 盘如果是 NTFS 且手机内核没编译对应模块系统层面就识别不了格式化成 exFAT 再试。这套流程能排除 90% 以上的“识别不了”问题。5. 真机踩坑反射不稳定、拔盘崩溃、挂载延迟的处理5.1 反射要捕获 Throwable而不是 Exception第一版代码我写的是catch (Exception e)结果在 Android 12 某款国产 ROM 上反射getVolumeList直接抛了NoSuchMethodError还是SecurityException记不清了反正 Exception 没接住崩溃了。后来学乖了所有反射调用一律catch (Throwable)并做兜底返回保证反射失败时不会拖垮主流程。反射本来就是“越权访问”厂商 ROM 理论上可以只改方法名甚至移除方法反射调用不像普通 API 有兼容性契约。所以反射代码的容错标准必须高于普通代码能回调默认值就回调默认值能不抛异常就不抛异常。5.2 getPath() 返回 null卷还在挂载中U 盘插入瞬间系统并不立刻完成挂载。VolumeInfo 会经历 STATE_MOUNTING、STATE_MOUNTED 等状态。如果你在插拔广播触发后立即反射调用getPath()很可能返回 null或者 File 对象存在但exists()是 false。我踩到这个问题是在一台 Android 9 平板上插入 U 盘后广播响得非常快代码去读路径结果挂载还没完成拿到的路径根本不存在。后来我把策略改成“广播触发后延迟 300ms 再扫描”并且调用isMountedWritable()过滤没就绪的卷问题才解决。更稳的方案是注册 StorageManager 的卷状态监听回调但前面说过 VolumeInfo 类在 SDK 里隐藏StorageManager.StorageVolumeCallback的参数类型编译期不可见实现起来体验很差。所以我还是推荐广播加状态校验的组合简单粗暴且有效IntentFilter filter new IntentFilter(); filter.addAction(Intent.ACTION_MEDIA_MOUNTED); filter.addAction(Intent.ACTION_MEDIA_UNMOUNTED); filter.addAction(Intent.ACTION_MEDIA_EJECT); filter.addDataScheme(file); context.registerReceiver(receiver, filter);广播的data是file://scheme记得注册addDataScheme(file)否则接收不到这是我第一次不停加filter.addAction却始终没反应时踩掉的坑。5.3 拔出 U 盘后的空指针与 File 异常用户可不会规规矩矩先卸载再拔盘。拔出瞬间VolumeInfo 里缓存的 File 路径实际已经不存在了此时如果正在遍历目录会出现两种情况listFiles()返回 null上面提过直接判空。正在读写文件时拔盘抛出ErrnoException或IOException这类异常要捕获并提示用户“设备已断开”。高发区域是读取大文件的中途拔出我建议所有 U 盘文件操作套一个 try-catch并在 catch 里主动清理打开的 FileInputStream/FileOutputStream避免文件句柄泄漏。反复插拔后出现“文件打不开”之类问题多半是句柄没释放干净。5.4 厂商 ROM 把 U 盘挂到 /mnt/media_rw某些定制 ROM尤其车机类和工业平板不走标准/storage/XXXX-XXXX挂载路线U 盘的挂载点出现在/mnt/media_rw/XXXX-XXXX。这个目录在原生 Android 上是媒体存储专用的普通应用直接访问会被 SELinux 拦截导致Permission denied。处理方式反射拿到路径后用 File 检测可读性如果/storage下的路径不可读就试着把路径前缀替换成/mnt/media_rw再检测一次。注意这只能作为兜底不要默认走这条线因为/mnt/media_rw的 SELinux 策略在不同 ROM 上差异极大有些 ROM 连系统应用都没开放。private static File resolveAccessiblePath(File original) { if (original.canRead()) return original; String path original.getAbsolutePath(); if (path.startsWith(/storage/)) { File alt new File(path.replace(/storage/, /mnt/media_rw/)); if (alt.canRead()) return alt; } return original; }5.5 别把 Android/data 目录和 U 盘路径混在一起很多人顺着content://.../external_root/android/data/...这类 Uri 就以为找到了 U 盘其实那是应用专属目录归属内置存储或外部存储的 Android/data 分区不是 U 盘挂载点。U 盘有自己的卷路径从getVolumeList()反射出来是/storage/XXXX-XXXX两者完全没有从属关系。如果你的需求只是读写自己的数据SAF 或者 Android/data 就够了根本不需要反射但如果要管理 U 盘上任意文件反射路径才是可靠的入口。6. 扩展搭载插拔监听、盘符和文件系统类型的完整工具6.1 监听 U 盘插拔的广播方案完整工具类需要包含监听逻辑。上面已经给了广播过滤器接收广播后重新执行getUsbVolumes()刷新列表即可。广播类型用三个ACTION_MEDIA_MOUNTEDU 盘挂载完成。ACTION_MEDIA_UNMOUNTEDU 盘卸载完成。ACTION_MEDIA_EJECTU 盘被弹出或拔出。每次收到广播都清掉旧缓存重新反射一次。这样上下位机联动时插拔 U 盘的状态就能实时同步到 UI。6.2 读取盘符和文件系统类型反射工具方法里已经取了getFsLabel()和getFsType()这两个值很有用。卷标可以直接展示在 UI 上比如“KINGSTON (64GB)”文件系统类型可以在写入前做容量和格式判断。另外有个注意点没有格式化过的 U 盘可能没有卷标getFsLabel()会返回空字符串UI 上要做 null 处理。文件系统类型字符串常见值vfat对应 FAT32exfat对应 exFATntfs对应 NTFS。判断逻辑很简单public static String describeFs(String fsType) { if (fsType null) return 未知; switch (fsType) { case vfat: return FAT32; case exfat: return exFAT; case ntfs: return NTFS; case ext4: return ext4; default: return fsType; } }6.3 最终输出示例完整跑通后日志输出大致这样USB卷: /storage/6F2A-1C33 label: KINGSTON fsType: exfat writable: true到这一步U 盘的路径获取、类型识别、读写权限全部打通后续不管是批量拷贝数据还是做 OTG 文件管理都只需要在这个工具类上叠加业务逻辑。最后分享一个小技巧处理热插拔时不要只在插拔广播里刷新一次路径最好在每次读文件前都重新调用一次getUsbVolumes()。原因是系统挂载状态在某些 ROM 上会延迟更新且用户可能很快换一个 U 盘。每次现取现用虽然多了一点点反射开销毫秒级但能彻底避免拿到陈旧路径后读文件失败的场景。我后来所有 U 盘相关的功能都统一走“操作前 refresh - 取出最新路径 - 再做 IO”的模板再也没有出现拔盘后路径失效导致的诡异 bug。本文还有配套的精品资源点击获取