Android U盘路径获取:基于StorageManager与反射的OTG文件读写实践

发布时间:2026/10/16 16:49:31
Android U盘路径获取:基于StorageManager与反射的OTG文件读写实践
简介面向Android开发者的U盘读写工程示例聚焦OTG外接存储场景解决常规API无法直接获取U盘路径的问题内容涵盖外部存储权限声明、运行时权限请求以及通过反射读取系统媒体扫描类的私有存储卷映射字段、遍历卷信息定位USB设备路径的完整思路。包体共110个文件压缩后约518KB以XML布局与资源配置、PNG图标为主另有Kotlin与Java源码、Gradle构建脚本等整体属于可导入Android Studio运行调试的轻量工程已有1355人学习浏览适合需要快速验证U盘读写方案的Android开发者。包内通过具体代码与目录结构展示从权限适配到反射获取路径、再到文件读写操作的实现流程并以构建脚本保留工程可复现性便于对照源码理解系统私有API调用的兼容性风险与异常处理要点同时提醒开发者关注Android版本更新对私有API的约束提前规划替代方案。1. 插上U盘没反应为什么需要反射去拿一个路径做过Android外设开发的应该都遇到过这种场景手机支持OTG插上U盘后系统弹了个“正在准备U盘”的提示然后……就没有然后了。你打开自带的文件管理器能看到U盘内容但自己App里死活找不到U盘的挂载路径。用Environment.getExternalStorageDirectory()拿到的只是内置存储用File.listFiles()列出来一堆0字节的空目录。原因很简单U盘虽然是外部存储设备但它和内置SD卡走的是完全不同的挂载逻辑而Android官方压根没提供公开API去拿U盘路径。实际开发中比较通用的解法是反射去读取StorageManager内部维护的Volume列表把挂载类型为USB的Volume单独拎出来再反射调它的getPath()拿到真实路径。这个项目做的就是这件事附带完整的读写封装适合需要做OTG文件管理、U盘数据导入导出、或者要在Android设备上做数据采集落盘的开发者。2. 技术选型为什么偏偏是StorageManager加反射而不是别的方案2.1 StorageVolume与U盘挂载点的关系Android从4.4开始引入了StorageManager这个类负责管理系统里所有的存储设备。每挂载一个存储设备StorageManager内部就会生成一个对应的StorageVolume对象。可以简单理解成StorageVolume就是存储设备的“身份证”记录了设备的挂载路径、状态、类型、是否可移除等信息。内置存储、SD卡、OTG外接U盘这三类设备在StorageManager里都有对应的StorageVolume。区分它们靠的是getVolumeType()这个方法返回的字符串常量里内置存储是“private”SD卡是“public”U盘走OTG接入的也是“public”类型。只靠volumType还不够因为SD卡和U盘都是public需要再配合isRemovable()来排除内置存储同时还要判断设备是否处于挂载状态。常见做法是先遍历getVolumeList()拿到的所有StorageVolume然后用isRemovable()和getVolumeType()的组合条件过滤出候选设备最后通过反射调getPath()拿路径。2.2 反射的核心思路这里要反射的核心是StorageVolume.getPath()。这个方法是隐藏API从Android 9开始被系统标记为hide意味着正常代码直接调用会编译报错。但运行时的类还在方法还在只是被藏起来了。反射本质上就是把运行时的类信息重新捞出来绕过编译期限制去调它。实现思路不复杂先拿Class.forName(android.os.storage.StorageVolume)然后getMethod(getPath)最后invoke在具体的StorageVolume实例上。这里有个坑Android 10之后Volume里还多了个getInternalPath()有的厂商会把U盘的实际挂载路径放在internalPath里。所以项目里通常会两个方法都反射出来先试getPath()拿不到或者路径不存在再试getInternalPath()。2.3 一段完整的U盘路径获取框架public class UsbStorageUtils { private StorageManager storageManager; public UsbStorageUtils(Context context) { storageManager (StorageManager) context.getSystemService(Context.STORAGE_SERVICE); } public String getUsbPath() { try { // 通过反射调用 StorageManager.getVolumeList() 获取所有存储卷 Method getVolumeListMethod StorageManager.class.getMethod(getVolumeList); Object[] volumes (Object[]) getVolumeListMethod.invoke(storageManager); if (volumes ! null) { for (Object volume : volumes) { // 反射获取每个 StorageVolume 的 isRemovable 和 getVolumeType boolean isRemovable (boolean) volume.getClass().getMethod(isRemovable).invoke(volume); String volumeType (String) volume.getClass().getMethod(getVolumeType).invoke(volume); // U盘属于 public 类型且可移除排除内置存储和SD卡 if (isRemovable public.equals(volumeType)) { // 优先反射 getPath()Android 10以下通用 Method getPathMethod volume.getClass().getMethod(getPath); String path (String) getPathMethod.invoke(volume); File file new File(path); if (file.exists() file.canRead()) { return path; } // 有的设备挂载路径在 internalPath 里兜底再反射一次 try { Method getInternalPathMethod volume.getClass().getMethod(getInternalPath); String internalPath (String) getInternalPathMethod.invoke(volume); File internalFile new File(internalPath); if (internalFile.exists() internalFile.canRead()) { return internalPath; } } catch (NoSuchMethodException ignored) { // getInternalPath 只在部分Android版本中存在 } } } } } catch (Exception e) { e.printStackTrace(); } return null; } }这段代码干了三件事第一反射调StorageManager.getVolumeList()拿到所有存储卷的对象数组这是后续一切操作的基础第二遍历每个StorageVolume实例用isRemovable()和getVolumeType()两个方法判断是不是外接U盘第三对符合条件的Volume先反射getPath()路径不可读时再反射getInternalPath()兜底。需要注意这段代码里用到了两次反射去检查路径文件是否存在且可读这是为了适配不同厂商ROM。有些ROM的getPath()返回的是一个空壳路径看起来像“/storage/XXXX-XXXX”但实际文件系统还没就绪必须加文件探测才能避开这类“假挂载”状态。3. 核心模块拆解从USB权限申请到拿到U盘根目录的完整链路3.1 初始化UsbManager与StorageManager获取U盘路径之前先要保证USB设备的访问权限。USB设备分为两种一种是系统已经识别并挂载好的这种直接走StorageManager就能拿到路径另一种是刚插上但还没授权给当前App的这种需要先走UsbManager的权限流程。常见做法是在Activity或Application的onCreate阶段拿到UsbManager实例并注册USB设备插拔的广播监听。UsbManager负责的是USB层的权限管理StorageManager负责的是存储层的挂载管理两者配合才能完整覆盖“插入U盘→授权→获取挂载路径→读写文件”这条链路。3.2 用getDeviceList判断U盘的连接状态public boolean isUsbAttached() { UsbManager usbManager (UsbManager) getSystemService(Context.USB_SERVICE); HashMapString, UsbDevice deviceList usbManager.getDeviceList(); if (deviceList ! null !deviceList.isEmpty()) { for (UsbDevice device : deviceList.values()) { // 典型的U盘设备接口类通常是 Mass Storage 类 if (device.getDeviceClass() UsbConstants.USB_CLASS_MASS_STORAGE || hasMassStorageInterface(device)) { return true; } } } return false; }这段代码的目的是在反射取路径之前先确认设备真的在USB总线上。getDeviceClass()返回0表示设备类信息在接口层面很多U盘会走这个值所以还需要遍历接口进一步判断。实际项目中这里容易翻车——有些U盘多合一读卡器的主设备类是0接口里才带Mass Storage类只判断deviceClass会漏判。参数方面UsbManager.getDeviceList()返回的是HashMapkey是设备名字符串value是UsbDevice对象。UsbDevice里的getInterfaceCount()可以遍历出所有接口每个接口有getInterfaceClass()USB_CLASS_MASS_STORAGE这个常量的值是8。判断完整逻辑应该是设备类为8或者某个接口类为8。3.3 发送USB权限广播并等待用户授权拿到UsbDevice后访问U盘前还需要用户授权。这一块很多新手理解偏了以为反射拿到路径就直接能读实际上在部分Android版本上未授权的USB设备根本无法访问其文件系统。public static final String ACTION_USB_PERMISSION com.example.usb.USB_PERMISSION; private void requestUsbPermission(UsbDevice device) { PendingIntent permissionIntent PendingIntent.getBroadcast( this, 0, new Intent(ACTION_USB_PERMISSION), PendingIntent.FLAG_IMMUTABLE ); usbManager.requestPermission(device, permissionIntent); } private BroadcastReceiver usbReceiver new BroadcastReceiver() { Override public void onReceive(Context context, Intent intent) { String action intent.getAction(); if (ACTION_USB_PERMISSION.equals(action)) { synchronized (this) { UsbDevice device intent.getParcelableExtra(UsbManager.EXTRA_DEVICE); boolean granted intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false); if (granted device ! null) { // 授权成功后再去反射获取U盘路径 String usbPath new UsbStorageUtils(context).getUsbPath(); } else { // 用户拒绝了授权需要提示后重新请求 } } } } };这里的关键点是PendingIntent的FLAG_IMMUTABLE标志。Android 12开始targetSdkVersion为31以上的应用如果PendingIntent不设置FLAG_IMMUTABLE会直接崩溃设置了FLAG_MUTABLE又容易出安全问题。实际开发中建议用FLAG_IMMUTABLE除非你的广播需要支持动态修改。3.4 拿到路径后的挂载状态检测反射拿到路径只是第一步后面还要确认挂载状态。这个环节最常见的问题是路径拿到了但读写文件时直接抛FileNotFoundException。这是因为U盘正在挂载过程中路径文件还不稳定。比较稳妥的校验方式是轮询检测路径下的目录项。常见做法是循环5次每次间隔200毫秒调用File.listFiles()看能不能返回非null结果。能返回说明文件系统已就绪直接去写业务逻辑返回null说明还在挂载中或已经异常弹出。这套检测逻辑本身不复杂但能省掉大量靠“睡眠2秒再读”的玄学等待。4. 从路径到文件U盘读写实战与权限处理4.1 读取U盘根目录的文件列表拿到U盘路径之后读取文件列表就和普通文件操作没什么区别了。但这里的坑在于U盘的文件系统可能是FAT32、exFAT或者NTFSAndroid对FAT32和exFAT是原生支持的NTFS部分设备只读。所以在读取前用File.isDirectory()判断后还需要用File.canRead()确认权限。public ListFileInfo listUsbRootFiles(String usbPath) { ListFileInfo fileList new ArrayList(); File rootDir new File(usbPath); if (rootDir.exists() rootDir.isDirectory()) { File[] files rootDir.listFiles(); if (files ! null) { for (File file : files) { FileInfo info new FileInfo(); info.setName(file.getName()); info.setPath(file.getAbsolutePath()); info.setDirectory(file.isDirectory()); info.setSize(file.isFile() ? file.length() : 0); info.setLastModified(file.lastModified()); fileList.add(info); } } } return fileList; }FileInfo是一个普通的POJO用来承载文件名、路径、大小、最后修改时间、是否目录这些元信息。这段代码里listFiles()返回null的情况要特别注意它不代表目录为空而是代表“路径不是目录”或者“权限不足”。很多开发者在这块会翻车把null当成空列表处理导致UI上显示“空U盘”。4.2 向U盘写入文件写文件建议用缓冲流避免频繁IO导致写入慢。U盘的IO性能比内置存储差不少尤其是便宜的小容量U盘小文件写入速度能掉到几MB每秒。下面这段是标准的复制写入逻辑。public boolean writeFileToUsb(String usbPath, InputStream inputStream, String fileName) { File targetFile new File(usbPath File.separator fileName); FileOutputStream fos null; BufferedOutputStream bos null; try { fos new FileOutputStream(targetFile); bos new BufferedOutputStream(fos); byte[] buffer new byte[8192]; int len; while ((len inputStream.read(buffer)) ! -1) { bos.write(buffer, 0, len); } bos.flush(); return true; } catch (IOException e) { e.printStackTrace(); return false; } finally { try { if (bos ! null) bos.close(); if (fos ! null) fos.close(); } catch (IOException ignored) { } } }这里缓冲区大小选8192字节也就是8KB是给U盘随机写入场景做的一个折中。太小了会频繁触发底层IO调度太大了又容易占内存。文件的文件名如果包含中文要注意编码问题FAT32文件系统在Android上的文件名编码通常是UTF-8但如果U盘之前是在Windows上格式化且使用了GBK编码的文件名读取时会出现乱码。遇到乱码时可以用new String(fileName.getBytes(StandardCharsets.ISO_8859_1), StandardCharsets.UTF_8)这类转换方式试试。4.3 动态权限申请与USB授权Android 6.0之后读写外部存储需要动态申请READ_EXTERNAL_STORAGE和WRITE_EXTERNAL_STORAGE两个权限。Android 13开始又多了一个READ_MEDIA_VIDEO之类的细分权限但U盘这种挂在外部存储路径下的场景核心还是存储权限加USB授权两层。private void requestStoragePermission() { if (Build.VERSION.SDK_INT Build.VERSION_CODES.M) { ListString permissions new ArrayList(); if (checkSelfPermission(Manifest.permission.READ_EXTERNAL_STORAGE) ! PackageManager.PERMISSION_GRANTED) { permissions.add(Manifest.permission.READ_EXTERNAL_STORAGE); } if (checkSelfPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE) ! PackageManager.PERMISSION_GRANTED) { permissions.add(Manifest.permission.WRITE_EXTERNAL_STORAGE); } if (Build.VERSION.SDK_INT Build.VERSION_CODES.Q) { // Android 10及以上应用不需要存储权限也能访问自己创建的文件 // 但访问U盘公共区域仍然建议申请 if (checkSelfPermission(Manifest.permission.READ_EXTERNAL_STORAGE) ! PackageManager.PERMISSION_GRANTED) { permissions.add(Manifest.permission.READ_EXTERNAL_STORAGE); } } if (!permissions.isEmpty()) { requestPermissions(permissions.toArray(new String[0]), REQUEST_CODE_STORAGE_PERMISSION); } } }注意Android 10API 29和Android 11API 30之间的行为差异。Android 10推出了分区存储App默认只能访问自己目录和公共媒体目录U盘这类外部存储卷的路径访问其实已经受限了。Android 11又把MANAGE_EXTERNAL_STORAGE权限单独拎出来但这个权限在Google Play上审核极严一般U盘读写项目不建议去申请。比较实际的兼容策略是Android 10以下用反射存储权限Android 10及以上走MANAGE_EXTERNAL_STORAGE或者用SAFStorage Access Framework让用户自己选U盘目录。4.4 完整读写Demo的调用链一个能跑通的调用链大概是这样的用户插上U盘系统弹USB授权请求用户同意后App反射获取U盘路径随后在UI上显示根目录文件列表点击某个按钮把内置存储里的某个文件写入U盘。这个流程里最容易被忽视的是时序问题。USB广播回调、权限申请回调、反射路径获取这三个操作是异步的没有严格的先后顺序。我的做法是在USB授权成功的回调里发起一个Handler.postDelayed的任务延迟100毫秒去调getUsbPath()给文件系统挂载一点缓冲时间然后在返回的路径上做目录探测。这种写法不算优雅但在多款不同厂商的设备上测试下来稳定性明显好过直接同步获取。5. 避坑记录U盘路径获取失败时的排查清单和定位方法5.1 现象路径拿到了但new File后isDirectory()返回false原因U盘文件系统还没真正完成挂载。USB设备插入后底层vold服务会先做文件系统检查尤其对exFAT格式的U盘挂载耗时可能达到1到3秒。反射调用在这个时间窗口内拿到的路径目录结构还不存在。解决不要拿到路径立刻做文件操作。在路径外面加一个轮询探测每200毫秒检查一次File.isDirectory()最多重试10次。如果10次之后仍然是false再尝试重新触发USB广播或提示用户重新插拔。这块建议封装成工具方法不要散落到业务代码里。5.2 现象部分设备上getPath()返回的字符串尾部带空格或换行原因这是厂商ROM的锅。某些定制的ROM在构建StorageVolume对象时路径字符串没有做trim处理拼接过程混入了不可见字符。用Log打印看路径看起来正常但new File(path)就是找不到文件。解决在拿到路径后强制做一次路径清洗。用path.replaceAll(\s$, )去掉尾部空白再用path.startsWith(/storage/)或path.startsWith(/mnt/)做一次白名单判断过滤掉明显异常的路径。5.3 现象在Android 11以上设备中反射返回路径但读取文件列表时全是空目录原因Android 11引入了分区存储的强制机制即使反射拿到了U盘路径App也没有权限去枚举公共目录之外的文件夹。这属于系统权限边界收窄不是路径没拿到。解决Android 11及以上设备优先引导用户通过SAF框架授权。用Intent.ACTION_OPEN_DOCUMENT_TREE让用户选中U盘目录系统会返回一个content://Uri后续所有文件操作都基于这个Uri进行。反射路径只在Android 10及以下的系统版本上作为主力方案。5.4 现象U盘拔出后之前拿到的路径File.exists()仍然返回true原因Linux的VFS层会缓存目录结构信息。U盘虽然物理拔出了但vold的清理流程有延迟短时间之内旧的挂载点还在内存里路径对象是“脏”的。解决不要信任缓存要监听ACTION_MEDIA_EJECT或ACTION_MEDIA_UNMOUNTED广播收到这些广播后立即把内存中保存的U盘路径置为null。业务代码每次读写前用File.listFiles()结果为null作为判断依据能有效避开“假路径”的操作。5.5 现象proguard混淆后反射调用NoSuchMethodException原因这是血泪教训。反射用的是字符串来定位方法如果你的release包开了代码混淆StorageVolume这个类名和方法名可能被混淆器改写。虽然Android的系统类一般不会被App的混淆规则影响但部分厂商ROM在自己的实现类上做了混淆。解决在proguard-rules.pro里加一行-keep class android.os.storage.StorageVolume { *; }。最好把StorageManager也一起keep住。就算本意上不打算keep系统类加了这条规则也不影响其他地方但能省掉一堆线上崩溃。6. 二次开发把U盘路径获取封装成一个自带插拔监听的库如果要在自己的项目里用这套逻辑不建议直接把反射代码平铺在Activity里。更合理的做法是封装成一个单例工具库对外只暴露三个方法getUsbPath()获取当前U盘路径、registerUsbListener(Listener)注册插拔监听、writeToUsb(InputStream, String)写入文件。public class UsbStorageManager { private static volatile UsbStorageManager instance; private String usbPath; private Context context; private ListUsbStateListener listeners new ArrayList(); public interface UsbStateListener { void onUsbMounted(String path); void onUsbUnmounted(); } private UsbStorageManager(Context context) { this.context context.getApplicationContext(); registerUsbReceiver(); } public static UsbStorageManager getInstance(Context context) { if (instance null) { synchronized (UsbStorageManager.class) { if (instance null) { instance new UsbStorageManager(context); } } } return instance; } private void registerUsbReceiver() { IntentFilter filter new IntentFilter(); filter.addAction(UsbManager.ACTION_USB_DEVICE_ATTACHED); filter.addAction(UsbManager.ACTION_USB_DEVICE_DETACHED); filter.addAction(Intent.ACTION_MEDIA_MOUNTED); filter.addAction(Intent.ACTION_MEDIA_UNMOUNTED); filter.addAction(Intent.ACTION_MEDIA_EJECT); filter.addDataScheme(file); context.registerReceiver(usbReceiver, filter); } }这个封装的价值在于把反射路径、权限申请、插拔状态三块逻辑收敛到一个类里。Activity侧只需要在onResume里调用getInstance().registerListener()在onPause里注销监听就能自动感知U盘的挂载和卸载。ACTION_MEDIA_MOUNTED广播的data值里本身带着路径有的设备上可以从intent.getData().getPath()直接拿路径而不必每次都走反射。后续如果要替换反射方案比如某个系统版本上反射完全失效只需要改UsbStorageManager内部的getUsbPath()实现换成SAF框架或者绕行FileObserver监听对外接口可以保持不变。这种替换成本就是我习惯做单例封装的原因。从那以后我每次涉及外设存储读写都会先跑一遍“插拔U盘→检查路径→读写文件→拔出U盘”的完整闭环用5.1里的轮询探测兜底挂载时序。这套流程帮我在不同项目的迭代里省了不少返工成本希望帮到你。本文还有配套的精品资源点击获取