Android U盘读写全攻略:反射获取路径与兼容方案
简介面向需要在Android应用中实现OTG U盘读写与路径获取的开发者这份资源围绕系统外部存储权限申请、MediaScannerConnection反射获取挂载卷信息等关键技术点提供可直接参考的工程示例与代码片段可帮助规避私有API适配风险并快速集成U盘文件操作功能。压缩包共110个文件以xml配置、java/kt源码、gradle构建脚本及若干png图片和bin缓存为主整体约518KB目录结构紧凑便于定位权限声明、反射工具类与读写逻辑。已有1356人学习下载适合具备基础Android开发经验、正在处理USB OTG外设兼容性问题的技术人员。通过阅读示例可掌握运行时权限处理、遍历VolumeInfo识别U盘路径、基于FileInputStream/FileOutputStream完成读写等完整思路并了解应对系统版本更新时反射方法失效的备选方案。1. Android U盘读写为什么非走反射不可在某跨平台系统的Android端改造里我接到一个需求OTG插上U盘后把U盘里的固件包拷贝到应用目录。查了一圈官方API最后卡在同一个地方——UsbManager能拿到设备厂商ID和接口信息但拿不到挂载路径File文件操作需要路径而这个路径被藏在StorageManager的隐藏方法里。社区里U盘读写方案绕来绕去最后几乎都是同一句话反射获取U盘路径。这篇博文把整条链路讲透从vold挂载到反射落点从广播监听到底层读写附上排查记录和一份可以直接改造复用的工具类。2. 挂载链路与反射落点从UsbManager到StorageManager的内部地图2.1 USB Host架构里藏着的路径断层Android从API 12开始支持USB Host模式UsbManager从此成为OTG外设通信的唯一官方入口。它能做的事情其实很受限枚举总线上的设备getDeviceList、向用户申请设备使用权限requestPermission、拿到接口后做bulkTransfer底层收发。它根本不参与文件系统的组织。U盘插入后总线层枚举成功会发ACTION_USB_DEVICE_ATTACHED但这个广播只代表设备被USB驱动识别不代表文件系统挂载完成。真正的挂载动作由vold完成。vold是Android的卷管理守护进程负责探测分区表、根据文件系统类型FAT32、exFAT、NTFS调用内核模块挂载完成后把新卷加入StorageManager的卷列表。设备节点从/mnt/media_rw/卷ID开始再由sdcardfs或FUSE在App可见层映射出/storage/卷ID路径。App通过File访问后者时实际经过的是有权限守卫的文件系统重定向。所以这条链路里有一个明摆着的断层UsbManager管总线、vold管挂载、File操作管文件前两者之间没有任何公开接口告诉App「U盘文件系统挂在哪个路径」。对不了解内部实现的开发者来说这几乎是个黑匣子。而反射StorageManager就是撬开黑匣子拿路径的常用手段。2.2 StorageManager反射的三个候选目标StorageManager维护着全系统所有卷Volume的状态包括内置存储、SD卡和OTG U盘。绕过公开API的限制反射它有几种路径。第一个是getVolumeList()。这是StorageManager上最老的隐藏方法返回StorageVolume数组。StorageVolume描述卷信息内部有mPath挂载路径、mDescription卷描述、mState挂载状态等字段。这套组合从Android 4.x一直用到9.x兼容面最广也是社区里流传最久的方案。第二个是getVolumes()/getStorageVolumes()。从Android 10开始系统逐步放开卷信息的访问API 30以上有getStorageVolumes()可以直接调用返回List 配合StorageVolume.getDirectory()拿路径。但代码要兼容老设备就不能只走这条新路。第三个是直接反射StorageManager实例的mVolumes字段。在两个方法都被系统拦截时可以兜底但字段名在不同版本不稳定Android 11里改成了mVolumesByUuid我一般只把它当最后的备选。方法/字段可见性返回内容StorageManager.getVolumeList()灰度隐藏APIStorageVolume数组老设备通用StorageManager.getStorageVolumes()API 30 公开List 新设备推荐StorageVolume.getPath()灰度隐藏API/storage/XXXX-XXXX 格式路径StorageVolume.getState()灰度隐藏APImounted / unmounted 字符串StorageVolume.getDirectory()API 30 公开卷根目录的File对象2.3 为什么反射在Android 9之后还能生效Android 9开始系统对非SDK接口做了限制反射隐藏方法不再是百无禁忌。但StorageManager、StorageVolume相关方法被划在灰度名单里反射调用不报错只是Logcat里多几行警告。实测在Android 12和Android 14上反射getVolumeList()仍然能返回完整卷列表。这里有个隐患灰度名单是官方给的基准OEM可以在自家ROM里二次收紧。比如某些厂商设备会把getVolumeList()替换成空实现返回数组永远是空。遇到这种情况不要死磕反射改成监听ACTION_MEDIA_MOUNTED广播配合mVolumes字段兜底能救回大部分机型。这一点在第5章避坑里会展开。3. 反射获取U盘路径完整代码与判定逻辑3.1 权限声明与USB插拔广播的动态注册先给manifest加上USB Host特性声明和存储权限。注意android.hardware.usb.host要声明为false这样没有USB Host能力的设备也能安装应用只是OTG功能不可用而已。manifest xmlns:androidhttp://schemas.android.com/apk/res/android xmlns:toolshttp://schemas.android.com/tools uses-feature android:nameandroid.hardware.usb.host android:requiredfalse / uses-permission android:nameandroid.permission.WRITE_EXTERNAL_STORAGE android:maxSdkVersion28 / uses-permission android:nameandroid.permission.MANAGE_EXTERNAL_STORAGE tools:ignoreScopedStorage / /manifestWRITE_EXTERNAL_STORAGE限定maxSdkVersion28是因为Android 10之后它不再生效。MANAGE_EXTERNAL_STORAGE是API 30的全文件访问权限需要用户在设置里手动授权代码里用Environment.isExternalStorageManager()判断是否已授权。uses-feature里的requiredfalse是选型关键没有USB Host能力的设备也能装有OTG能力的设备正常触发插拔广播。广播注册写在Activity的onCreate里用于感知U盘插拔。private final BroadcastReceiver usbReceiver new BroadcastReceiver() { Override public void onReceive(Context context, Intent intent) { String action intent.getAction(); if (UsbManager.ACTION_USB_DEVICE_ATTACHED.equals(action)) { // USB设备枚举成功但不代表文件系统已挂载完 // 延迟一小段时间再去反射卷列表命中率更高 new Handler(Looper.getMainLooper()).postDelayed(() - { ListString paths UsbPathReflector.detectUsbStoragePaths(context); if (paths.isEmpty()) { Log.w(UsbDemo, 设备已连接但未识别到可挂载的U盘卷); } else { Log.i(UsbDemo, U盘路径: paths.get(0)); } }, 200); } else if (UsbManager.ACTION_USB_DEVICE_DETACHED.equals(action)) { Log.i(UsbDemo, USB设备已拔出); } } }; IntentFilter filter new IntentFilter(); filter.addAction(UsbManager.ACTION_USB_DEVICE_ATTACHED); filter.addAction(UsbManager.ACTION_USB_DEVICE_DETACHED); registerReceiver(usbReceiver, filter);注释里已经把延迟原因写清楚了USB attach广播走在vold挂载之前立刻反射往往拿不到卷。200毫秒是经验值我在低端设备上实测要延迟到500毫秒才稳定第5章避坑会说明为什么。另一个容易被忽略的点ACTION_USB_DEVICE_ATTACHED不一定只由U盘触发键鼠、游戏手柄、MIDI设备都会触发所以广播里拿到的路径为空是常态不代表逻辑写错。注意200毫秒只是经验值低端设备建议放宽到500毫秒或者改成监听ACTION_MEDIA_MOUNTED再触发反射。3.2 反射工具类getVolumeList与getPath的兼容落地核心工具类封装成一个静态方法输入Context输出当前已挂载的U盘路径列表上层不管是Activity还是Service都能复用。import android.os.Build; import android.os.storage.StorageManager; import android.os.storage.StorageVolume; import java.lang.reflect.Method; import java.util.ArrayList; import java.util.List; public class UsbPathReflector { private static final String TAG UsbPathReflector; private static final String MOUNTED mounted; public static ListString detectUsbStoragePaths(Context context) { ListString usbPaths new ArrayList(); if (context null) { return usbPaths; } // 高版本直接走公开接口免反射 if (Build.VERSION.SDK_INT Build.VERSION_CODES.R) { StorageManager sm context.getSystemService(StorageManager.class); ListStorageVolume volumes sm.getStorageVolumes(); for (StorageVolume volume : volumes) { if (MOUNTED.equals(volume.getState()) isCandidateUsbPath(volume, context)) { usbPaths.add(volume.getDirectory().getAbsolutePath()); } } return usbPaths; } // 低版本走反射 getVolumeList() try { StorageManager sm (StorageManager) context .getSystemService(Context.STORAGE_SERVICE); Method method StorageManager.class.getMethod(getVolumeList); StorageVolume[] volumes (StorageVolume[]) method.invoke(sm); if (volumes null) { return usbPaths; } for (StorageVolume volume : volumes) { String path reflectVolumePath(volume); String state reflectVolumeState(volume); if (MOUNTED.equals(state) isCandidateUsbPath(volume, context)) { usbPaths.add(path); } } } catch (Exception e) { Log.e(TAG, 反射失败常见原因是OEM裁剪了getVolumeList, e); } return usbPaths; } private static String reflectVolumePath(StorageVolume volume) { // 高版本SDK里getPath已放开访问限制反射只是兜底 try { Method getPath StorageVolume.class.getMethod(getPath); return (String) getPath.invoke(volume); } catch (Exception e) { Log.e(TAG, getPath 反射失败, e); } return null; } private static String reflectVolumeState(StorageVolume volume) { try { Method getState StorageVolume.class.getMethod(getState); return (String) getState.invoke(volume); } catch (Exception e) { Log.e(TAG, getState 反射失败, e); } return null; } }逻辑说明detectUsbStoragePaths是入口先按系统版本分叉。R及以上版本用公开的getStorageVolumes获取卷列表省掉反射的警告日志低版本走getVolumeList反射路径。每个卷用getState判断挂载状态只有mounted才进入候选队列。reflectVolumePath和reflectVolumeState是独立的反射函数方便在个别OEM ROM上单独替换实现。参数说明MOUNTED字符串必须小写匹配StorageManager返回的状态是英文枚举字符串不是int常量getVolumeList返回数组可能为null要判空Context为空时直接返回空列表避免空指针污染调用方。3.3 内置存储与U盘路径的判定逻辑isCandidateUsbPath是整个工具类里最容易翻车的地方。内置存储路径是/storage/emulated/0SD卡和U盘路径则都形如/storage/XXXX-XXXX如果只按「非emulated」过滤SD卡会被误判成U盘。private static boolean isCandidateUsbPath(StorageVolume volume, Context context) { try { String path reflectVolumePath(volume); if (path null || path.isEmpty()) { return false; } // 排除内置存储emulated路径由FUSE动态生成 if (path.contains(emulated)) { return false; } // 排除应用主目录所在的物理存储卷 File appDir context.getExternalFilesDir(null); if (appDir ! null) { String primaryParent appDir.getAbsolutePath().replaceAll(/Android/data/.*, ); if (path.equals(primaryParent)) { return false; } } // Android 10路径形如 /storage/XXXX-XXXX卷号是十六进制 String name path.substring(path.lastIndexOf(/) 1); if (path.startsWith(/storage/) name.matches([A-Fa-f0-9]{4}-[A-Fa-f0-9]{4})) { return true; } // 部分国产ROM路径是 /mnt/usb_storage/xxx做兼容 return path.startsWith(/mnt/usb_storage/) || path.startsWith(/mnt/media_rw/); } catch (Exception e) { Log.e(TAG, 候选判定异常, e); } return false; }判定顺序是先排除emulated路径再用getExternalFilesDir定位主存储物理卷并排除最后用路径特征做正向匹配。正则[A-Fa-f0-9]{4}-[A-Fa-f0-9]{4}是U盘卷ID的典型格式在FAT32的卷序列号规则下几乎不会误伤SD卡。最后两行是国产ROM的替代挂载点兼容。这里有个细节值得多说U盘卷ID由MBR分区序列号生成固定是「四个十六进制-四个十六进制」格式SD卡路径在绝大多数设备上仍然复用mmcblk块设备命名规则末尾是数字而非这种格式。当然这不是绝对真理某些设备上TF卡也会被vold分配成这种卷ID格式所以完整判断还应该加上「卷描述字段包含USB关键词」这个软条件只是这个字段依赖厂商翻译不适合做硬性判据。4. 拿到路径后的读写实操File操作与权限边界4.1 用File对象打开U盘目录的三个前置检查拿到路径之后第一件事不是立刻listFiles而是先做三个前置检查路径存在性、目录属性、可读性。这三步能把后续问题提前暴露在日志里避免在业务代码里躲猫猫。public static void openUsbRoot(String usbPath) throws IOException { File usbRoot new File(usbPath); if (!usbRoot.exists()) { throw new IOException(路径不存在: usbPath); } if (!usbRoot.isDirectory()) { throw new IOException(usbPath 不是目录); } if (!usbRoot.canRead()) { throw new IOException(没有读权限: usbPath); } String[] entries usbRoot.list(); if (entries null) { throw new IOException(list() 返回 null存储权限可能被拦截: usbPath); } Log.i(UsbIo, U盘根目录条目数: entries.length); }前置检查的逻辑说明exists过滤路径错误isDirectory过滤拿到的是文件路径而不是目录路径canRead和list判空把权限问题拦在业务代码之前。list返回null和返回空数组含义不同——null是权限层拒绝访问空数组是目录确实没有文件。后面每次读写前建议把usbRoot的基础信息缓存起来频繁File操作会产生额外IOU盘顺序读场景下尤其明显。4.2 各Android版本的写权限策略U盘路径读写受两层管控Linux内核的AF_UNIX权限以及Android框架层的存储权限模型。不同版本策略差别很大。API 28及以下直接申请WRITE_EXTERNAL_STORAGE运行时权限即可U盘挂载路径和内置存储一样受这个权限保护。这里要注意targetSdkVersion不要高于28否则等于提前进入分区存储模型。API 29Android 10分区存储默认开启但官方留了豁免开关。在manifest的application标签下设置android:requestLegacyExternalStoragetrueApp就能继续用旧的文件路径模型访问U盘不需要申请MANAGE_EXTERNAL_STORAGE。这个豁免只在targetSdkVersion为29时有效targetSdk升到30之后会被忽略。API 30及以上豁免开关彻底失效访问U盘路径需要MANAGE_EXTERNAL_STORAGE权限。App需要引导用户到系统设置里手动开启「所有文件访问权限」然后代码检测Environment.isExternalStorageManager()授权成功后File操作才能穿透到/storage/XXXX-XXXX。注意requestLegacyExternalStorage只在targetSdkVersion为29时生效升级targetSdk到30后此开关无效。这里有个常踩的坑就算申请到了MANAGE_EXTERNAL_STORAGE部分Android 11设备上File对U盘路径的write操作仍然会失败。原因是MediaProvider对可移动存储的读权限放开给了所有App但写权限要求U盘本身是「可卸载主存储」或者「通过SAF授权」。遇到这种情况别死磕路径方案直接用Intent.ACTION_OPEN_DOCUMENT_TREE走SAF拿目录树用DocumentFile执行写操作这是系统真正推荐的U盘写入通道。4.3 大文件拷贝的Buffer与进度回调U盘上的固件包动辄几百MB拷贝代码写得不好容易在传输中被系统Kill。我一般用64KB的缓冲区做中转并暴露进度回调给UI层。public interface FileCopyListener { void onStart(String fileName, long totalBytes); void onProgress(long copiedBytes); void onFinish(boolean success, String errorMsg); } public static void copyFileWithProgress(String usbRootPath, String fileName, File dstDir, FileCopyListener listener) throws IOException { File src new File(usbRootPath, fileName); File dst new File(dstDir, fileName); long totalBytes src.length(); if (listener ! null) { listener.onStart(fileName, totalBytes); } try (FileInputStream fis new FileInputStream(src); FileOutputStream fos new FileOutputStream(dst)) { byte[] buffer new byte[64 * 1024]; int len; long copied 0; long lastNotify 0; while ((len fis.read(buffer)) ! -1) { fos.write(buffer, 0, len); copied len; // 每2MB回调一次避免频繁刷新UI if (copied - lastNotify 2 * 1024 * 1024) { if (listener ! null) { listener.onProgress(copied); } lastNotify copied; } } fos.flush(); if (listener ! null) { listener.onFinish(true, null); } } catch (IOException e) { if (listener ! null) { listener.onFinish(false, e.getMessage()); } throw e; } }参数说明buffer大小选64KB是因为U盘FAT32的默认簇大小通常是32KB或64KB两倍于簇大小的缓冲能减少地址切换的机会。进度回调按每2MB节流一次这是针对进度条控件帧率的经验值回调太频繁反而容易卡UI。try-with-resources保证两个流在异常时也能关闭U盘拷贝最忌讳的是文件句柄不释放。5. 避坑指南反射路径方案跑不通时的排查路径5.1 插上U盘立即反射拿不到卷现象USB插上后日志显示设备连接成功但detectUsbStoragePaths返回空列表。 原因ACTION_USB_DEVICE_ATTACHED只是总线枚举事件vold的文件系统挂载可能发生在几百毫秒到两秒之后反射查询时机太早。 解决优先监听ACTION_MEDIA_MOUNTED广播再触发反射这个广播由vold挂载完成后发出时序上最可靠。如果必须用attach广播postDelayed的延迟建议放宽到500毫秒以上并在首次拿到空列表后做一次3秒内的重试某台设备实测挂载耗时2.1秒。5.2 反射调出NoSuchMethodException或NoSuchFieldException现象低版本设备上运行正常换到Android 12的某品牌机型就抛异常或者日志出现Accessing hidden method相关警告。 原因OEM在自家ROM里删除了getVolumeList方法或者系统把非SDK接口限制升级为直接屏蔽。 解决按降级顺序做三连先走公开接口getStorageVolumes再走反射getVolumeList最后反射mVolumes字段。这三个入口能覆盖大多数机器。真机调试时开发者选项里没有「不限制隐藏API」开关的情况下可以用adb shell settings put global hidden_api_policy 1临时放开限制辅助定位问题。5.3 路径拿到了但listFiles返回null现象反射返回/storage/1234-5678字符串有值但new File(path).list()是null。 原因App没有存储权限。Android 10的分区存储对非媒体文件做透明拦截list返回null表示访问被拒不是路径拼错。 解决先检查targetSdkVersion。targetSdk为29时加requestLegacyExternalStoragetruetargetSdk为30以上时引导用户打开MANAGE_EXTERNAL_STORAGE页面。个别情况是部分设备需要用户完成授权后重新调用一次detectUsbStoragePaths权限页面返回后记得刷新我因为忘了刷新在测试机上反复翻车了两次。5.4 内置存储被误判成U盘路径现象应用私有目录所在存储卷被当作U盘返回固件写进了内置存储。 原因只按「非emulated」过滤的判定逻辑在部分使用FUSE绑定的设备上不够严谨内置存储可能以原生物理卷形式暴露出来。 解决用getExternalFilesDir(null)拿到App主目录位置往上推一层如果候选路径和主目录所在物理卷重合就排除。再叠加卷ID正则做正向匹配双保险。测试时最好在带SD卡槽的机型上试一遍确认TF卡不会混进U盘列表。5.5 U盘没有挂载反射卷列表里查无此卷现象U盘插上后USB设备列表能查到但StorageManager的卷列表里根本没有新卷文件系统层毫无反应。 原因U盘格式是NTFS或exFAT而vold的内核驱动不支持少数U盘是多分区只有第一分区尝试挂载剩余分区在部分Android实现上被丢弃。 解决快速判定——换一个FAT32格式的U盘测试。如果FAT32正常说明是文件系统兼容问题引导用户重新格式化U盘为FAT32。如果项目必须支持NTFS可以在UsbManager授权后走bulkTransfer协议自己实现块级读写但工程量接近移植文件系统驱动除非设备固定否则不建议。U盘多分区场景下检查vold日志里media_rw下各分区的挂载状态确认分区策略问题。6. 验证进阶用自检函数确认路径可读可写反射拿到的路径看着像样不代表真正能读写。U盘如果是FAT32但被硬件写保护开关锁成只读反射结果依然正常卷状态照样是mounted可一写文件就抛IOException。所以在真正开始业务拷贝之前我习惯先跑一个自检函数在U盘根目录创建随机命名的临时文件写入固定字节回读校验长度最后删除。三步全过才认为这个路径在当前设备上可用否则直接把错误抛给用户避免业务代码在深层报出莫名其妙的IOException。public static boolean selfCheckUsbPath(String usbPath) { File tmp new File(usbPath, .usb_check_ System.currentTimeMillis() .tmp); try (FileOutputStream fos new FileOutputStream(tmp)) { fos.write(usb_ok.getBytes(StandardCharsets.UTF_8)); fos.flush(); } catch (IOException e) { return false; } boolean readable tmp.exists() tmp.length() 6; boolean deleted readable tmp.delete(); return readable deleted; }这个自检函数有两点值得延伸校验字节固定为usb_ok正好6个字节length判断能顺手确认写盘长度没被截断临时文件名带时间戳避免多线程同时自检时互相覆盖。如果在只读挂载的U盘上运行第一步写文件就会抛异常直接返回false不需要额外判断。进阶场景里自检函数的返回值还能和挂载状态联动。U盘识别成功但自检失败时说明文件系统层可用但框架权限层受限日志里应同时记录detectUsbStoragePaths返回的卷状态和自检结果驱动后续排查往权限方向走。以前我在某跨平台系统模块上做U盘升级一开始没加自检用户反馈插上没反应追了半天才发现是某型号把U盘默认只读挂载。从那以后我每次拿到反射路径都强制走一遍自检能写能删再继续少踩了很多坑。这份资源里的工具类已经把这套代码整理成可直接引入的Module动手改造时能省不少调参时间希望帮到你。本文还有配套的精品资源点击获取