1. 项目概述为什么Android外置存储读写是个“技术活”刚接触Android开发那会儿总觉得文件读写不就是FileInputStream和FileOutputStream那点事儿吗直到第一次把App装到带TF卡的设备上用户反馈“保存的图片不见了”、“无法导入U盘里的文档”我才意识到Android对外部存储External Storage的管理尤其是可移动介质Removable Media如TF卡和U盘完全是一个独立的、充满“坑”的领域。这不仅仅是调用几个API那么简单它涉及到Android系统为了安全、多用户和存储抽象化而设计的一整套复杂权限模型和存储访问框架SAF。简单来说Android设备上的存储空间被划分为“内部存储”和“外部存储”。内部存储是设备内置的、与系统紧密绑定的空间App私有数据就存在这里。而我们常说的“外部存储”早期主要指设备内置的共享存储空间如/storage/emulated/0所有App在获得权限后都能读写。但随着Android版本的演进特别是从Android 4.4KitKat引入“存储访问框架”Storage Access Framework, SAF到Android 6.0Marshmallow的动态权限再到Android 10Q引入的“分区存储”Scoped Storage对于真正的、物理上可插拔的外部存储设备——TF卡MicroSD和U盘——的访问方式发生了翻天覆地的变化。这个项目要解决的就是如何在现代Android应用特别是面向Android 10及以上版本中安全、合规且用户友好地实现对TF卡和U盘的读写操作。这不仅仅是写几行代码更是理解Android存储沙箱、权限边界和用户意图的过程。无论你是开发文件管理器、音乐播放器、备份工具还是任何需要与用户外部存储设备交互的应用掌握这套“组合拳”都至关重要。2. 核心概念与权限演变从“野蛮生长”到“精耕细作”在动手写代码之前我们必须先理清几个关键概念和Android在这方面的政策变迁。这能帮你理解为什么有些“老方法”现在行不通了以及新方法的设计逻辑。2.1 内部存储、外部存储与可移动存储很多开发者容易混淆这几个术语内部存储 (Internal Storage)设备内置的、不可移除的存储芯片。每个App在此拥有一个私有的目录/data/data/package_name其他App无权限访问除非设备已Root。这里存放App的代码、私有数据库和配置文件。外部存储 (External Storage)这是一个更宽泛的概念。在早期Android中它通常指设备内置的、用于共享媒体文件图片、音乐、视频的存储分区路径如/storage/emulated/0。注意现在很多手机所谓的“外部存储”其实是内置存储的一部分并非物理可移动。可移动存储 (Removable Storage)这才是我们项目的主角——物理上可以插拔的存储设备包括MicroSD卡TF卡和USB OTG连接的U盘、移动硬盘等。它们的挂载路径不固定通常是/storage/XXXX-XXXX或/mnt/media_rw/XXXX-XXXX这样的格式。我们的目标就是安全地访问“可移动存储”。2.2 权限模型的演进与分区存储Scoped StorageAndroid的存储访问权限管理经历了几个关键阶段远古时期 (Android 4.3及以前)在AndroidManifest.xml中声明WRITE_EXTERNAL_STORAGE权限即可获得对整个共享存储包括可移动存储的读写权。简单粗暴但安全隐患巨大。动态权限时代 (Android 6.0 - 9)引入了运行时权限。READ_EXTERNAL_STORAGE和WRITE_EXTERNAL_STORAGE权限需要在使用时动态向用户申请。但一旦授权App仍然可以几乎无限制地访问共享存储区。分区存储时代 (Android 10及以上默认启用)这是最大的转折点。Google引入了“分区存储”概念旨在更好地保护用户隐私和应用数据。核心思想每个App都拥有一个自己的“沙箱”在外部存储中对应一个专有的目录通过Context.getExternalFilesDir()等获取。App无需任何权限即可自由读写自己的沙箱。对共享区域的访问受限App不能直接通过文件路径如/storage/emulated/0/DCIM访问其他App创建的文件或用户媒体库。必须通过MediaStore API访问媒体文件或Storage Access Framework (SAF)访问任意文档和目录来访问。对可移动存储的影响在分区存储下App无法直接通过文件路径API访问可移动存储的根目录。传统的new File(“/storage/XXXX-XXXX”)方法会失败。访问必须通过SAF或者申请一个特殊的、谷歌不鼓励的权限MANAGE_EXTERNAL_STORAGE。重要提示MANAGE_EXTERNAL_STORAGE权限授予App访问所有文件包括可移动存储的能力但它的使用受到严格限制。Google Play商店要求使用此权限的应用必须属于特定的豁免类别如文件管理器、备份工具、防病毒软件等并且需要在应用商店中提交声明审核非常严格。对于大多数普通应用不应该使用此权限而应该使用SAF。3. 方案选型如何为你的App选择正确的访问策略面对TF卡和U盘我们主要有三种武器Storage Access Framework (SAF)、MediaStore API以及谨慎使用的直接路径访问。选择哪种取决于你的目标Android版本和具体需求。3.1 方案一Storage Access Framework (SAF) —— 现代应用的推荐方案这是Android 4.4引入的框架在分区存储时代成为访问外部文件尤其是非媒体文件和目录的标准且推荐的方式。工作原理SAF不直接暴露文件系统路径给App。它通过Intent启动一个系统级的文件选择器界面让用户自己选择要授予App访问权限的文件或目录。用户选择后系统会返回一个Uri内容URI给App。App通过ContentResolver并利用这个Uri来读写文件内容。优点用户控制权限由用户通过直观的UI授予符合最小权限原则。路径无关App不关心文件的实际物理路径只操作Uri兼容性好。安全App只能访问用户明确选择的文件/目录。统一接口可以访问设备存储、云盘如果文件选择器支持等多种来源。缺点依赖用户交互每次需要访问新的目录或大量文件时都需要用户再次选择流程可能被打断。无法后台静默访问不适合需要定期自动扫描或备份的场景。Uri权限持久化获取的Uri权限通常只在App存活期间有效重启后可能需要重新申请可以通过takePersistableUriPermission尝试持久化但需要用户在选择时勾选“允许”。适用场景文档编辑器打开/保存用户指定的文件、图片选择器让用户选择一张非媒体库中的图片、需要用户明确指向某个文件夹进行操作的场景。3.2 方案二MediaStore API —— 专为媒体文件设计如果你的App主要处理图片、音频、视频等媒体文件MediaStore是更高效、更语义化的选择。工作原理MediaStore是一个系统数据库索引了设备上所有的媒体文件。App通过ContentResolver查询MediaStore来获取媒体文件的Uri和元数据如日期、大小、经纬度等然后通过Uri进行读写。在可移动存储上的使用从Android 11开始App可以向MediaStore请求将媒体文件写入可移动存储的特定公共目录如Pictures,Movies,DCIM等。你不需要直接处理路径而是通过MediaStore的insert或createWriteRequest等API来操作系统会负责将文件放到正确的位置可能在内部存储也可能在用户选择的SD卡上。优点无需路径权限访问媒体文件不需要READ/WRITE_EXTERNAL_STORAGE权限Android 10。检索高效基于数据库查询比遍历文件系统快。自动扫描通过MediaStore写入的文件会被系统自动扫描并加入图库等应用。缺点仅限于媒体文件无法用于访问.txt,.pdf,.apk等非媒体文件。目录受限只能访问标准的媒体公共目录。适用场景相机应用保存照片到SD卡、音乐播放器读取SD卡中的歌曲、图库应用。3.3 方案三直接文件路径访问传统方式—— 仅限特定条件在满足以下所有条件时你才可能考虑使用传统的java.io.FileAPI进行直接访问targetSdkVersion 28 (Android 9) 且运行在Android 9及以下的设备上。或者你的App成功申请并获得了MANAGE_EXTERNAL_STORAGE权限且通过了Google Play的审核。你确切知道可移动存储的挂载路径需要通过Environment.getExternalStorageDirectory()或监听存储挂载广播来动态获取但方法已废弃或不可靠。实操心得对于新的、面向Android 10的应用强烈建议放弃此方案。依赖MANAGE_EXTERNAL_STORAGE权限会让你的应用上架过程变得复杂且可能被用户视为过度索权。SAF和MediaStore才是未来。4. 核心实现使用SAF读写TF卡/U盘全流程下面我们以最通用、最推荐的SAF方案为例详细拆解如何实现让用户选择TF卡/U盘上的一个目录并对其进行读写操作。4.1 第一步启动文档树选择器我们不请求选择单个文件而是请求选择一个目录文档树以获得对该目录及其子内容的持续访问权。// 在Activity或Fragment中 private fun openDirectoryPicker() { val intent Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).apply { // 可选设置初始URI但大多数情况下让用户自己选 // putExtra(DocumentsContract.EXTRA_INITIAL_URI, initialUri) // 可选提示用户选择特定存储卷Android 11 if (Build.VERSION.SDK_INT Build.VERSION_CODES.R) { putExtra(DocumentsContract.EXTRA_INITIAL_URI, DocumentsContract.buildRootUri( com.android.externalstorage.documents, primary )) } } // 使用startActivityForResult或Activity Results API的registerForActivityResult startActivityForResult(intent, REQUEST_CODE_OPEN_DIRECTORY) }注意EXTRA_INITIAL_URI只是一个建议用户完全可以在选择器中切换到其他位置如SD卡。无法通过代码直接、静默地定位到SD卡根目录这是SAF的设计哲学——用户主导。4.2 第二步处理选择结果并获取持久化权限用户选择目录后我们在onActivityResult中接收返回的Uri。override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { super.onActivityResult(requestCode, resultCode, data) if (requestCode REQUEST_CODE_OPEN_DIRECTORY resultCode Activity.RESULT_OK) { data?.data?.let { treeUri - // 1. 尝试获取持久化权限 val contentResolver applicationContext.contentResolver val takeFlags Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION contentResolver.takePersistableUriPermission(treeUri, takeFlags) // 2. 将treeUri保存到SharedPreferences或数据库中供后续使用 saveTreeUriToPrefs(treeUri.toString()) // 3. 现在可以使用这个treeUri来操作该目录了 listFilesInDirectory(treeUri) } } }关键点解析takePersistableUriPermission尝试将此次授权持久化。前提是用户在系统选择器中勾选了“允许访问此文件夹”旁边的复选框。如果用户没勾选此调用会失败并且该Uri权限仅在当前Activity生命周期内有效。务必保存treeUri的字符串形式。即使App重启只要权限是持久化的你依然可以用这个Uri来访问。4.3 第三步使用DocumentFile进行文件操作拿到目录树的Uri后我们不能直接用File类而要用DocumentFile这个辅助类。它提供了类似File的API但操作对象是SAF返回的Uri。import androidx.documentfile.provider.DocumentFile private fun listFilesInDirectory(treeUri: Uri) { val pickedDir DocumentFile.fromTreeUri(applicationContext, treeUri) pickedDir?.listFiles()?.forEach { documentFile - Log.d(SAF, Name: ${documentFile.name}, Type: ${documentFile.type}, Uri: ${documentFile.uri}) // 判断是文件还是目录 if (documentFile.isDirectory) { // 这是一个子目录 } else { // 这是一个文件 } } } // 创建新文件 private fun createFileInDirectory(treeUri: Uri, fileName: String, mimeType: String): DocumentFile? { val pickedDir DocumentFile.fromTreeUri(applicationContext, treeUri) return pickedDir?.createFile(mimeType, fileName) // 例如 text/plain, my_note.txt } // 创建子目录 private fun createSubDirectory(treeUri: Uri, dirName: String): DocumentFile? { val pickedDir DocumentFile.fromTreeUri(applicationContext, treeUri) return pickedDir?.createDirectory(dirName) } // 读取文件内容 private fun readFileContent(documentFile: DocumentFile): String { return applicationContext.contentResolver.openInputStream(documentFile.uri)?.use { inputStream - inputStream.bufferedReader().readText() } ?: } // 写入文件内容 private fun writeFileContent(documentFile: DocumentFile, content: String) { applicationContext.contentResolver.openOutputStream(documentFile.uri)?.use { outputStream - outputStream.bufferedWriter().use { writer - writer.write(content) } } } // 删除文件或目录 private fun deleteDocument(documentFile: DocumentFile): Boolean { return documentFile.delete() }实操心得DocumentFile的性能listFiles()操作可能比传统的File.listFiles()慢尤其是目录下文件非常多时。因为它涉及与DocumentsProvider的IPC通信。对于大型目录遍历考虑在后台线程进行并可能需添加进度提示。MIME类型很重要创建文件时指定正确的MIME类型如image/jpeg,text/plain有助于系统和其他应用正确识别你的文件。Uri的稳定性通过SAF获取的Uri通常是content://协议的它可能对应物理存储上的一个文件也可能是一个虚拟文件。不要试图去解析它的路径直接用它进行读写操作。4.4 第四步处理MediaStore与SAF的协作有时你可能需要通过SAF让用户选择一个文件夹来存放媒体文件但你又希望这些文件能被系统的媒体扫描器识别并加入图库。这时你可以结合MediaStore。用SAF获取到目标目录的treeUri。使用DocumentFile在该目录下创建文件例如一个.jpg文件。创建成功后你获得了新文件的documentFile.uri。为了通知媒体库你可以使用MediaScannerConnection扫描这个文件private fun scanMediaFile(fileUri: Uri) { val filePath getFilePathFromUri(fileUri) // 注意从SAF的Uri可能无法直接获取真实路径 // 更可靠的方式是使用MediaStore.insert if (Build.VERSION.SDK_INT Build.VERSION_CODES.Q) { val values ContentValues().apply { put(MediaStore.MediaColumns.DISPLAY_NAME, my_image.jpg) put(MediaStore.MediaColumns.MIME_TYPE, image/jpeg) put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_PICTURES /MyApp) } val resolver applicationContext.contentResolver val collection MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY) val mediaUri resolver.insert(collection, values) // 然后通过mediaUri打开OutputStream写入图片数据 } else { // Android Q以下如果获得了真实路径可以使用MediaScannerConnection filePath?.let { path - MediaScannerConnection.scanFile(applicationContext, arrayOf(path), arrayOf(image/jpeg), null) } } }警告从SAF的content://Uri获取真实文件路径_data字段在Android 10及以上是不可行的系统会返回null或抛出异常。永远不要依赖Uri到真实路径的转换。正确的做法是如果要操作媒体文件优先使用MediaStoreAPI操作其他文档则完全使用DocumentFile和ContentResolver。5. 常见问题排查与实战技巧在实际开发中你会遇到各种各样的问题。下面是我踩过坑后总结的一些典型问题和解决方法。5.1 问题一SAF选择器返回的Uri重启App后无法访问现象用户选择了目录当时能用但App进程被杀死或重启后再用保存的Uri去操作提示权限不足。原因没有成功调用takePersistableUriPermission或者用户在选择时没有勾选“允许访问此文件夹”的复选框。解决方案检查授权是否持久化每次App启动后使用contentResolver.persistedUriPermissions检查之前获取的Uri权限是否还在。引导用户在UI上提示用户选择目录时务必勾选“允许访问此文件夹”选项。可以在启动选择器前用对话框说明。优雅降级如果发现权限丢失重新引导用户到选择器流程。可以将之前保存的Uri通过Intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, previousUri)传入方便用户快速找到上次的位置。5.2 问题二通过SAF在SD卡上创建的文件在系统文件管理器中看不到现象使用DocumentFile.createFile()在SD卡目录创建文件成功用DocumentFile.listFiles()也能列出但用手机自带的“文件”App或连接电脑查看SD卡时找不到这个文件。原因这通常是因为文件系统缓存或FUSE用户空间文件系统层的问题。SAF通过DocumentsProvider操作文件有时不会立即触发底层文件系统的元数据更新。解决方案对于媒体文件使用MediaScannerConnection.scanFileAndroid Q以下或通过MediaStore插入Android Q以上来通知系统刷新。对于非媒体文件可以尝试发送一个广播此方法已逐渐废弃但某些旧系统可能有效val intent Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE) // 注意这里需要file:// Uri而从SAF可能无法获得。所以此方法通常不适用。 // 更可靠的方法是提示用户重启设备或等待系统自动刷新可能几分钟到几小时。最实用的建议在App内提供完整的文件浏览和管理功能。向用户解释通过本App创建和管理的文件最可靠的方式是在本App内查看。系统文件管理器的显示可能存在延迟。5.3 问题三如何判断用户选择的是内部存储还是SD卡/U盘需求场景App可能需要根据存储位置的不同采取不同策略如提示“SD卡速度较慢”。解决方法解析SAF返回的Uri。SD卡/U盘的Uri通常具有特定模式。fun isUriOnRemovableStorage(uri: Uri): Boolean { val uriString uri.toString() // 常见的可移动存储Uri模式: content://com.android.externalstorage.documents/tree/XXXX-XXXX:/ // 其中XXXX-XXXX是存储卷的IDprimary通常代表内部共享存储。 return uriString.startsWith(content://com.android.externalstorage.documents/tree/) !uriString.contains(/tree/primary:) }更准确的方法是使用DocumentsContract.getTreeDocumentId(uri)获取文档ID然后进行判断。但请注意不同设备、不同厂商的DocumentsProvider实现可能有差异此逻辑不一定100%准确。5.4 问题四批量操作复制、移动、删除大量文件速度慢原因每个DocumentFile操作都涉及与DocumentsProvider的跨进程通信IPC单线程顺序执行大量操作时延迟会累积。优化方案使用工作线程务必在后台线程如Dispatchers.IO执行批量操作。并发操作对于大量独立文件的读写可以考虑使用线程池进行有限的并发操作但注意不要开太多线程避免造成ANR或系统压力。进度反馈必须向用户显示进度条或当前处理的项目名因为操作可能很耗时。考虑使用SAF的剪切板功能对于移动/复制操作可以研究DocumentsContract中关于createDocument()、moveDocument()等底层API但复杂度较高。5.5 实战技巧保存和恢复目录访问权限的最佳实践持久化存储将用户授权的目录Uri字符串treeUri.toString()保存到SharedPreferences或数据库中。启动时验证在App主Activity或需要访问存储的界面初始化时读取保存的Uri字符串尝试恢复DocumentFile。fun restoreTreeUri(): DocumentFile? { val uriString getSavedUriStringFromPrefs() return if (!uriString.isNullOrEmpty()) { val uri Uri.parse(uriString) // 检查权限是否还在 val hasPermission applicationContext.contentResolver.persistedUriPermissions.any { it.uri uri } if (hasPermission) { DocumentFile.fromTreeUri(applicationContext, uri) } else { // 权限已丢失清除保存的Uri引导用户重新选择 clearSavedUriFromPrefs() null } } else { null } }提供便捷入口在App设置中提供一个“重新选择存储目录”的按钮方便用户在权限丢失或想更换目录时操作。6. 针对不同Android版本的兼容性处理你的App可能需要支持较旧的Android版本如Android 5.0/6.0。这时需要一套兼容方案。6.1 Android 5.0/6.0 到 Android 9 (API 21-28)在这个版本区间如果targetSdkVersion设置为28或以下你仍然可以尝试使用传统的文件路径访问SD卡前提是用户授予了WRITE_EXTERNAL_STORAGE运行时权限。获取SD卡路径方法已废弃且不可靠。常见做法是遍历/storage或/mnt目录通过Environment.isExternalStorageRemovable()和Environment.getExternalStorageState()判断哪个是有效的可移动存储。但不同厂商设备路径差异极大。更推荐的做法即使在这个版本也优先使用SAFACTION_OPEN_DOCUMENT_TREE从API 21开始支持。它提供了更一致的体验并为将来升级targetSdkVersion铺平道路。对于只需要读写自己沙箱和媒体库的应用使用MediaStore和运行时权限。6.2 编写版本兼容的代码使用Build.VERSION.SDK_INT进行条件判断。fun handleFileOperation(uri: Uri) { if (Build.VERSION.SDK_INT Build.VERSION_CODES.Q) { // Android 10及以上强制使用分区存储逻辑 // 使用SAF或MediaStore if (isMediaFile(uri)) { useMediaStoreApi(uri) } else { useSafApi(uri) } } else { // Android 9及以下 if (hasWriteExternalStoragePermission()) { // 如果有权限可以尝试传统路径访问谨慎 // 但为了代码统一和未来兼容建议也转向使用SAF val filePath getFilePathFromUri(uri) // 注意此方法在Android 10可能失效 if (!filePath.isNullOrEmpty() File(filePath).exists()) { useLegacyFileApi(File(filePath)) } else { // 回退到SAF useSafApi(uri) } } else { // 申请权限或引导用户使用SAF requestStoragePermissionOrLaunchSaf() } } }核心建议即使支持旧版本也应在代码架构上以SAF和MediaStore为核心将传统路径访问作为条件性的、可选的回退方案。这能最大程度减少未来迁移到更高targetSdkVersion时的工作量。7. 性能优化与用户体验细节处理好基础功能后一些细节优化能极大提升用户体验。后台操作与ANR避免所有涉及SAF和DocumentFile的IO操作尤其是listFiles()、大文件读写必须放在后台线程。使用Kotlin协程、RxJava或AsyncTask已废弃但可理解等。在主线程操作会导致界面卡顿甚至ANR。进度指示对于遍历大型目录、复制大量文件等耗时操作务必显示不确定进度条或确定进度条。让用户知道App正在工作没有卡死。错误处理与用户提示SAF操作可能因各种原因失败存储弹出、文件被占用、权限回收等。用try-catch包裹关键操作并向用户提供清晰、友好的错误提示而不是崩溃或空白界面。缓存策略如果App需要频繁列出某个目录的内容如文档列表可以考虑在内存或本地数据库中缓存文件名、大小等元数据并监听目录变化可通过DocumentFile的Uri和ContentObserver实现部分监听但支持程度有限来更新缓存避免每次打开都重新遍历。UI/UX设计模仿系统文件管理器的交互。例如长按文件出现操作菜单重命名、删除、分享支持多选操作提供列表和网格视图切换等。让用户感觉熟悉和顺手。开发Android外置存储访问功能尤其是面向现代Android系统更像是在与一套精心设计的“规则”合作而非简单地操作硬件。理解并遵循SAF和分区存储的规则虽然初期学习曲线较陡但能换来更好的应用安全性、用户隐私保护以及未来版本的兼容性。从“我要怎么拿到路径”转变为“用户想让我操作哪个Uri”是思维上需要完成的关键转变。在实际项目中我通常会封装一个StorageAccessHelper类将SAF的复杂调用、权限检查和版本兼容逻辑隐藏起来对外提供简洁的listFiles、createFile等异步接口这样业务代码会清晰很多。最后多在不同的物理设备特别是不同品牌、不同Android版本的设备上测试你的存储相关功能厂商定制可能会带来一些意想不到的行为及早发现并处理这些差异是保证应用稳定性的关键。
Android外置存储读写:SAF与分区存储实战指南
1. 项目概述为什么Android外置存储读写是个“技术活”刚接触Android开发那会儿总觉得文件读写不就是FileInputStream和FileOutputStream那点事儿吗直到第一次把App装到带TF卡的设备上用户反馈“保存的图片不见了”、“无法导入U盘里的文档”我才意识到Android对外部存储External Storage的管理尤其是可移动介质Removable Media如TF卡和U盘完全是一个独立的、充满“坑”的领域。这不仅仅是调用几个API那么简单它涉及到Android系统为了安全、多用户和存储抽象化而设计的一整套复杂权限模型和存储访问框架SAF。简单来说Android设备上的存储空间被划分为“内部存储”和“外部存储”。内部存储是设备内置的、与系统紧密绑定的空间App私有数据就存在这里。而我们常说的“外部存储”早期主要指设备内置的共享存储空间如/storage/emulated/0所有App在获得权限后都能读写。但随着Android版本的演进特别是从Android 4.4KitKat引入“存储访问框架”Storage Access Framework, SAF到Android 6.0Marshmallow的动态权限再到Android 10Q引入的“分区存储”Scoped Storage对于真正的、物理上可插拔的外部存储设备——TF卡MicroSD和U盘——的访问方式发生了翻天覆地的变化。这个项目要解决的就是如何在现代Android应用特别是面向Android 10及以上版本中安全、合规且用户友好地实现对TF卡和U盘的读写操作。这不仅仅是写几行代码更是理解Android存储沙箱、权限边界和用户意图的过程。无论你是开发文件管理器、音乐播放器、备份工具还是任何需要与用户外部存储设备交互的应用掌握这套“组合拳”都至关重要。2. 核心概念与权限演变从“野蛮生长”到“精耕细作”在动手写代码之前我们必须先理清几个关键概念和Android在这方面的政策变迁。这能帮你理解为什么有些“老方法”现在行不通了以及新方法的设计逻辑。2.1 内部存储、外部存储与可移动存储很多开发者容易混淆这几个术语内部存储 (Internal Storage)设备内置的、不可移除的存储芯片。每个App在此拥有一个私有的目录/data/data/package_name其他App无权限访问除非设备已Root。这里存放App的代码、私有数据库和配置文件。外部存储 (External Storage)这是一个更宽泛的概念。在早期Android中它通常指设备内置的、用于共享媒体文件图片、音乐、视频的存储分区路径如/storage/emulated/0。注意现在很多手机所谓的“外部存储”其实是内置存储的一部分并非物理可移动。可移动存储 (Removable Storage)这才是我们项目的主角——物理上可以插拔的存储设备包括MicroSD卡TF卡和USB OTG连接的U盘、移动硬盘等。它们的挂载路径不固定通常是/storage/XXXX-XXXX或/mnt/media_rw/XXXX-XXXX这样的格式。我们的目标就是安全地访问“可移动存储”。2.2 权限模型的演进与分区存储Scoped StorageAndroid的存储访问权限管理经历了几个关键阶段远古时期 (Android 4.3及以前)在AndroidManifest.xml中声明WRITE_EXTERNAL_STORAGE权限即可获得对整个共享存储包括可移动存储的读写权。简单粗暴但安全隐患巨大。动态权限时代 (Android 6.0 - 9)引入了运行时权限。READ_EXTERNAL_STORAGE和WRITE_EXTERNAL_STORAGE权限需要在使用时动态向用户申请。但一旦授权App仍然可以几乎无限制地访问共享存储区。分区存储时代 (Android 10及以上默认启用)这是最大的转折点。Google引入了“分区存储”概念旨在更好地保护用户隐私和应用数据。核心思想每个App都拥有一个自己的“沙箱”在外部存储中对应一个专有的目录通过Context.getExternalFilesDir()等获取。App无需任何权限即可自由读写自己的沙箱。对共享区域的访问受限App不能直接通过文件路径如/storage/emulated/0/DCIM访问其他App创建的文件或用户媒体库。必须通过MediaStore API访问媒体文件或Storage Access Framework (SAF)访问任意文档和目录来访问。对可移动存储的影响在分区存储下App无法直接通过文件路径API访问可移动存储的根目录。传统的new File(“/storage/XXXX-XXXX”)方法会失败。访问必须通过SAF或者申请一个特殊的、谷歌不鼓励的权限MANAGE_EXTERNAL_STORAGE。重要提示MANAGE_EXTERNAL_STORAGE权限授予App访问所有文件包括可移动存储的能力但它的使用受到严格限制。Google Play商店要求使用此权限的应用必须属于特定的豁免类别如文件管理器、备份工具、防病毒软件等并且需要在应用商店中提交声明审核非常严格。对于大多数普通应用不应该使用此权限而应该使用SAF。3. 方案选型如何为你的App选择正确的访问策略面对TF卡和U盘我们主要有三种武器Storage Access Framework (SAF)、MediaStore API以及谨慎使用的直接路径访问。选择哪种取决于你的目标Android版本和具体需求。3.1 方案一Storage Access Framework (SAF) —— 现代应用的推荐方案这是Android 4.4引入的框架在分区存储时代成为访问外部文件尤其是非媒体文件和目录的标准且推荐的方式。工作原理SAF不直接暴露文件系统路径给App。它通过Intent启动一个系统级的文件选择器界面让用户自己选择要授予App访问权限的文件或目录。用户选择后系统会返回一个Uri内容URI给App。App通过ContentResolver并利用这个Uri来读写文件内容。优点用户控制权限由用户通过直观的UI授予符合最小权限原则。路径无关App不关心文件的实际物理路径只操作Uri兼容性好。安全App只能访问用户明确选择的文件/目录。统一接口可以访问设备存储、云盘如果文件选择器支持等多种来源。缺点依赖用户交互每次需要访问新的目录或大量文件时都需要用户再次选择流程可能被打断。无法后台静默访问不适合需要定期自动扫描或备份的场景。Uri权限持久化获取的Uri权限通常只在App存活期间有效重启后可能需要重新申请可以通过takePersistableUriPermission尝试持久化但需要用户在选择时勾选“允许”。适用场景文档编辑器打开/保存用户指定的文件、图片选择器让用户选择一张非媒体库中的图片、需要用户明确指向某个文件夹进行操作的场景。3.2 方案二MediaStore API —— 专为媒体文件设计如果你的App主要处理图片、音频、视频等媒体文件MediaStore是更高效、更语义化的选择。工作原理MediaStore是一个系统数据库索引了设备上所有的媒体文件。App通过ContentResolver查询MediaStore来获取媒体文件的Uri和元数据如日期、大小、经纬度等然后通过Uri进行读写。在可移动存储上的使用从Android 11开始App可以向MediaStore请求将媒体文件写入可移动存储的特定公共目录如Pictures,Movies,DCIM等。你不需要直接处理路径而是通过MediaStore的insert或createWriteRequest等API来操作系统会负责将文件放到正确的位置可能在内部存储也可能在用户选择的SD卡上。优点无需路径权限访问媒体文件不需要READ/WRITE_EXTERNAL_STORAGE权限Android 10。检索高效基于数据库查询比遍历文件系统快。自动扫描通过MediaStore写入的文件会被系统自动扫描并加入图库等应用。缺点仅限于媒体文件无法用于访问.txt,.pdf,.apk等非媒体文件。目录受限只能访问标准的媒体公共目录。适用场景相机应用保存照片到SD卡、音乐播放器读取SD卡中的歌曲、图库应用。3.3 方案三直接文件路径访问传统方式—— 仅限特定条件在满足以下所有条件时你才可能考虑使用传统的java.io.FileAPI进行直接访问targetSdkVersion 28 (Android 9) 且运行在Android 9及以下的设备上。或者你的App成功申请并获得了MANAGE_EXTERNAL_STORAGE权限且通过了Google Play的审核。你确切知道可移动存储的挂载路径需要通过Environment.getExternalStorageDirectory()或监听存储挂载广播来动态获取但方法已废弃或不可靠。实操心得对于新的、面向Android 10的应用强烈建议放弃此方案。依赖MANAGE_EXTERNAL_STORAGE权限会让你的应用上架过程变得复杂且可能被用户视为过度索权。SAF和MediaStore才是未来。4. 核心实现使用SAF读写TF卡/U盘全流程下面我们以最通用、最推荐的SAF方案为例详细拆解如何实现让用户选择TF卡/U盘上的一个目录并对其进行读写操作。4.1 第一步启动文档树选择器我们不请求选择单个文件而是请求选择一个目录文档树以获得对该目录及其子内容的持续访问权。// 在Activity或Fragment中 private fun openDirectoryPicker() { val intent Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).apply { // 可选设置初始URI但大多数情况下让用户自己选 // putExtra(DocumentsContract.EXTRA_INITIAL_URI, initialUri) // 可选提示用户选择特定存储卷Android 11 if (Build.VERSION.SDK_INT Build.VERSION_CODES.R) { putExtra(DocumentsContract.EXTRA_INITIAL_URI, DocumentsContract.buildRootUri( com.android.externalstorage.documents, primary )) } } // 使用startActivityForResult或Activity Results API的registerForActivityResult startActivityForResult(intent, REQUEST_CODE_OPEN_DIRECTORY) }注意EXTRA_INITIAL_URI只是一个建议用户完全可以在选择器中切换到其他位置如SD卡。无法通过代码直接、静默地定位到SD卡根目录这是SAF的设计哲学——用户主导。4.2 第二步处理选择结果并获取持久化权限用户选择目录后我们在onActivityResult中接收返回的Uri。override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { super.onActivityResult(requestCode, resultCode, data) if (requestCode REQUEST_CODE_OPEN_DIRECTORY resultCode Activity.RESULT_OK) { data?.data?.let { treeUri - // 1. 尝试获取持久化权限 val contentResolver applicationContext.contentResolver val takeFlags Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION contentResolver.takePersistableUriPermission(treeUri, takeFlags) // 2. 将treeUri保存到SharedPreferences或数据库中供后续使用 saveTreeUriToPrefs(treeUri.toString()) // 3. 现在可以使用这个treeUri来操作该目录了 listFilesInDirectory(treeUri) } } }关键点解析takePersistableUriPermission尝试将此次授权持久化。前提是用户在系统选择器中勾选了“允许访问此文件夹”旁边的复选框。如果用户没勾选此调用会失败并且该Uri权限仅在当前Activity生命周期内有效。务必保存treeUri的字符串形式。即使App重启只要权限是持久化的你依然可以用这个Uri来访问。4.3 第三步使用DocumentFile进行文件操作拿到目录树的Uri后我们不能直接用File类而要用DocumentFile这个辅助类。它提供了类似File的API但操作对象是SAF返回的Uri。import androidx.documentfile.provider.DocumentFile private fun listFilesInDirectory(treeUri: Uri) { val pickedDir DocumentFile.fromTreeUri(applicationContext, treeUri) pickedDir?.listFiles()?.forEach { documentFile - Log.d(SAF, Name: ${documentFile.name}, Type: ${documentFile.type}, Uri: ${documentFile.uri}) // 判断是文件还是目录 if (documentFile.isDirectory) { // 这是一个子目录 } else { // 这是一个文件 } } } // 创建新文件 private fun createFileInDirectory(treeUri: Uri, fileName: String, mimeType: String): DocumentFile? { val pickedDir DocumentFile.fromTreeUri(applicationContext, treeUri) return pickedDir?.createFile(mimeType, fileName) // 例如 text/plain, my_note.txt } // 创建子目录 private fun createSubDirectory(treeUri: Uri, dirName: String): DocumentFile? { val pickedDir DocumentFile.fromTreeUri(applicationContext, treeUri) return pickedDir?.createDirectory(dirName) } // 读取文件内容 private fun readFileContent(documentFile: DocumentFile): String { return applicationContext.contentResolver.openInputStream(documentFile.uri)?.use { inputStream - inputStream.bufferedReader().readText() } ?: } // 写入文件内容 private fun writeFileContent(documentFile: DocumentFile, content: String) { applicationContext.contentResolver.openOutputStream(documentFile.uri)?.use { outputStream - outputStream.bufferedWriter().use { writer - writer.write(content) } } } // 删除文件或目录 private fun deleteDocument(documentFile: DocumentFile): Boolean { return documentFile.delete() }实操心得DocumentFile的性能listFiles()操作可能比传统的File.listFiles()慢尤其是目录下文件非常多时。因为它涉及与DocumentsProvider的IPC通信。对于大型目录遍历考虑在后台线程进行并可能需添加进度提示。MIME类型很重要创建文件时指定正确的MIME类型如image/jpeg,text/plain有助于系统和其他应用正确识别你的文件。Uri的稳定性通过SAF获取的Uri通常是content://协议的它可能对应物理存储上的一个文件也可能是一个虚拟文件。不要试图去解析它的路径直接用它进行读写操作。4.4 第四步处理MediaStore与SAF的协作有时你可能需要通过SAF让用户选择一个文件夹来存放媒体文件但你又希望这些文件能被系统的媒体扫描器识别并加入图库。这时你可以结合MediaStore。用SAF获取到目标目录的treeUri。使用DocumentFile在该目录下创建文件例如一个.jpg文件。创建成功后你获得了新文件的documentFile.uri。为了通知媒体库你可以使用MediaScannerConnection扫描这个文件private fun scanMediaFile(fileUri: Uri) { val filePath getFilePathFromUri(fileUri) // 注意从SAF的Uri可能无法直接获取真实路径 // 更可靠的方式是使用MediaStore.insert if (Build.VERSION.SDK_INT Build.VERSION_CODES.Q) { val values ContentValues().apply { put(MediaStore.MediaColumns.DISPLAY_NAME, my_image.jpg) put(MediaStore.MediaColumns.MIME_TYPE, image/jpeg) put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_PICTURES /MyApp) } val resolver applicationContext.contentResolver val collection MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY) val mediaUri resolver.insert(collection, values) // 然后通过mediaUri打开OutputStream写入图片数据 } else { // Android Q以下如果获得了真实路径可以使用MediaScannerConnection filePath?.let { path - MediaScannerConnection.scanFile(applicationContext, arrayOf(path), arrayOf(image/jpeg), null) } } }警告从SAF的content://Uri获取真实文件路径_data字段在Android 10及以上是不可行的系统会返回null或抛出异常。永远不要依赖Uri到真实路径的转换。正确的做法是如果要操作媒体文件优先使用MediaStoreAPI操作其他文档则完全使用DocumentFile和ContentResolver。5. 常见问题排查与实战技巧在实际开发中你会遇到各种各样的问题。下面是我踩过坑后总结的一些典型问题和解决方法。5.1 问题一SAF选择器返回的Uri重启App后无法访问现象用户选择了目录当时能用但App进程被杀死或重启后再用保存的Uri去操作提示权限不足。原因没有成功调用takePersistableUriPermission或者用户在选择时没有勾选“允许访问此文件夹”的复选框。解决方案检查授权是否持久化每次App启动后使用contentResolver.persistedUriPermissions检查之前获取的Uri权限是否还在。引导用户在UI上提示用户选择目录时务必勾选“允许访问此文件夹”选项。可以在启动选择器前用对话框说明。优雅降级如果发现权限丢失重新引导用户到选择器流程。可以将之前保存的Uri通过Intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, previousUri)传入方便用户快速找到上次的位置。5.2 问题二通过SAF在SD卡上创建的文件在系统文件管理器中看不到现象使用DocumentFile.createFile()在SD卡目录创建文件成功用DocumentFile.listFiles()也能列出但用手机自带的“文件”App或连接电脑查看SD卡时找不到这个文件。原因这通常是因为文件系统缓存或FUSE用户空间文件系统层的问题。SAF通过DocumentsProvider操作文件有时不会立即触发底层文件系统的元数据更新。解决方案对于媒体文件使用MediaScannerConnection.scanFileAndroid Q以下或通过MediaStore插入Android Q以上来通知系统刷新。对于非媒体文件可以尝试发送一个广播此方法已逐渐废弃但某些旧系统可能有效val intent Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE) // 注意这里需要file:// Uri而从SAF可能无法获得。所以此方法通常不适用。 // 更可靠的方法是提示用户重启设备或等待系统自动刷新可能几分钟到几小时。最实用的建议在App内提供完整的文件浏览和管理功能。向用户解释通过本App创建和管理的文件最可靠的方式是在本App内查看。系统文件管理器的显示可能存在延迟。5.3 问题三如何判断用户选择的是内部存储还是SD卡/U盘需求场景App可能需要根据存储位置的不同采取不同策略如提示“SD卡速度较慢”。解决方法解析SAF返回的Uri。SD卡/U盘的Uri通常具有特定模式。fun isUriOnRemovableStorage(uri: Uri): Boolean { val uriString uri.toString() // 常见的可移动存储Uri模式: content://com.android.externalstorage.documents/tree/XXXX-XXXX:/ // 其中XXXX-XXXX是存储卷的IDprimary通常代表内部共享存储。 return uriString.startsWith(content://com.android.externalstorage.documents/tree/) !uriString.contains(/tree/primary:) }更准确的方法是使用DocumentsContract.getTreeDocumentId(uri)获取文档ID然后进行判断。但请注意不同设备、不同厂商的DocumentsProvider实现可能有差异此逻辑不一定100%准确。5.4 问题四批量操作复制、移动、删除大量文件速度慢原因每个DocumentFile操作都涉及与DocumentsProvider的跨进程通信IPC单线程顺序执行大量操作时延迟会累积。优化方案使用工作线程务必在后台线程如Dispatchers.IO执行批量操作。并发操作对于大量独立文件的读写可以考虑使用线程池进行有限的并发操作但注意不要开太多线程避免造成ANR或系统压力。进度反馈必须向用户显示进度条或当前处理的项目名因为操作可能很耗时。考虑使用SAF的剪切板功能对于移动/复制操作可以研究DocumentsContract中关于createDocument()、moveDocument()等底层API但复杂度较高。5.5 实战技巧保存和恢复目录访问权限的最佳实践持久化存储将用户授权的目录Uri字符串treeUri.toString()保存到SharedPreferences或数据库中。启动时验证在App主Activity或需要访问存储的界面初始化时读取保存的Uri字符串尝试恢复DocumentFile。fun restoreTreeUri(): DocumentFile? { val uriString getSavedUriStringFromPrefs() return if (!uriString.isNullOrEmpty()) { val uri Uri.parse(uriString) // 检查权限是否还在 val hasPermission applicationContext.contentResolver.persistedUriPermissions.any { it.uri uri } if (hasPermission) { DocumentFile.fromTreeUri(applicationContext, uri) } else { // 权限已丢失清除保存的Uri引导用户重新选择 clearSavedUriFromPrefs() null } } else { null } }提供便捷入口在App设置中提供一个“重新选择存储目录”的按钮方便用户在权限丢失或想更换目录时操作。6. 针对不同Android版本的兼容性处理你的App可能需要支持较旧的Android版本如Android 5.0/6.0。这时需要一套兼容方案。6.1 Android 5.0/6.0 到 Android 9 (API 21-28)在这个版本区间如果targetSdkVersion设置为28或以下你仍然可以尝试使用传统的文件路径访问SD卡前提是用户授予了WRITE_EXTERNAL_STORAGE运行时权限。获取SD卡路径方法已废弃且不可靠。常见做法是遍历/storage或/mnt目录通过Environment.isExternalStorageRemovable()和Environment.getExternalStorageState()判断哪个是有效的可移动存储。但不同厂商设备路径差异极大。更推荐的做法即使在这个版本也优先使用SAFACTION_OPEN_DOCUMENT_TREE从API 21开始支持。它提供了更一致的体验并为将来升级targetSdkVersion铺平道路。对于只需要读写自己沙箱和媒体库的应用使用MediaStore和运行时权限。6.2 编写版本兼容的代码使用Build.VERSION.SDK_INT进行条件判断。fun handleFileOperation(uri: Uri) { if (Build.VERSION.SDK_INT Build.VERSION_CODES.Q) { // Android 10及以上强制使用分区存储逻辑 // 使用SAF或MediaStore if (isMediaFile(uri)) { useMediaStoreApi(uri) } else { useSafApi(uri) } } else { // Android 9及以下 if (hasWriteExternalStoragePermission()) { // 如果有权限可以尝试传统路径访问谨慎 // 但为了代码统一和未来兼容建议也转向使用SAF val filePath getFilePathFromUri(uri) // 注意此方法在Android 10可能失效 if (!filePath.isNullOrEmpty() File(filePath).exists()) { useLegacyFileApi(File(filePath)) } else { // 回退到SAF useSafApi(uri) } } else { // 申请权限或引导用户使用SAF requestStoragePermissionOrLaunchSaf() } } }核心建议即使支持旧版本也应在代码架构上以SAF和MediaStore为核心将传统路径访问作为条件性的、可选的回退方案。这能最大程度减少未来迁移到更高targetSdkVersion时的工作量。7. 性能优化与用户体验细节处理好基础功能后一些细节优化能极大提升用户体验。后台操作与ANR避免所有涉及SAF和DocumentFile的IO操作尤其是listFiles()、大文件读写必须放在后台线程。使用Kotlin协程、RxJava或AsyncTask已废弃但可理解等。在主线程操作会导致界面卡顿甚至ANR。进度指示对于遍历大型目录、复制大量文件等耗时操作务必显示不确定进度条或确定进度条。让用户知道App正在工作没有卡死。错误处理与用户提示SAF操作可能因各种原因失败存储弹出、文件被占用、权限回收等。用try-catch包裹关键操作并向用户提供清晰、友好的错误提示而不是崩溃或空白界面。缓存策略如果App需要频繁列出某个目录的内容如文档列表可以考虑在内存或本地数据库中缓存文件名、大小等元数据并监听目录变化可通过DocumentFile的Uri和ContentObserver实现部分监听但支持程度有限来更新缓存避免每次打开都重新遍历。UI/UX设计模仿系统文件管理器的交互。例如长按文件出现操作菜单重命名、删除、分享支持多选操作提供列表和网格视图切换等。让用户感觉熟悉和顺手。开发Android外置存储访问功能尤其是面向现代Android系统更像是在与一套精心设计的“规则”合作而非简单地操作硬件。理解并遵循SAF和分区存储的规则虽然初期学习曲线较陡但能换来更好的应用安全性、用户隐私保护以及未来版本的兼容性。从“我要怎么拿到路径”转变为“用户想让我操作哪个Uri”是思维上需要完成的关键转变。在实际项目中我通常会封装一个StorageAccessHelper类将SAF的复杂调用、权限检查和版本兼容逻辑隐藏起来对外提供简洁的listFiles、createFile等异步接口这样业务代码会清晰很多。最后多在不同的物理设备特别是不同品牌、不同Android版本的设备上测试你的存储相关功能厂商定制可能会带来一些意想不到的行为及早发现并处理这些差异是保证应用稳定性的关键。