Android 10+ 文件操作适配:3种SD卡写入方案对比与Scoped Storage实战
Android 10 文件操作适配3种SD卡写入方案深度解析与实战指南1. 理解Scoped Storage的核心变革Android 10引入的分区存储机制Scoped Storage彻底改变了应用访问外部存储的方式。这项变革的核心目标是保护用户隐私和数据安全防止应用随意访问其他应用的私有数据。在传统模式下一个简单的文件管理器应用就能扫描整个设备存储这显然存在严重的安全隐患。新机制下应用对存储空间的访问被划分为三个明确区域应用私有目录每个应用独有的沙盒空间路径通常为/storage/emulated/0/Android/data/package_name/。在这里应用拥有完整的读写权限无需任何额外授权。媒体集合包括图片、视频、音频等公共媒体文件通过MediaStore API访问。应用可以读取这些文件但写入需要用户授权。下载集合包含Documents、Downloads等特定目录通过Storage Access Framework(SAF)访问。关键限制变化// 传统方式 - Android 9及以下有效 val file File(/storage/emulated/0/CustomFolder/data.txt) file.createNewFile() // 在Android 10上会抛出异常2. 三种SD卡写入方案对比分析2.1 方案一直接申请MANAGE_EXTERNAL_STORAGE权限这是最暴力的解决方案通过声明MANAGE_EXTERNAL_STORAGE权限获取近似root的存储访问能力。但Google Play对这类应用有严格审核适用场景文件管理器类应用系统备份/恢复工具杀毒软件等安全应用实现步骤在AndroidManifest.xml中添加声明uses-permission android:nameandroid.permission.MANAGE_EXTERNAL_STORAGE /检查并请求权限if (Build.VERSION.SDK_INT Build.VERSION_CODES.R) { if (!Environment.isExternalStorageManager()) { val intent Intent(Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION) startActivity(intent) } }优缺点对比优点缺点完全绕过Scoped Storage限制需要用户手动在系统设置中授权保持传统File API使用方式Google Play审核严格非核心功能会被拒绝兼容旧代码成本低可能被部分应用商店标记为高风险提示小米等厂商设备需要额外提交功能证明材料才能上架应用商店2.2 方案二使用DocumentFile API适配DocumentFile是Android系统提供的兼容性API它在File类基础上增加了对内容URI的支持成为Scoped Storage下的推荐方案。典型使用场景// 获取Downloads目录的DocumentFile实例 val downloadsUri Uri.parse(content://com.android.externalstorage.documents/tree/primary%3ADownloads) val downloadsDir DocumentFile.fromTreeUri(context, downloadsUri) // 创建新文件 val newFile downloadsDir?.createFile(text/plain, log.txt) newFile?.uri?.let { uri - context.contentResolver.openOutputStream(uri)?.use { os - os.write(Hello Scoped Storage.toByteArray()) } }关键优势自动适配不同Android版本支持URI持久化权限提供与File类相似的API接口性能对比测试数据操作类型File API(ms)DocumentFile(ms)创建100个1KB文件120180读取100个文件属性80150递归遍历目录(100文件)503002.3 方案三通过SAF让用户选择目录Storage Access Framework(SAF)是Android 4.4引入的系统级文件选择器在Scoped Storage下成为访问非媒体文件的唯一官方推荐方案。完整实现流程启动目录选择器val intent Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).apply { flags Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION or Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION putExtra(DocumentsContract.EXTRA_INITIAL_URI, initialUri) } startActivityForResult(intent, REQUEST_CODE_DIRECTORY_PICK)处理选择结果并获取持久权限override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { if (requestCode REQUEST_CODE_DIRECTORY_PICK resultCode RESULT_OK) { data?.data?.let { treeUri - // 获取持久访问权限 contentResolver.takePersistableUriPermission( treeUri, Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION ) // 保存URI供后续使用 Prefs.saveDirectoryUri(treeUri.toString()) } } }在选定目录中创建文件fun createFileInSelectedDir(context: Context, fileName: String, mimeType: String): Uri? { val treeUri Uri.parse(Prefs.getDirectoryUri()) return DocumentsContract.createDocument( context.contentResolver, treeUri, mimeType, fileName ) }SAF的限制与注意事项Android 11禁止访问Android/data和Android/obb目录不同设备厂商可能定制文件选择器界面获取的URI在应用卸载后失效批量操作性能较差不适合高频IO场景3. 实战文件下载功能的多版本适配以下是一个完整的文件下载实现兼容Android 5.0到最新版本class FileDownloader(private val context: Context) { companion object { private const val REQUEST_CODE_DIRECTORY 1024 private const val DOWNLOAD_DIR Download } fun downloadFile(url: String, fileName: String) { when { // Android 10 使用SAF方案 Build.VERSION.SDK_INT Build.VERSION_CODES.Q - { if (Prefs.hasDirectoryUri()) { downloadWithSAF(Uri.parse(Prefs.getDirectoryUri()), url, fileName) } else { requestDirectoryPermission() } } // Android 6-9 使用传统权限方案 Build.VERSION.SDK_INT Build.VERSION_CODES.M - { if (checkSelfPermission(WRITE_EXTERNAL_STORAGE) PERMISSION_GRANTED) { downloadLegacy(url, fileName) } else { requestPermissions(arrayOf(WRITE_EXTERNAL_STORAGE), REQUEST_CODE_PERMISSION) } } // Android 5及以下版本 else - downloadLegacy(url, fileName) } } private fun downloadWithSAF(directoryUri: Uri, url: String, fileName: String) { try { val docFile DocumentFile.fromTreeUri(context, directoryUri) val newFile docFile?.createFile(getMimeType(fileName), fileName) newFile?.uri?.let { uri - val outputStream context.contentResolver.openOutputStream(uri) OkHttpClient().newCall(Request.Builder().url(url).build()).enqueue( object : Callback { override fun onFailure(call: Call, e: IOException) { // 处理下载失败 } override fun onResponse(call: Call, response: Response) { response.body?.byteStream()?.use { input - outputStream?.use { output - input.copyTo(output) } } } } ) } } catch (e: Exception) { // 处理异常 } } private fun downloadLegacy(url: String, fileName: String) { val downloadDir Environment.getExternalStoragePublicDirectory(DOWNLOAD_DIR) if (!downloadDir.exists()) downloadDir.mkdirs() val targetFile File(downloadDir, fileName) OkHttpClient().newCall(Request.Builder().url(url).build()).enqueue( object : Callback { override fun onFailure(call: Call, e: IOException) { // 处理下载失败 } override fun onResponse(call: Call, response: Response) { response.body?.byteStream()?.use { input - FileOutputStream(targetFile).use { output - input.copyTo(output) } } } } ) } private fun getMimeType(fileName: String): String { return when { fileName.endsWith(.pdf) - application/pdf fileName.endsWith(.txt) - text/plain // 其他MIME类型判断 else - application/octet-stream } } }4. 疑难问题排查与性能优化4.1 常见问题解决方案问题一SAF返回的URI权限丢失现象应用重启后无法访问之前获取的目录URI解决方案// 在Application或主Activity的onCreate中恢复权限 Prefs.getDirectoryUri()?.let { uriString - val uri Uri.parse(uriString) try { contentResolver.takePersistableUriPermission( uri, Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION ) } catch (e: SecurityException) { // URI权限已失效需要重新获取 Prefs.clearDirectoryUri() } }问题二大文件操作超时当通过ContentResolver操作大文件时可能会因IPC通信超时导致失败。建议采用分块写入策略fun writeLargeFile(uri: Uri, inputStream: InputStream) { val buffer ByteArray(1024 * 1024) // 1MB缓冲区 var outputStream: OutputStream? null try { outputStream contentResolver.openOutputStream(uri) var bytesRead: Int while (inputStream.read(buffer).also { bytesRead it } ! -1) { outputStream?.write(buffer, 0, bytesRead) outputStream?.flush() // 定期刷新缓冲区 } } finally { outputStream?.close() inputStream.close() } }4.2 性能优化建议批量操作优化// 不好的实践逐个文件操作 files.forEach { file - val docFile parentDir.createFile(mimeType, file.name) copyFileToDocument(file, docFile) } // 优化方案批量处理 val batch ArrayListPairFile, DocumentFile() files.forEach { file - val docFile parentDir.createFile(mimeType, file.name) batch.add(file to docFile) } // 使用线程池并行处理 val executor Executors.newFixedThreadPool(4) batch.forEach { (file, docFile) - executor.submit { copyFileToDocument(file, docFile) } } executor.shutdown()缓存策略// 建立DocumentFile到本地路径的映射缓存 val documentCache LruCacheString, DocumentFile(100) fun getCachedDocument(path: String): DocumentFile? { return documentCache.get(path) ?: run { val doc queryDocument(path) doc?.let { documentCache.put(path, it) } doc } }IO操作最佳实践使用缓冲流BufferedInputStream/BufferedOutputStream避免在主线程执行文件操作对频繁访问的文件考虑内存映射(MappedByteBuffer)5. 版本兼容性处理与测试要点5.1 多版本兼容策略建议采用如下兼容性方案矩阵Android版本推荐方案备选方案5.0及以下传统File API-6.0-9.0WRITE_EXTERNAL_STORAGE权限File APIDocumentFile10.0-12.0DocumentFile/SAFMANAGE_EXTERNAL_STORAGE(特殊场景)13.0SAF优先DocumentFile(私有目录)5.2 关键测试场景权限测试矩阵测试场景预期结果授予MANAGE_EXTERNAL_STORAGE权限可访问所有存储位置拒绝MANAGE_EXTERNAL_STORAGE权限只能访问授权目录撤销SAF目录授权相关操作应失败并提示从备份恢复应用URI权限应保持有效跨厂商兼容性测试重点关注以下厂商的特殊行为小米可能对SAF有定制修改华为EMUI的文件管理器差异三星One UI对存储权限的特殊处理OPPO/Vivo可能限制后台文件操作性能测试指标100个1MB文件的批量写入时间目录遍历响应时间(包含1000个文件)低内存设备上的稳定性表现长时间运行的资源泄漏检测在实际项目中我们发现采用分层架构能有效处理这些兼容性问题。将文件操作抽象为统一接口不同版本实现具体细节interface StorageManager { fun createFile(fileName: String): Boolean fun copyFile(source: Uri, destName: String): Boolean fun listFiles(): ListFileInfo } // Android 10实现 class ScopedStorageManager(val context: Context) : StorageManager { override fun createFile(fileName: String): Boolean { // 使用SAF或DocumentFile实现 } } // 传统实现 class LegacyStorageManager : StorageManager { override fun createFile(fileName: String): Boolean { // 使用File API实现 } } // 统一入口 fun getStorageManager(context: Context): StorageManager { return if (Build.VERSION.SDK_INT Build.VERSION_CODES.Q) { ScopedStorageManager(context) } else { LegacyStorageManager() } }