Android分区存储下外置存储设备(U盘/SD卡)的发现、监听与安全访问实战
1. 项目缘起一个被忽视的“基础”需求在Android应用开发中处理外置存储设备比如U盘、SD卡、移动硬盘的读取和监听听起来像是一个基础功能但实际做起来你会发现官方文档语焉不详社区资料零散很多开发者要么用一些“野路子”凑合要么干脆放弃这个需求。我最近在做一个车载多媒体项目核心功能之一就是自动识别并播放用户插入的U盘里的音乐和视频。这个需求把我逼到了墙角不得不把Android存储系统的这块“硬骨头”啃下来。为什么说它“硬”因为从Android 4.4KitKat引入存储访问框架SAF开始到Android 10Q强制推行分区存储Scoped Storage再到Android 11R对文件访问权限的进一步收紧Google一直在试图收紧应用对共享存储空间的随意访问。外置存储设备作为典型的“外部共享存储”其访问方式也在这股浪潮中几经变迁。很多老方法比如直接通过/storage路径遍历在新系统上要么完全失效要么行为诡异。如果你还在用Environment.getExternalStorageDirectory()来指代“外部存储”那在Android 10及以上的设备上这很可能指向的是应用自身的私有目录而非真正的SD卡或U盘。所以这个标题背后其实是一系列问题的集合在分区存储的新世界里应用如何合法、安全地发现和读取用户主动连接的外置存储设备又如何能像系统自带的“文件”应用一样实时感知到设备的插拔事件这不仅仅是调用几个API那么简单它涉及到存储卷Storage Volume的管理、媒体库MediaStore的查询、内容提供者ContentProvider的交互以及广播Broadcast机制的正确使用。接下来我将结合实战把这套机制掰开揉碎了讲清楚。2. 核心概念厘清什么是“外置存储设备”在深入代码之前我们必须统一语言。在Android的语境下“外置存储设备”是一个容易混淆的概念它至少包含两层含义而我们的目标通常是第二层。2.1 广义的“外部存储” vs 狭义的“可移动存储”广义外部存储External Storage这是一个历史遗留的、容易误导人的术语。在早期Android中它指的是设备内部的一块从系统存储中划分出来、用于存放用户媒体文件照片、音乐等的存储区域。这块区域在物理上位于设备内部但对用户和应用而言是“外部”的、可共享的。Environment.getExternalStorageDirectory()返回的就是这个路径如/storage/emulated/0。在分区存储下应用对此区域的直接文件路径访问受到严格限制。狭义的可移动存储Removable Storage这才是我们标题里所指的“外置存储设备”即用户物理上可以插拔的存储介质。主要包括SD卡MicroSD Card通过卡槽插入。USB存储设备USB Mass Storage如U盘、移动硬盘通过OTGOn-The-Go数据线连接。我们的核心目标就是发现、枚举并访问这些物理上可插拔的存储卷。2.2 存储卷StorageVolume与挂载点Android系统将每一个可用的存储区域抽象为一个StorageVolume对象。每个StorageVolume包含以下关键信息描述用户可见的名称如“SD卡”、“USB驱动器”。UUID卷的唯一标识符。路径该卷在文件系统中的挂载点Mount Point。重要提示在Android 10及以上应用通常无法直接通过这个路径字符串进行文件操作java.io.FileAPI必须通过MediaStore或Storage Access Framework来访问。是否可移除Removable判断是否为SD卡或USB设备。是否为主存储Primary判断是否为设备内置的主共享存储。理解这些概念是后续所有操作的基础。我们的任务就是获取到所有isRemovable() true的StorageVolume并监听它们的状态变化。3. 方案选型与权限配置为新存储模型做好准备在动手写代码前必须根据你的目标API级别targetSdkVersion确定技术方案并配置正确的权限。这里假设你的应用需要适配Android 10API 29及以上版本这是目前的主流和强制要求。3.1 Android 10 的强制分区存储Scoped Storage分区存储的核心思想是应用默认只能访问自身的私有目录和通过特定API如MediaStore授予访问权限的公共媒体文件。对于外置存储设备上的文件也不例外。这意味着不能再使用uses-permission android:nameandroid.permission.READ_EXTERNAL_STORAGE /来直接获取访问所有共享文件的权限。在Android 10上这个权限的作用域被大大缩小了。不能再通过File类直接遍历/storage或/mnt目录来发现设备。正确的姿势是使用系统提供的StorageManager和MediaStoreAPI。3.2 必需的权限声明在AndroidManifest.xml中你需要声明以下权限!-- 用于请求访问所有文件的管理权限谨慎使用 -- !-- 在Android 11R API 30及以上需要此权限才能使用MANAGE_EXTERNAL_STORAGE -- uses-permission android:nameandroid.permission.MANAGE_EXTERNAL_STORAGE tools:ignoreScopedStorage / !-- 在Android 10Q API 29上可能还需要声明旧权限但运行时请求无效 -- uses-permission android:nameandroid.permission.READ_EXTERNAL_STORAGE android:maxSdkVersion28 /重要警告MANAGE_EXTERNAL_STORAGE权限授予应用访问设备上所有文件的能力包括其他应用的数据。Google Play对使用此权限的应用审核非常严格你必须提供充分的理由如文件管理器、备份还原、杀毒软件等否则很可能被拒。对于只是读取U盘媒体文件的多媒体应用应尽量避免申请此权限而是优先使用MediaStoreAPI。3.3 替代方案使用MediaStore和SAF对于大多数“读取外置存储设备媒体文件”的需求更推荐的方式是不申请MANAGE_EXTERNAL_STORAGE权限。通过StorageManager获取可移动存储卷的信息。针对每个存储卷使用MediaStoreAPI来查询其上的图片、视频、音频文件。MediaStore会自动索引这些文件应用通过内容解析器ContentResolver查询无需文件路径权限。如果用户需要访问非媒体文件如PDF、文档则通过Storage Access Framework(SAF) 启动一个系统文件选择器让用户主动授权选择文件或目录。这套组合拳是符合Android最新存储规范的最佳实践。下文将主要围绕此方案展开。4. 实战枚举已挂载的外置存储设备我们首先解决“读取”的第一步发现当前有哪些外置存储设备已经连接并挂载好了。4.1 获取StorageManager实例StorageManager是系统服务负责管理所有存储卷。// 在Activity或Fragment中 val storageManager getSystemService(Context.STORAGE_SERVICE) as StorageManager4.2 获取存储卷列表并过滤从Android 9Pie API 28开始推荐使用StorageManager.getStorageVolumes()来获取列表。fun getRemovableStorageVolumes(context: Context): ListStorageVolume { val storageManager context.getSystemService(Context.STORAGE_SERVICE) as StorageManager val storageVolumes storageManager.storageVolumes return storageVolumes.filter { volume - // 判断是否为可移动设备 volume.isRemovable // 注意在有些系统上即使SD卡被移除这个列表里可能还会存在一个“未挂载”的卷对象 // 更严谨的做法是结合卷的状态如是否已挂载一起判断但API没有直接提供。 // 通常通过后续的文件访问测试来判断是否可用。 } }踩坑点1isRemovable的可靠性在某些定制ROM或特定Android版本上isRemovable的返回值可能不准确。例如有些设备将内置存储的一部分虚拟成“SD卡”。更健壮的做法是结合卷的描述volume.getDescription(context)来判断如果描述中包含“SD”、“USB”、“可移动”等关键字则认为是目标设备。但这需要处理多语言问题。踩坑点2存储卷的“活动”状态getStorageVolumes()返回的是系统已知的卷但不一定都是当前已挂载、可访问的。比如用户移除了SD卡但未重新启动这个卷可能还在列表中但状态是未挂载。目前没有直接的API查询挂载状态。一个实用的方法是尝试通过该卷的MediaStore内容URI进行一个简单的查询如MediaStore.Images.Media.EXTERNAL_CONTENT_URI如果查询成功或能获取到MediaStore的数据库路径则认为该卷是活动的。这涉及到下一个步骤。4.3 获取存储卷的唯一标识与MediaStore数据库每个StorageVolume都有一个唯一的uuid字符串。对于可移动设备这个uuid是动态生成的通常基于设备序列号或文件系统UUID。这个uuid是构建特定于该卷的MediaStore内容URI的关键。// 获取卷的UUID val volumeUuid storageVolume.uuid // 如果uuid为null通常表示这是主共享存储primary external storage // 对于可移动设备uuid通常不为null // 构建该卷特定的MediaStore查询URI // 以查询图片为例 val imagesCollection: Uri if (volumeUuid null) { // 主存储 MediaStore.Images.Media.EXTERNAL_CONTENT_URI } else { // 外置存储卷 MediaStore.Files.getContentUri(volumeUuid) } // 注意MediaStore.Files.getContentUri(volumeUuid) 需要API 29 // 对于更早的API需要自己拼接URI格式类似content://media/external_primary/images/media通过向这个特定的Uri发起查询你就可以只检索该外置存储设备上的媒体文件而不会混入设备内部存储的文件。5. 核心难点突破监听外置存储设备的插拔事件设备枚举是静态的动态监听插拔才是真正的挑战。Android系统在存储卷状态变化时会发送广播Broadcast但广播的Action和携带的数据随着Android版本升级发生了很大变化。5.1 传统广播的局限性在Android 7.0Nougat API 24之前常用的广播Action是ACTION_MEDIA_MOUNTEDACTION_MEDIA_UNMOUNTEDACTION_MEDIA_EJECTACTION_MEDIA_REMOVED这些广播是sticky广播并且携带一个Data字段即存储设备的挂载路径如file:///storage/XXXX-XXXX。然而从Android 7.0开始对静态注册在AndroidManifest.xml中声明的广播接收器限制增多许多系统广播不再发送给静态接收器。而动态注册在代码中registerReceiver又要求应用必须在前台运行。更重要的是在Android 10及以上这些基于路径的广播可能不再可靠因为应用可能根本没有权限感知到那些具体的文件系统路径。5.2 Android 9 的新广播机制从Android 9开始Google引入了新的、更清晰的广播Action专门用于存储卷事件ACTION_MEDIA_VOLUME_STATE_CHANGED这是一个protected广播普通应用无法接收。StorageManager.ACTION_DISK_SCANNED(API 28)StorageManager.ACTION_VOLUME_STATE_CHANGED(API 31)最实用的是StorageManager.ACTION_VOLUME_STATE_CHANGED但它需要API 31。对于更广泛的兼容我们需要一个混合策略。5.3 实战中的混合监听方案经过大量测试一个相对稳健的方案是结合使用新旧广播并辅以StorageManager的回调StorageEventListener API 26。步骤一动态注册广播接收器兼容旧版本private val storageReceiver object : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { when (intent.action) { Intent.ACTION_MEDIA_MOUNTED - { // 媒体已挂载 val dataUri intent.data // 例如file:///storage/XXXX-XXXX Log.d(TAG, Media mounted: $dataUri) // 注意在Android 10dataUri可能为null或应用无权限访问该路径 // 因此这里更多是作为一个触发信号提示我们去重新枚举StorageVolume scanForNewVolumes() } Intent.ACTION_MEDIA_UNMOUNTED, Intent.ACTION_MEDIA_EJECT, Intent.ACTION_MEDIA_REMOVED - { // 媒体已卸载或移除 Log.d(TAG, Media removed/unmounted) scanForNewVolumes() // 重新枚举看哪个卷不见了 } // 可以添加更多Action如ACTION_MEDIA_SCANNER_FINISHED等 } } } // 在Activity的onCreate或Service的onStartCommand中注册 override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) val filter IntentFilter().apply { addAction(Intent.ACTION_MEDIA_MOUNTED) addAction(Intent.ACTION_MEDIA_UNMOUNTED) addAction(Intent.ACTION_MEDIA_EJECT) addAction(Intent.ACTION_MEDIA_REMOVED) // 必须添加这个否则接收不到基于file://的广播 addDataScheme(file) } registerReceiver(storageReceiver, filter) }步骤二使用StorageEventListenerAndroid 8.0 更现代的方式StorageManager.registerListener()可以让你监听存储卷的更精细的状态变化。private val storageEventListener object : StorageManager.StorageEventListener() { override fun onVolumeStateChanged(volume: StorageVolume, state: Int) { // state: StorageVolume.STATE_* 常量如 STATE_MOUNTED, STATE_EJECTING, STATE_UNMOUNTED Log.d(TAG, Volume ${volume.getDescription(thisMainActivity)} state changed to: $state) if (state StorageVolume.STATE_MOUNTED || state StorageVolume.STATE_UNMOUNTED) { // 重新扫描卷列表 scanForNewVolumes() } } // 还有其他回调如onDiskScanned, onVolumeRecordChanged等根据需要重写 } // 注册监听器 storageManager.registerListener(storageEventListener) // 不要忘记在合适的时机如onDestroy取消注册 override fun onDestroy() { super.onDestroy() storageManager.unregisterListener(storageEventListener) unregisterReceiver(storageReceiver) // 注销广播接收器 }步骤三实现scanForNewVolumes函数这个函数负责对比前后两次枚举的存储卷列表找出新增或移除的设备。private var lastKnownVolumes emptySetString() // 存储上次已知卷的UUID集合 private fun scanForNewVolumes() { val currentVolumes getRemovableStorageVolumes(this) val currentUuids currentVolumes.mapNotNull { it.uuid }.toSet() val added currentUuids - lastKnownVolumes val removed lastKnownVolumes - currentUuids if (added.isNotEmpty()) { Log.i(TAG, New volume(s) added: $added) // 处理新增设备例如自动扫描该设备上的媒体文件更新UI列表 added.forEach { uuid - val volume currentVolumes.find { it.uuid uuid } volume?.let { startMediaScanForVolume(it) } } } if (removed.isNotEmpty()) { Log.i(TAG, Volume(s) removed: $removed) // 处理移除设备清理相关缓存更新UI } lastKnownVolumes currentUuids }踩坑点3广播的延迟与漏报实测中发现ACTION_MEDIA_MOUNTED等广播的触发时机有延迟特别是在设备刚插入、系统正在进行文件系统检查和媒体扫描时。有时甚至可能漏报。因此不能完全依赖广播作为唯一的事件源。一个常见的补偿策略是在应用的关键生命周期点如从后台回到前台onResume主动调用一次scanForNewVolumes()以确保UI状态与实际情况同步。6. 读取设备内容通过MediaStore安全访问文件监听到设备挂载后下一步就是读取其中的内容。如前所述直接文件IO是行不通的我们必须通过MediaStore。6.1 构建特定卷的查询假设我们已经获取到了一个StorageVolume对象targetVolume。fun queryMediaFilesOnVolume(context: Context, volume: StorageVolume) { val volumeUuid volume.uuid ?: return // 如果uuid为空通常不是可移动设备 // 确定要查询的媒体类型。这里以音频为例。 val collection: Uri MediaStore.Audio.Media.getContentUri(volumeUuid) val projection arrayOf( MediaStore.Audio.Media._ID, MediaStore.Audio.Media.DISPLAY_NAME, MediaStore.Audio.Media.ARTIST, MediaStore.Audio.Media.DURATION, MediaStore.Audio.Media.SIZE, // 特别注意COLUMN_RELATIVE_PATH 和 DATA 在Android 10上行为有变 MediaStore.Audio.Media.RELATIVE_PATH, ) val selection ${MediaStore.Audio.Media.IS_MUSIC} ! 0 // 只查询音乐文件 val sortOrder ${MediaStore.Audio.Media.DATE_ADDED} DESC context.contentResolver.query( collection, projection, selection, null, sortOrder )?.use { cursor - while (cursor.moveToNext()) { val id cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Audio.Media._ID)) val name cursor.getString(cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.DISPLAY_NAME)) val artist cursor.getString(cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.ARTIST)) // 构建该媒体项的内容Uri用于后续播放或获取InputStream val contentUri ContentUris.withAppendedId(collection, id) Log.d(TAG, Found music: $name by $artist, Uri: $contentUri) // 使用contentUri来播放音乐 // mediaPlayer.setDataSource(context, contentUri) } } }6.2 处理文件路径的变迁RELATIVE_PATH vs DATA这是一个巨大的坑点。在MediaStore的列中有两个与路径相关的列MediaStore.MediaColumns.DATA 文件的绝对路径如/storage/XXXX-XXXX/Music/song.mp3。在Android 10及以上对于应用未通过SAF明确授权访问的文件此列可能返回空值、假路径或直接抛出安全异常。绝对不要依赖此列MediaStore.MediaColumns.RELATIVE_PATH 文件相对于标准公共目录如Music/,Pictures/,DCIM/的相对路径如Music/MyArtist/。这是Android 10推荐使用的列。如果你需要让其他组件如原生媒体播放器、视频解码库访问文件最佳实践是使用ContentResolver.openFileDescriptor(contentUri, r)获取一个ParcelFileDescriptor或者直接使用contentUri。大多数现代媒体框架都支持通过ContentResolver和Uri来读取数据。6.3 触发媒体库扫描当用户向U盘拷贝了新文件或者你的应用在外置存储上创建了文件系统媒体扫描器MediaScanner可能不会立即索引它们。你可以手动触发扫描fun scanVolumeForNewMedia(context: Context, volume: StorageVolume) { val intent Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE) // 注意这里需要传递一个文件的Uri。对于整个卷可以发送卷的根目录Uri。 // 但更通用的做法是使用MediaScannerConnection扫描特定文件或目录。 // 对于整个卷一个可行的但非官方方法是发送一个指向卷根目录的file:// Uri // 但这在Android 10上可能因权限问题失败。 // 更可靠的方法是依赖系统的自动扫描或使用MediaScannerConnection.scanFile扫描你已知的新文件。 // 替代方案使用MediaScannerConnection扫描单个文件如果你知道文件路径 // val filePath ... // 注意在Android 10你很可能没有这个路径的权限 // MediaScannerConnection.scanFile(context, arrayOf(filePath), null, null) // 对于外置存储设备最稳妥的方式是等待系统自动完成扫描。 // 你可以监听 ACTION_MEDIA_SCANNER_FINISHED 广播但此广播在较新版本可能不可靠。 Log.w(TAG, Manually triggering full volume scan is tricky on Android 10. Relying on auto-scan.) }在实际项目中我通常的做法是检测到新卷挂载后启动一个后台服务定期比如每隔5秒查询一次MediaStore直到在指定的卷uuid下能查询到预期的文件为止并设置一个超时时间。这是一种“轮询补偿”策略虽然不优雅但很有效。7. 进阶话题与疑难杂症排查即使按照上述步骤操作在实际设备尤其是不同厂商的定制系统上你仍会遇到各种奇怪的问题。这里分享几个常见的“坑”和排查思路。7.1 厂商定制系统的兼容性问题某些国内手机厂商如小米、华为、OPPO、vivo的早期系统可能会修改存储相关的广播和行为。现象收不到ACTION_MEDIA_MOUNTED广播但设备管理器里能看到U盘。排查检查应用是否被厂商的“省电策略”或“自启动管理”限制了后台活动。去系统设置里给应用打开“自启动”和“关联启动”权限。尝试监听更通用的系统存储状态变化广播如Intent.ACTION_MEDIA_BAD_REMOVAL虽然不对或者监听系统StorageManager的日志logcat中过滤StorageManagerService。终极方案在应用设置中增加一个“手动刷新”按钮让用户主动触发设备扫描。同时在onResume中强制扫描一次。现象通过MediaStore查询外置存储设备返回空列表但系统文件管理器里明明有文件。排查确认你使用的volumeUuid是否正确。打印所有StorageVolume的信息对比uuid和description。确认系统媒体扫描器是否已完成对该设备的索引。插入U盘后等待几分钟再查询。可以监听ACTION_MEDIA_SCANNER_FINISHED广播尽管不保证或者在logcat中查看MediaScanner相关的日志。尝试使用MediaStore.Files表进行更广泛的查询MediaStore.Files.getContentUri(volumeUuid)并设置合适的MIME_TYPE筛选。7.2 Android 11R的进一步限制Android 11引入了“所有文件访问权限”的对话框即使你声明了MANAGE_EXTERNAL_STORAGE权限也需要用户手动在系统设置中为应用开启此权限。你的应用需要引导用户去设置。fun checkAndRequestManageStoragePermission(activity: Activity) { if (Build.VERSION.SDK_INT Build.VERSION_CODES.R) { if (!Environment.isExternalStorageManager()) { // 引导用户去设置页面开启权限 val intent Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION) intent.data Uri.parse(package:${activity.packageName}) activity.startActivity(intent) } } }注意再次强调除非你的应用是文件管理器、备份工具等核心文件管理应用否则应尽量避免使用此权限。Google Play的审核政策非常严格。7.3 处理多分区U盘或exFAT/NTFS文件系统有些大容量U盘或移动硬盘可能被格式化为多个分区或者使用exFAT、NTFS等Windows常用文件系统。多分区Android通常会将每个分区识别为一个独立的StorageVolume。你的应用需要能处理这种情况可能需要在UI上将它们合并显示为一个逻辑设备。exFAT/NTFSAndroid原生支持exFAT但对NTFS的支持因设备而异。如果设备内核不支持该文件系统U盘可能根本无法挂载或者挂载为只读。你的应用需要处理查询失败或只能读取不能写入的情况。可以通过尝试在卷的根目录通过MediaStore创建一个小文件来测试写权限记得最后删除。7.4 性能优化大量文件的查询与监听如果一个U盘里有数万个媒体文件一次性查询所有数据可能会阻塞UI线程或导致内存占用过高。使用分页查询ContentResolver.query()支持LIMIT和OFFSET可以通过sortOrder和selection配合实现分页。但更推荐使用CursorLoader已废弃或其替代品androidx.paging库或者直接使用Room等数据库库来管理本地缓存。后台服务与通知监听存储插拔和扫描媒体文件应该是后台任务。考虑使用WorkManager来调度这些任务并在扫描大量文件时通过前台服务显示进度通知避免被系统杀死。缓存策略将扫描到的媒体文件信息Uri,id,name等缓存到本地数据库如Room。下次应用启动或设备重新插入时可以先显示缓存数据同时在后台进行增量扫描比对MediaStore中的DATE_MODIFIED字段来更新缓存。我在车载项目中的最终方案是启动一个ForegroundService专门负责监听USB主机Host模式下的设备连接通过UsbManager和UsbDevice一旦检测到U盘插入就启动一个Worker来扫描MediaStore将结果存入Room数据库并通过LiveData通知UI更新。对于拔除事件则清理该卷对应的缓存数据。这套方案在Android 10和11的各种车机系统上运行稳定。