简介:一套面向Android开发者的U盘读写与OTG路径反射获取完整工程代码包,直击系统权限限制与厂商差异导致的挂载点获取难题。资源共110个文件,压缩包仅518KB,结构紧凑;其中xml对应Manifest权限声明与界面资源,gradle和properties管理构建依赖,java与kt源文件实现反射逻辑及文件操作,png为图标资源,bin、lock等属于编译缓存与锁文件,导入Android Studio即可直接编译调试。目前已有1356人学习下载,说明在同类需求中具有一定实践参考价值。内容覆盖从Android 6.0+运行时权限申请,到反射遍历VolumeInfo获取U盘挂载路径,再到基于FileInputStream/FileOutputStream执行文件读写的完整流程,并配有可运行的工程配置,能帮助开发者避开常规API限制,理解反射调用系统私有接口的原理与风险。对想深入掌握Android存储架构、解决OTG设备兼容适配的进阶开发者而言,这份代码包既提供了可直接改造的基线工程,也给出了排查挂载路径问题的清晰思路。
1. 项目定位:Android U盘读写,为什么非要用反射拿路径
插上 OTG 线、U 盘在系统文件管理器里明明能看到,自己的 App 却怎么都列不出文件——这是做 Android 外设开发最常见的一道坎。系统没公开“当前挂载了哪些 U 盘卷”的 API,而 MediaStore 只能看到多媒体文件,拿不到完整目录树。这个项目给出的解法很直接:通过反射调用 StorageManager 的内部接口,把 U 盘在系统里的真实挂载路径挖出来,再基于这个路径做文件读写。它适合要做设备端离线数据采集、工控报表导出、U 盘固件升级的开发者,也适合刚接触 OTG 开发、想知道路径来源的 Android 新人。
顺着这个思路往下走,项目的价值不只是“能读到 /mnt/media_rw/XXXX 这样的路径”,而是把“枚举卷 → 拿路径 → 读写文件 → 处理热插拔”这一整套链路做成了一份可跑可改的工程。我会从原理、结构、核心代码、接入方式和踩坑点依次拆开讲。
2. 原理与结构:从 USB 挂载卷到反射路径的完整链路
2.1 为什么官方 API 拿不到 U 盘路径
Android 从 4.4 开始引入了 StorageManager 作为存储卷的统一入口,但它对外暴露的接口要么聚焦在应用私有目录,要么只提供 StorageVolume 的弱化信息。U 盘通过 USB Host 协议挂载后,系统会把它登记为一个卷(Volume),真实挂载点通常位于 /storage/XXXX-XXXX 或 /mnt/media_rw/XXXX-XXXX 这类目录,而这些卷的遍历接口并没有进入公开 SDK。
常见做法是把目标锁定在 StorageManager 的内部方法 getVolumeList 上,它返回 StorageVolume[] 数组,每个 StorageVolume 内部字段里藏着 path、state、isRemovable、isEmulated 等关键信息。反射要做的就是从 StorageVolume 对象中取出 path 字段,再用 isRemovable 和 state 过滤出“处于挂载状态的移动 USB 卷”。整个链路是:USB Host 层识别设备 → vold 守护进程挂载卷 → StorageManager 注册卷 → 反射枚举卷拿路径 → 应用读写。
2.2 工程模块划分与关键类
工程本身是一个标准的 Android 项目,按职责拆成三层。
app/src/main/java/com/example/usbdrive/ ├── usb/ │ ├── UsbDriveManager.java // 反射获取U盘卷信息 │ ├── UsbVolume.java // 卷信息封装:path、state、removable │ └── UsbDriveListener.java // 热插拔与挂载状态回调 ├── file/ │ ├── FileOperator.java // 文件复制、删除、目录遍历 │ └── FileResult.java // 读写结果封装 ├── permission/ │ └── PermissionHelper.java // 运行时权限与MANAGE_EXTERNAL_STORAGE处理 └── ui/ ├── MainActivity.java // 入口:列出U盘根目录文件 └── FileListAdapter.java // 文件列表展示UsbDriveManager 是整个工程的地基,封装了反射枚举与状态过滤;FileOperator 负责路径到手之后的实际读写;PermissionHelper 专门处理 Android 10 以后分区存储带来的权限黑洞。UI 层只是验证用途,真正值钱的逻辑都在 usb 与 file 两个包。
2.3 从卷枚举到文件读写的整体流程
一次完整的 U 盘读写按下面几步走:
- 应用启动后先跑 PermissionHelper,检查是否持有读写外部存储的权限。
- 调用 UsbDriveManager.refreshUsbVolumes(),触发反射枚举当前所有 StorageVolume。
- 用 isRemovable + state == MOUNTED 过滤,拿到 U 盘根路径。
- 把根路径交给 FileOperator,遍历目录或执行复制。
- 注册 UsbDriveListener,在用户拔出 U 盘时收到回调,清理正在进行的读写任务。
这里有一个容易被忽略的点:U 盘路径不是固定不变的。同一张 U 盘在不同设备上可能被挂载到不同卷名,重新格式化后卷名也会变,所以每次插入都必须重新枚举,不能把路径硬编码进配置文件。
3. 核心代码拆解:反射获取路径与读写落地
3.1 反射获取 U 盘挂载路径
反射的核心类是 UsbDriveManager。它负责调用 StorageManager 的隐藏方法 getVolumeList,再从返回的 StorageVolume 数组里逐一读取内部字段。
public List<UsbVolume> refreshUsbVolumes() { List<UsbVolume> result = new ArrayList<>(); try { // 1. 通过反射拿到 StorageManager 实例 StorageManager sm = (StorageManager) context.getSystemService(Context.STORAGE_SERVICE); Class<?> storageManagerClass = sm.getClass(); // 2. 反射调用 getVolumeList(),返回 StorageVolume[] Method getVolumeListMethod = storageManagerClass.getMethod("getVolumeList"); Object[] volumes = (Object[]) getVolumeListMethod.invoke(sm); if (volumes != null) { for (Object volume : volumes) { UsbVolume usbVolume = parseVolume(volume); if (usbVolume != null && usbVolume.isRemovable() && usbVolume.isMounted()) { result.add(usbVolume); } } } } catch (Exception e) { // 反射不能保证每个ROM都成功,这里必须兜底 Log.e(TAG, "refreshUsbVolumes failed", e); } return result; } private UsbVolume parseVolume(Object storageVolume) throws Exception { Class<?> volumeClass = storageVolume.getClass(); // 3. 读取 StorageVolume 内部的 path 字段 Field pathField = volumeClass.getDeclaredField("path"); pathField.setAccessible(true); String path = (String) pathField.get(storageVolume); // 4. 读取 removable / state 字段,判断是否为U盘 Field removableField = volumeClass.getDeclaredField("removable"); removableField.setAccessible(true); boolean removable = removableField.getBoolean(storageVolume); Field stateField = volumeClass.getDeclaredField("state"); stateField.setAccessible(true); String state = (String) stateField.get(storageVolume); return new UsbVolume(path, removable, state, storageVolume); }第 1 步从系统服务拿 StorageManager,第 2 步反射调用 getVolumeList 拿到卷对象数组。这里要注意,getVolumeList 的返回值类型是 StorageVolume[],但反射统一按 Object[] 接,再逐个解析。第 3、4 步是真正让 U 盘路径现形的关键:path 字段存放挂载路径,removable 用来区分 U 盘和内置存储,state 必须是 mounted 才说明卷可读写。
参数上需要留意的是,不同 Android 版本 StorageVolume 内部字段名基本稳定,但个别国产 ROM 会做混淆或改名。所以 parseVolume 里的 getDeclaredField 建议统一走字段名白名单匹配:先试 path,再试 rawPath,最后试 fsUuid+挂载根拼接,失败就跳过,而不是直接抛异常终止整个枚举。
3.2 文件读写:从路径到字节流
拿到路径后,读写就是对普通 File 的操作。下面是 FileOperator 里做文件复制到 U 盘的方法。
public FileResult copyToUsb(File sourceFile, String usbRootPath, String targetFileName) { File targetDir = new File(usbRootPath, "export"); if (!targetDir.exists() && !targetDir.mkdirs()) { return FileResult.fail("无法创建目录: " + targetDir.getAbsolutePath()); } File targetFile = new File(targetDir, targetFileName); try (InputStream in = new FileInputStream(sourceFile); OutputStream out = new FileOutputStream(targetFile)) { byte[] buffer = new byte[8 * 1024]; int len; while ((len = in.read(buffer)) != -1) { out.write(buffer, 0, len); } return FileResult.success(targetFile.getAbsolutePath(), targetFile.length()); } catch (IOException e) { return FileResult.fail("复制失败: " + e.getMessage()); } }调用方式很简单:FileOperator operator = new FileOperator(); operator.copyToUsb(本地文件, "/storage/ABCD-1234", "report.dat")。这个方法的几个参数值得解释:usbRootPath 必须来自上一节反射枚举的结果,手工拼接很容易因卷名不对而写到内置存储;targetFileName 要注意目标设备上不能重名,连续插入同一张卡时会出现重复文件。
缓冲区选了 8KB,这是兼顾大数据块连续读写和小文件响应速度的折中值。U 盘的小文件随机写本身就慢,缓冲区太小会频繁触发底层 flush,太大则占用内存。如果你要拷贝的是单个几百 MB 的大文件,把 buffer 提到 64KB 性能更稳。
3.3 权限与挂载状态处理
权限这里要分两层讲。Android 8 及以下,U 盘读写只需要在 Manifest 里声明 WRITE_EXTERNAL_STORAGE;Android 9 还支持在 Activity 里动态请求。Android 10 以后分区存储上线,直接读写 /storage/XXXX-XXXX 会被系统拦截,常见做法是声明 MANAGE_EXTERNAL_STORAGE 并引导用户到设置页面授权。
public boolean ensureUsbPermission(Activity activity) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { if (!Environment.isExternalStorageManager()) { Intent intent = new Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION); intent.setData(Uri.parse("package:" + activity.getPackageName())); activity.startActivityForResult(intent, REQUEST_MANAGE_STORAGE); return false; } } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { if (activity.checkSelfPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) { activity.requestPermissions(new String[]{ Manifest.permission.WRITE_EXTERNAL_STORAGE}, REQUEST_WRITE_STORAGE); return false; } } return true; }这段代码在 Android 11 及以上走“所有文件访问”授权,在 Android 6~10 走普通运行时权限。“挂载状态”不是权限能覆盖的,U 盘可能在读了一半时被拔走,所以 UsbDriveListener 里要监听 ACTION_MEDIA_REMOVED 这类广播,收到广播后立刻中断文件流操作,否则底层会抛 IOException,向上冒泡成用户看不懂的红色错误。
另外一个细节:如果设备系统版本较老,U 盘可能以 FAT32 挂载,单文件不能超过 4GB。碰到这种边界情况,写入会返回 “No space left on device”,但实际是文件系统限制。处理方式是在写入前用 UsbVolume 拿到 fsUuid,再通过反射读取挂载点对应的文件系统类型,如果是 vfat 且目标文件超过 4GB,提前给出提示。
4. 运行与接入:从零把 U 盘读写集成进项目
4.1 构建配置与依赖说明
工程用 Gradle 构建,没有引入第三方库,依赖体系非常薄。如果你从零新建项目,build.gradle 里最低要保证以下配置。
android { compileSdk 34 defaultConfig { minSdk 23 targetSdk 34 } } dependencies { implementation 'androidx.appcompat:appcompat:1.6.1' }minSdk 放到 23,是为了能用动态权限,且老设备的 U 盘挂载路径模式在 Android 6 以后才相对统一。targetSdk 34 意味着要适配分区存储,也就是必须处理 MANAGE_EXTERNAL_STORAGE 的授权流程,这正好是 PermissionHelper 覆盖的。不建议把 targetSdk 压到 29 以下来规避权限问题,上架后会被平台侧要求升级。
4.2 AndroidManifest 配置要点
权限和组件注册集中在 Manifest 里,下面是必须保留的最小集合。
<uses-feature android:name="android.hardware.usb.host" android:required="true" /> <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" /> <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="28" /> <uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" tools:ignore="ScopedStorage" /> <application> <activity android:name=".ui.MainActivity" android:exported="true"> <intent-filter> <action android:name="android.intent.action.MAIN" /> <category android:name="android.intent.category.LAUNCHER" /> </intent-filter> </activity> </application>uses-feature 声明 usb.host 是为了让应用在无 OTG 能力的设备上不显示在应用市场。WRITE_EXTERNAL_STORAGE 加了 maxSdkVersion 限制,因为 Android 10 之后它不再生效,反而会引发隐私合规告警。MANAGE_EXTERNAL_STORAGE 是 Android 11+ 读取完整路径的通行证,少了它,反射拿到的路径只能看列表,不能写文件。
4.3 真机验证流程与常见结果
装到真机上验证时,建议按下面这个顺序走:
- 准备两张 U 盘,一张 FAT32,一张 exFAT,分别插入设备。
- 首次启动,系统会弹“允许访问所有文件”的授权页,点了允许后回到 App。
- 点击“刷新 U 盘”,观察 Logcat 里 UsbDriveManager 打印的卷路径。
- 在设备自带文件管理器里对比同一张 U 盘的真实路径,确认反射拿到的路径一致。
- 执行一次文件复制,检查目标文件在 U 盘里的大小与哈希值。
实际跑的时候,最常遇到的情况是 Logcat 里看不到任何卷输出。这通常不是反射代码的问题,而是 U 盘没有以卷的形式挂载——检查一下系统是否弹出了存储设备通知,或者换个 U 盘类型。exFAT 在自带文件管理器里能读,但反射拿到的卷是 mounted 状态,这个也属于正常现象,因为 vold 已挂载,只是读写速度受制于 U 盘主控。
5. 避坑记录:反射路径、热插拔与厂商差异
5.1 反射方法报 NoSuchMethodException
现象:在部分国产设备上调用 getVolumeList 直接抛异常,U 盘路径永远拿不到。原因:厂商在定制 ROM 时改了 StorageManager 内部方法签名,或者把方法挪到了别的类。解决:将反射调用的返回类型从 StorageVolume[] 降级为 Object[],并用 try-catch 包住每个字段读取;再加一段基于 /mnt/media_rw 与 /storage 目录扫描的兜底逻辑,反射失败时直接从文件系统层枚举目录。
5.2 权限授权后仍写不进文件
现象:MANAGE_EXTERNAL_STORAGE 已允许,但写入 FileOutputStream 时报 EACCES。原因:部分 ROM 的“所有文件访问”开关不会即时生效,设置页返回后权限状态尚未同步。解决:每次回到 onResume 时重新调用 ensureUsbPermission(),同时延迟 300ms 再执行第一次写入;如果仍然失败,提示用户重启 App,让系统重新加载存储权限白名单。
5.3 插入多张 U 盘时只能显示一张
现象:通过 OTG 扩展坞同时插两张 U 盘,反射枚举只返回一个 removable 卷。原因:getVolumeList 在部分设备上只返回第一个挂载卷,或者第二个卷仍处于 pending 状态。解决:注册 StorageManager.StorageEventListener 监听卷状态变化,在收到 VOLUME_STATE_CHANGED 时重新调用 refreshUsbVolumes(),并把重复卷按路径去重。
5.4 热插拔导致文件写入中断、U 盘文件损坏
现象:大文件复制到一半,用户拔掉 U 盘,再次插上后文件大小为 0 或无法打开。原因:写缓存没有 flush,断电式拔出导致 FAT 表项损坏。解决:写文件最后强制调用 out.flush() 和 out.getFD().sync();同时监听 ACTION_MEDIA_EJECT 广播,在拔出前 200ms 内让 FileOperator 丢弃正在进行的写任务。
6. 二次开发:把反射路径改造成一个可用的文件管理器
反射拿到路径只是第一步,要让这套代码真正可用,下一步通常把它扩展成文件管理器。改造时别急着做多 TAB 的界面,先把三个能力补上:目录递归遍历、复制进度回调、文件类型筛选。
递归遍历改起来不大,在 FileOperator 里增加一个 walk() 方法,返回 List ,FileNode 包含文件路径、大小、mimeType 和最后修改时间。UI 层用 RecyclerView 展示,mimeType 决定条目右上角图标。复制进度回调需要把 3.2 的同步方法拆成子线程版本,用 Handler 每拷贝 1MB 发送一次进度消息。这里我建议把缓冲区提到 64KB,因为文件管理器里经常整目录复制,小缓冲区会让进度回调过于频繁。
U 盘路径在二次开发里还有个经典应用:把 U 盘当作设备间的离线同步介质。路径枚举成功后,用 FileObserver 监控 U 盘根目录,一旦有新文件写入,立即触发 App 内置的上传任务。FileObserver 的监控路径必须是反射拿到的真实路径,而不是通过 openFileDescriptor 拿到的 Uri 转路径——后者在分区存储下转换是坏路径。
验证这套改造,不需要特殊设备,一台支持 OTG 的手机、一张 U 盘就够。每次都把所有文件访问权限删掉重授一遍,逼着自己走完整套路径枚举→授权→读写→热插拔的状态流转,而不是图省事只测一次成功路径。从那以后我每改一次 UsbDriveManager,都强制跑一遍“插卡→授权→拷贝→拔出→重插”的循环,很多隐藏的路径失效问题就是这么暴露出来的。希望帮到你。
本文还有配套的精品资源,点击获取