Android分区存储下外置存储设备(U盘/SD卡)的发现、监听与安全访问实战
1. 项目缘起:一个被忽视的“基础”需求
在Android应用开发中,处理外置存储设备(比如U盘、SD卡、移动硬盘)的读取和监听,听起来像是一个基础功能,但实际做起来,你会发现官方文档语焉不详,社区资料零散,很多开发者要么用一些“野路子”凑合,要么干脆放弃这个需求。我最近在做一个车载多媒体项目,核心功能之一就是自动识别并播放用户插入的U盘里的音乐和视频。这个需求把我逼到了墙角,不得不把Android存储系统的这块“硬骨头”啃下来。
为什么说它“硬”?因为从Android 4.4(KitKat)引入存储访问框架(SAF)开始,到Android 10(Q)强制推行分区存储(Scoped Storage),再到Android 11(R)对文件访问权限的进一步收紧,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盘、移动硬盘,通过OTG(On-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 10(API 29)及以上版本,这是目前的主流和强制要求。
3.1 Android 10+ 的强制分区存储(Scoped Storage)
分区存储的核心思想是:应用默认只能访问自身的私有目录和通过特定API(如MediaStore)授予访问权限的公共媒体文件。对于外置存储设备上的文件,也不例外。
这意味着:
- 不能再使用
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />来直接获取访问所有共享文件的权限。在Android 10+上,这个权限的作用域被大大缩小了。 - 不能再通过
File类直接遍历/storage或/mnt目录来发现设备。 - 正确的姿势是使用系统提供的
StorageManager和MediaStoreAPI。
3.2 必需的权限声明
在AndroidManifest.xml中,你需要声明以下权限:
<!-- 用于请求访问所有文件的管理权限(谨慎使用) --> <!-- 在Android 11(R, API 30)及以上,需要此权限才能使用MANAGE_EXTERNAL_STORAGE --> <uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" tools:ignore="ScopedStorage" /> <!-- 在Android 10(Q, API 29)上,可能还需要声明旧权限,但运行时请求无效 --> <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="28" />重要警告: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 9(Pie, API 28)开始,推荐使用StorageManager.getStorageVolumes()来获取列表。
fun getRemovableStorageVolumes(context: Context): List<StorageVolume> { val storageManager = context.getSystemService(Context.STORAGE_SERVICE) as StorageManager val storageVolumes = storageManager.storageVolumes return storageVolumes.filter { volume -> // 判断是否为可移动设备 volume.isRemovable // 注意:在有些系统上,即使SD卡被移除,这个列表里可能还会存在一个“未挂载”的卷对象 // 更严谨的做法是结合卷的状态(如是否已挂载)一起判断,但API没有直接提供。 // 通常通过后续的文件访问测试来判断是否可用。 } }踩坑点1:isRemovable的可靠性在某些定制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.0(Nougat, 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 10+,dataUri可能为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) }步骤二:使用StorageEventListener(Android 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(this@MainActivity)} 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 = emptySet<String>() // 存储上次已知卷的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 11(R)的进一步限制
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/NTFS:Android原生支持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的各种车机系统上运行稳定。
