简介:面向需要在Android应用中实现U盘/OTG存储读写的开发人员,这份资料完整演示了在系统安全限制下通过反射获取U盘路径并完成文件读写的方法。内容从Android内部存储与外部存储的差异讲起,覆盖外部存储权限声明、Android 6.0及以上运行时权限申请、MediaScannerConnection内部字段反射、VolumeInfo类型识别等关键知识点,并结合文件读写示例说明异常处理与兼容性注意事项。资源共110个文件,以XML布局与配置、PNG图片、Gradle构建脚本、Java/Kotlin源码为主,另含properties、jar包等工程辅助文件,压缩包约518KB,体积小巧但结构清晰,便于按模块查阅;其中XML与源码配合可还原出一个可运行的工程骨架。目前已有1356人学习浏览,对希望突破常规API限制、理解外部存储挂载机制和U盘目录识别逻辑的开发者具有直接参考价值,也可作为后续Android版本适配与读写方案优化的起点。
1. Android 反射获取 U 盘路径:OTG 读写到底卡在哪一步
做 Android 文件读写的人,迟早会撞上 U 盘。手机通过 OTG 线插上 U 盘后,系统确实会把 U 盘挂载到某个路径,但官方 SDK 一直没给一个干净的公开接口让你直接拿这个路径——MediaStore 扫不到、File 遍历/storage又分不清哪个是 U 盘。某开发者第一次做这个需求,在真机上折腾了两天,最后被一段反射代码解决了:通过StorageManager的隐藏方法拿到挂载卷信息,再配合Environment.isExternalStorageRemovable把内置存储踢掉,才能稳定定位到 U 盘真实路径。这篇就是把那次完整过程拆开:方案怎么选、反射代码怎么写、路径拿到了为什么还是写不进去,以及七个必须提前知道的坑。
适合两类人:一类是做文件管理器、备份工具、OTG 传输类 App 的开发者,需要在 Android 原生层直接读写 U 盘;另一类是已经用 SAF 框架做得想吐、想试试直接拿路径操作的人。反射方案的优点是不依赖系统文件选择器,路径拿到后FileInputStream、FileOutputStream随便用;代价是碎片化兼容问题多,而且每个 Android 大版本都可能改内部实现。往下看之前先明确一点:如果你只在 Android 4.4 以下跑,那直接用MountService的老反射就行了;但今天主流设备都是 7.0 以上,本文代码按 Android 7.0 到 12 的实测结果来写。
2. 为什么是反射而不是 MediaStore:方案选型与适用边界
2.1 MediaStore 的局限:它其实看不到 U 盘
Android 从 4.4 开始,MediaStore对外部存储的访问策略就变得很别扭。U 盘通过 OTG 挂载后,并不是所有 ROM 都会把它的文件索引进 MediaStore。某公司实测过一批设备:一部分 ROM 会把 U 盘根目录的图片、视频扫进 MediaStore,但文档、压缩包、任意二进制文件完全不索引;另一部分 ROM 干脆整个 U 盘都不出现在 MediaStore 的查询结果里。
这就是为什么不要指望query(MediaStore.Files.getContentUri("external"))能列出 U 盘内容。即使能列出,MediaStore 返回的是 content URI,不是直接的文件路径,你还要用OpenableColumns或者ContentResolver.openFileDescriptor去操作。对于批量拷贝、按目录递归遍历这种需求,content URI 方案写起来又绕又慢。而且 MediaStore 的索引有延时,刚插上 U 盘立刻去查,大概率拿到空集合。
反射方案绕开了索引这一层,直接问StorageManager要挂载卷的路径。U 盘插上后,StorageManager.getVolumeList()会在几秒内返回包含 U 盘挂载信息的对象,路径是真实存在的文件系统路径,new File(path)之后就能用标准 Java IO 操作。这就是它在这个场景下比 MediaStore 合适的最根本原因。
2.2 反射拿 StorageManager 的四个关键对象
StorageManager的挂载信息在公开 API 里只暴露了一部分,路径相关的接口基本都被隐藏了。实现反射拿路径,需要依次拿到四个关键对象:
| 对象 | 获取方式 | 作用 |
|---|---|---|
| StorageManager 实例 | context.getSystemService("storage") | 入口 |
| getVolumeList 方法 | Class.forName("android.os.storage.StorageManager").getDeclaredMethod("getVolumeList") | 拿到所有挂载卷 |
| StorageVolume 对象数组 | 反射调用方法后的返回值 | 每个卷代表一个存储设备 |
| StorageVolume 的 getPath 方法 | StorageVolume.class.getDeclaredMethod("getPath") | 取出卷的挂载路径 |
getVolumeList在 Android 7.0 之前的签名是getVolumeList()无参,之后也是无参,但因为方法被@hide标记,直接调用会编译报错,只能反射。getPath同理,在不同版本上返回的内容格式有差别,有的返回/storage/XXXX-XXXX(U 盘卷标或序列号),有的返回/mnt/media_rw/XXXX-XXXX,这个问题后面避坑章节会展开说。
2.3 路径过滤:怎么把内置存储和 SD 卡踢出去
拿到StorageVolume[]之后,数组里不止 U 盘一个元素。典型设备上会包含内置存储、外置 SD 卡、U 盘三个卷,如果不过滤,你可能会把/storage/emulated/0当成 U 盘路径去写,写完才发现写进了手机内部存储。
过滤逻辑按两步走:第一步,判断StorageVolume是不是可移除的,调用Environment.isExternalStorageRemovable(volume.getPath())可以过滤掉内置存储;第二步,在可移除的卷里区分 U 盘和 SD 卡。SD 卡一般挂载在/storage/XXXX-XXXX这种路径上,但 U 盘也长这样,单纯看路径区分不了。
更好的做法是看StorageVolume的getUuid方法,SD 卡和 U 盘都会返回类似XXXX-XXXX的磁盘 ID,但内置存储的 UUID 是空。另一个判断维度是isEmulated,U 盘不是模拟存储,SD 卡也不是,所以还需要结合设备是否有 SD 卡槽来判断——如果设备本身没有 SD 卡槽,那可移除卷基本就是 U 盘。某开发者在这块吃过亏:一台带 SD 卡槽的平板插上 U 盘后,可移除卷有两个,他靠路径前缀区分,结果某次 SD 卡格式化成 exFAT 后路径格式变了,直接把 SD 卡路径当 U 盘用了。后来改成按卷的getDescription或者插拔前后的卷集合差集来做,才稳定下来。
3. 完整实现:从挂载识别到文件读写一条龙
3.1 权限与初始化:Android 6.0 之后的动态申请
反射拿路径本身不需要权限,但拿到路径后你要new FileOutputStream去写,这就涉及存储权限了。Android 6.0 以上必须动态申请READ_EXTERNAL_STORAGE和WRITE_EXTERNAL_STORAGE,而且这两个权限是分开弹窗的。注意WRITE_EXTERNAL_STORAGE属于危险权限,必须在运行时请求,不能在清单里声明完就当没事。
代码实现:
private static final int REQUEST_STORAGE_PERMISSION = 1001; private void requestStoragePermission() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { String[] permissions = { Manifest.permission.READ_EXTERNAL_STORAGE, Manifest.permission.WRITE_EXTERNAL_STORAGE }; if (checkSelfPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) { requestPermissions(permissions, REQUEST_STORAGE_PERMISSION); } } }这段逻辑的核心是:先判断系统版本,6.0 以下不需要动态申请;6.0 以上只检查写权限,因为WRITE_EXTERNAL_STORAGE被授予时,系统会同时默认授予读权限。实际开发中经常有人把两个权限分开检查、分开请求,结果用户在权限弹窗上点了"仅此一次",第二次访问 U 盘时又崩。正确做法是检查写权限,不顺带检查读权限。
3.2 反射获取 U 盘路径并封装成工具类
反射代码建议封装成一个工具类,不要散落在 Activity 里。下面这个UsbStorageUtils做了两件事:反射拿StorageVolume[],然后过滤出可移除的 U 盘路径。注意cancel标志位防止用户取消时崩溃。
public class UsbStorageUtils { private static final String TAG = "UsbStorageUtils"; public static String getUsbPath(Context context) { try { StorageManager sm = (StorageManager) context.getSystemService("storage"); Class<?> storageManagerClass = Class.forName("android.os.storage.StorageManager"); Method getVolumeListMethod = storageManagerClass.getDeclaredMethod("getVolumeList"); getVolumeListMethod.setAccessible(true); Object result = getVolumeListMethod.invoke(sm); if (result == null) { Log.e(TAG, "getVolumeList returned null"); return null; } StorageVolume[] volumes = (StorageVolume[]) result; for (StorageVolume volume : volumes) { if (Environment.isExternalStorageRemovable(volume.getPath()) && !volume.isEmulated()) { return volume.getPath(); } } return null; } catch (Exception e) { Log.e(TAG, "reflect get usb path failed", e); return null; } } }setAccessible(true)这一步不能省,不加的话,隐藏方法会抛IllegalAccessException。另一个细节是返回值强转成StorageVolume[],虽然getVolumeList在 AOSP 源码里返回的就是这个类型,但某些 ROM 做过混淆,稳妥起见可以强转前先判断result instanceof StorageVolume[]。
拿到/storage/XXXX-XXXX这种路径后,直接new File(path)就能用了。实际项目中,这个工具类还会加上系统广播注册逻辑:监听ACTION_MEDIA_MOUNTED、ACTION_MEDIA_UNMOUNTED来感知 U 盘插拔,插拔后刷新路径缓存。很多开发者忽略这一点,导致用户拔下 U 盘再插上,App 还拿着旧路径去写,写操作直接抛FileNotFoundException。
3.3 真实读写验证:拷贝一张照片到 U 盘
路径拿到手,写一个最小验证用例:把 App 私有目录下的一张图片拷到 U 盘根目录,然后读回来比对字节数。这一步能筛掉大部分环境问题。
private void copyFileToUsb(String usbPath) { File source = new File(getExternalFilesDir(null), "test.jpg"); File dest = new File(usbPath, "test_copy.jpg"); try (FileInputStream fis = new FileInputStream(source); FileOutputStream fos = new FileOutputStream(dest)) { byte[] buffer = new byte[8192]; int len; while ((len = fis.read(buffer)) != -1) { fos.write(buffer, 0, len); } fos.flush(); Log.d(TAG, "copy success, dest exists: " + dest.exists() + ", size: " + dest.length()); } catch (IOException e) { Log.e(TAG, "copy failed", e); } }FileOutputStream构造时不会自动创建父目录,所以如果 U 盘挂载路径下还有一层目录,得先dest.getParentFile().mkdirs(),不然会抛FileNotFoundException: /storage/XXXX-XXXX/sub/test.jpg (No such file or directory)。buffer 大小设置成 8192 是读写吞吐和内存占用比较平衡的值,拷贝大文件时比 1024 快很多,又不会像 64KB buffer 那样在某些低端设备上频繁 GC。
验证时建议做三步:第一步看dest.exists()是不是 true;第二步看dest.length()是否等于源文件长度;第三步直接拔下 U 盘插到电脑上看文件能不能打开。第三步很重要,有些 ROM 上文件写成功了,但系统没有及时同步文件系统元数据,拔掉 U 盘后文件是 0 字节,解决办法后面避坑章节专门说。
4. 避坑:路径拿到了却写不进文件的 7 个常见问题
4.1 反射得到的是空列表,或者拿到的是内置存储路径
现象:getUsbPath返回null,或者返回/storage/emulated/0。
原因:一部分 ROM(比如某国产品牌)把getVolumeList的实现改了,返回的数组里不含 U 盘;还有一部分设备上Environment.isExternalStorageRemovable("/storage/emulated/0")会误判返回 true,因为系统认为模拟外置存储也算 removable。解决:不要只依赖isExternalStorageRemovable一个条件,加一道volume.getPath().contains("emulated")判断直接排除。同时把反射失败时的异常信息打全,getVolumeList在某些 ROM 上会抛NoSuchMethodException,说明方法名被改成了getVolumeList()带参数的重载,这种只能做兼容分支,反射getMethod("getVolumeList", int.class)再试一次。
4.2 明明有读写权限,write 却抛 Permission denied
现象:AndroidManifest里声明了权限,运行时也弹窗同意了,但FileOutputStream一写就抛Permission denied。
原因:Android 11(API 30)开始,WRITE_EXTERNAL_STORAGE对File路径直接写外部存储做了更严格的限制。就算有权限,也不能随便写任意路径,必须落在 App 专属目录或通过 MediaStore/SAF 操作。U 盘路径属于"其他应用的外部存储",直接 File 写会被FileSystemException拦下来。解决:Android 11 以上建议直接走 SAF 框架,调Intent.ACTION_OPEN_DOCUMENT_TREE让用户授权 U 盘根目录,拿到 tree URI 后用DocumentFile读写。反射方案在 Android 10 及以下还能用,11 以上只适合做读操作或配合MANAGE_EXTERNAL_STORAGE权限使用——后者需要在设置里手动开启"所有文件访问",普通 App 很难过审,不建议专门为 U 盘读写引入。
4.3 中文文件名乱码与文件名长度超限
现象:往 U 盘写入测试报告.pdf,文件名变成æµè¯æ¥å.pdf或直接报错。
原因:U 盘默认文件系统是 FAT32 或 exFAT,FAT32 不支持 UTF-8 文件名,系统底层转换时容易出乱码;另外 FAT32 单个文件最大 4GB,文件名最长 255 字节。解决:写文件前做一次文件名规范化,把非 ASCII 字符转成拼音或英文缩写;或者干脆改用 exFAT 格式的 U 盘。批量拷贝场景下,先检查dest.getName().getBytes("UTF-8").length > 200就自动截断,避免 255 字节上限触发IOException: File name too long。
4.4 挂载路径在不同 ROM 上表现不一致
现象:同一台手机,U 盘路径在 A 版本上是/storage/AAAA-BBBB,升级系统后变成/mnt/media_rw/AAAA-BBBB。
原因:StorageVolume.getPath()返回的是卷的挂载点,不同 ROM 对挂载点的定义不一致。/storage/是用户空间可见路径,/mnt/media_rw/是底层挂载路径有 SELinux 限制,App 直接访问后者大概率 Permission denied。解决:拿到路径后先判断前缀,/mnt/media_rw开头的不要直接用,尝试通过File.getCanonicalPath()解析成/storage/下的路径;解析不了就放弃反射,回退到 SAF 方案。血泪经验:别在代码里硬编码路径前缀,每次都做一次canRead检测,读不了就换方案。
4.5 热插拔之后路径失效,File 对象变成僵尸句柄
现象:U 盘拔下再插上,之前保存的String usbPath还在,但new File(usbPath).exists()返回 false,写入直接失败。
原因:U 盘重新挂载后,挂载点可能变成新的路径(卷标没变时路径相同,但文件系统句柄已失效)。解决:注册BroadcastReceiver监听ACTION_MEDIA_MOUNTED和ACTION_MEDIA_EJECT,每次挂载事件后重新调用getUsbPath,不要缓存路径超过一次插拔周期。另外插拔瞬间的ACTION_MEDIA_UNMOUNTED不是立即回调的,文件操作前先File.exists()做一次活体检测,防写一半 U 盘被拔走。
4.6 部分设备把 U 盘挂到了 /mnt/media_rw 而不是 /storage
现象:getUsbPath 返回/mnt/media_rw/XXXX,对这个路径canRead()返回 true,但listFiles()返回空数组。
原因:/mnt/media_rw是 vold 的私有挂载命名空间,App 进程通过反射虽然能看到路径,但 SELinux 策略不允许普通 App 直接读该目录下的文件节点。解决:把这个路径传给Environment.isExternalStorageRemovable不会报错,但它不是真正的用户可访问路径。可以尝试在反射代码里同时取StorageVolume的getInternalPath方法,某些版本上有internalPath字段对应/mnt/media_rw路径,而getPath对应/storage路径,优先用非 media_rw 的那个。
4.7 小容量 U 盘出现 0 字节文件
现象:写入过程没报错,dest.length()是真实大小,但 U 盘拔下来插电脑上文件是 0 字节。
原因:文件系统缓存还没落盘就被拔走。代码里虽然调了fos.flush(),但flush只把 Java 层缓冲区推给系统,不保证物理写入完成。解决:写完文件后强制做一次FileDescriptor.sync(),或者直接调fos.getFD().sync()再close()。sync 会拖慢写入速度,但 U 盘写入这种低频操作无所谓,数据安全优先。某开发者的习惯是批量拷贝完所有文件后统一 sync 一次,而不是每个文件都 sync,省时间且效果一样。
fos.flush(); fos.getFD().sync(); fos.close();5. 进阶:用 StorageVolume 判断可移动磁盘并提升兼容性
5.1 用 StorageManager.getStorageVolumes 拿到类型信息(API 24+)
getVolumeList反射方案在 Android 7.0 之后的设备上有一个更体面的替代:公开的StorageManager.getStorageVolumes()方法(API 24 引入)。它返回List<StorageVolume>,不需要反射。这个 API 在 Android 9 之前可以直接拿到isRemovable等信息,但getPath在部分版本上仍被隐藏,所以实际用法是混搭:公开方法拿卷列表,反射拿每个卷的路径。
代码实现:
@RequiresApi(api = Build.VERSION_CODES.N) public static String getUsbPathApi24(Context context) { StorageManager sm = (StorageManager) context.getSystemService("storage"); List<StorageVolume> volumes = sm.getStorageVolumes(); for (StorageVolume volume : volumes) { // 公开方法,判断是否可移除 if (volume.isRemovable()) { try { // getPath 仍是隐藏方法,需要反射 Method getPathMethod = StorageVolume.class.getDeclaredMethod("getPath"); String path = (String) getPathMethod.invoke(volume); if (path != null && !path.contains("emulated")) { return path; } } catch (Exception e) { Log.e(TAG, "getPath reflect failed", e); } } } return null; }这套写法的优势是getStorageVolumes()不会被 ROM 改签名,即使反射getPath失败,至少volume.isRemovable()能可靠区分内置存储。注意getStorageVolumes()在 Android 10 以下返回的列表里有模拟内置卷,isRemovable()对它是 false,过滤时不用额外处理。而 Android 11 以上,getStorageVolumes可能返回null或者隐藏 U 盘卷,需要捕获SecurityException。
5.2 结合 Environment.isExternalStorageRemovable 做双重校验
StorageVolume.isRemovable()和Environment.isExternalStorageRemovable()是两个独立的判断来源,前者在部分 ROM 上被篡改(设成永远 false),后者底层读取的是 vold 的卷属性,相对可靠。双重校验的逻辑是:两者都为 true 才认定为 U 盘。但要注意isExternalStorageRemovable需要传入路径字符串,如果你拿到的路径是/mnt/media_rw/...,它可能返回 false,导致误判。所以调用顺序很关键:先用volume.isRemovable()缩小范围,再对候选路径调Environment.isExternalStorageRemovable(path)做二次确认,两个条件 AND。
在 Android 10 之前,这套双重校验能覆盖 90% 的设备。到了 Android 10 和 11,getPath反射经常抛NoSuchMethodException,因为 AOSP 把StorageVolume.getPath改成调用getPathInternal间接实现。此时可以反射getPathInternal,签名是getPathInternal()或getPathInternal(int userId),前者无参,后者带 userId。某开发者在 Android 11 真机上两种签名都试过,无参版本成功率更高。
5.3 从反射路径回退到 SAF:一把后悔药
反射方案说到底走的是系统私有接口,Google 随时可能改。如果想项目长期稳定,建议在反射失败时自动降级到 SAF 框架:弹系统文件选择器让用户授权 U 盘目录,然后通过DocumentFile读写。降级条件写成这样:反射返回 null、返回的路径canRead()为 false、或者写入时抛Permission denied且错误信息包含EACCES,三种情况任一种触发 SAF 兜底。
private void writeWithFallback(String usbPath, File source) { try { File dest = new File(usbPath, source.getName()); copyFile(source, dest); } catch (IOException e) { // 反射路径写失败,自动切 SAF Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE); startActivityForResult(intent, REQUEST_OPEN_TREE); } }SAF 授权后你会得到一个 tree URI,每次读写都要通过DocumentFile.fromTreeUri(context, uri)拿目录对象,再createFile("image/jpeg", name)建文件。这套流程比 File 路径啰嗦,但它稳定、不依赖反射、上架 Google Play 不会被审核卡。实际项目里我的建议是:反射方案只作为"快速路径",在多设备测试中一旦发现兼容性问题立刻切 SAF,不要让用户长期暴露在写失败的风险里。
6. 最后再收紧一步:挂载点检测与拷贝前的路径自检
反射拿到 U 盘路径后,别急着直接写。先跑一遍路径合法性自检,能拦掉八成的问题。自检逻辑包含三块:路径前缀是否合法、目录是否可读可写、挂载状态是否活动。把这套逻辑封装成checkUsbPath(String path)方法,在每次 U 盘操作前调用一次,比出问题后再排查快得多。
public static boolean checkUsbPath(String usbPath) { if (usbPath == null || usbPath.trim().isEmpty()) { return false; } // 排除非用户空间路径 if (usbPath.startsWith("/mnt/media_rw/")) { Log.w(TAG, "media_rw path detected, try /storage equivalent"); return false; } File dir = new File(usbPath); if (!dir.exists() || !dir.isDirectory()) { return false; } // 活体检测:尝试创建临时文件再删除 File probe = new File(dir, ".usb_probe_" + System.currentTimeMillis()); try { if (!probe.createNewFile()) { return false; } probe.delete(); return true; } catch (IOException e) { Log.e(TAG, "probe file create failed", e); return false; } }临时探针文件是最好用的路径自检手段,createNewFile能成功说明路径可写,能删掉说明权限完整。探针文件名加时间戳防止被其他应用抢先创建导致createNewFile返回 false。这一步成本极低,却能提前暴露 SELinux 限制、FUSE 挂载异常、FAT32 只读挂载三类问题。某开发者在一次适配中,靠探针文件发现某款设备的 U 盘是以只读方式挂载的,代码里没处理,结果用户反馈"App 显示成功但文件没拷进去",后来把只读挂载也列入 U 盘状态判断,才修掉这个隐蔽 bug。
从那以后,我每次写 U 盘相关功能都要在操作前强制走一遍checkUsbPath,宁可多花 20 毫秒做探针,也不想让用户盯着"写入成功"的提示却找不到文件。反射方案虽好,但它在 Android 大版本迭代中越来越脆,建议你下载源码包后先在 Android 10 的模拟器上跑通,再拿一台 Android 11 以上的真机做降级验证。这套方案能不能最终落进你的项目,取决于你愿意为兼容性付出多少;但至少,U 盘路径不会再是黑匣子。希望帮到你。
本文还有配套的精品资源,点击获取