ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Android存储权限演进与文件读写适配:从运行时权限到分区存储的完整解决方案

Android存储权限演进与文件读写适配:从运行时权限到分区存储的完整解决方案 1. 从一次真实的文件保存失败说起那天下午我正在调试一个需要将用户日志保存到外部存储的安卓应用。在模拟器上一切正常但当我将应用安装到一台运行 Android 11 的真机上点击“导出日志”按钮时控制台赫然抛出了一个熟悉的异常java.io.IOException: Operation not permitted。用户界面上的进度条卡住然后弹出一个模糊的“保存失败”提示。这已经不是第一次遇到了从 Android 6.0 (Marshmallow) 引入运行时权限到 Android 10 (Q) 的沙盒存储Scoped Storage再到 Android 11 (R) 进一步的权限收紧每一次安卓大版本的更新都让“读写SD卡”这个看似简单的操作变得愈发复杂。这个Operation not permitted错误就像一扇不断变换锁芯的门开发者需要持续更新手中的“钥匙”。本文的目的就是为你梳理清楚这扇门从 Android 早期版本到 Android 13 (Tiramisu) 的锁芯变化并提供一套完整的、可落地的“开锁”方案。无论你是要保存一张图片、一段录音还是备份应用数据到用户指定的SD卡目录你都能在这里找到清晰的路径和必须绕开的坑。我们将从最基础的运行时权限申请讲起穿越分区存储的迷雾最终抵达在 Android 13 上仍能稳定工作的彼岸。这不是一篇简单的API调用文档而是融合了多年适配血泪史的经验总结其中包含了许多官方文档语焉不详但实际开发中却至关重要的细节。2. 权限演进史从“为所欲为”到“画地为牢”要解决问题必须先理解问题是如何产生的。安卓外部存储访问权限的演变是一部从粗放走向精细从混乱走向秩序的历史。理解这段历史你就能明白为什么旧代码在新系统上会失灵以及新规范试图解决什么核心问题。2.1 远古时代Android 4.4及以前WRITE_EXTERNAL_STORAGE的霸权在 Android 4.4 (KitKat) 及更早的版本中开发者在AndroidManifest.xml中静态声明一个权限用户安装应用时一次性授予之后应用几乎就能在外部存储包括内置的“模拟SD卡”和物理SD卡的根目录下为所欲为。你可以在任何地方创建文件夹、写入文件甚至修改其他应用的文件如果权限允许。这种方式对开发者极其友好但带来了严重的安全和隐私问题一个手电筒应用可以偷偷读取你的微信聊天图片库用户也无法清晰知道应用到底在存储空间里做了什么。2.2 运行时权限时代Android 6.0 - 9.0用户的第一次掌控Android 6.0 (Marshmallow) 引入了运行时权限模型。WRITE_EXTERNAL_STORAGE和READ_EXTERNAL_STORAGE被归为危险权限。这意味着即使你在清单文件中声明了也必须在应用运行时弹窗向用户申请。用户可以选择“允许”或“拒绝”。这是一个巨大的进步将权限控制的粒度从安装时细化到了运行时。然而在这个阶段一旦用户授予了WRITE_EXTERNAL_STORAGE权限应用仍然拥有广泛的写入能力可以访问共享存储空间的大部分区域。对于物理SD卡通过Environment.getExternalStorageDirectory()获取的路径通常是内置存储要访问真正的可移除SD卡需要一些额外的技巧比如通过Context.getExternalFilesDirs()返回的数组来识别第二个路径如果存在SD卡。2.3 沙盒存储时代Android 10格局重塑Android 10 是外部存储访问规则的一个分水岭它强制实施了Scoped Storage分区存储。其核心思想是应用私有目录每个应用都拥有一个位于外部存储上的私有目录Android/data/package_name/或Android/obb/package_name/无需任何权限即可自由读写。应用卸载时这些目录会被自动清理。这是谷歌强烈推荐的应用数据存储位置。共享集合对于媒体文件图片、视频、音频应用可以通过 MediaStore API 访问公共集合如 DCIM, Pictures, Movies, Music 等无需WRITE_EXTERNAL_STORAGE权限即可向这些集合插入文件。但修改或删除其他应用创建的文件则需要用户通过系统选择器如照片选择器明确授权。访问其他目录对于非媒体文件或者需要访问共享存储中任意位置如下载文件夹中的特定文件应用必须使用系统的文件选择器ACTION_OPEN_DOCUMENT或ACTION_CREATE_DOCUMENT来让用户指定文件/目录并获得一个长期有效的访问权限URI 和takePersistableUriPermission。在 Android 10 上默认情况下以 Android 10API 29为目标平台的应用会自动启用分区存储。如果你将targetSdkVersion设为 29 但还想沿用旧模式必须在清单文件中添加android:requestLegacyExternalStorage”true”。但请注意这只是暂时的避风港。2.4 权限收紧时代Android 11 及以后沙盒成为唯一选项从 Android 11 (R) 开始分区存储被强制启用requestLegacyExternalStorage属性失效。这意味着所有应用无论targetSdkVersion是多少在 Android 11 的设备上都将遵循分区存储规则。同时权限进一步收紧WRITE_EXTERNAL_STORAGE权限被严重降级在 Android 11 上即使你申请并获得了这个权限它的实际效力也大打折扣基本只能用于兼容旧应用对新应用开发的意义已经不大。所有文件访问权限如果应用确实需要绕过 MediaStore 和文件选择器直接访问共享存储中的任何文件例如文件管理器应用它必须申请一个名为MANAGE_EXTERNAL_STORAGE的特殊权限。用户需要在系统设置中手动开启且应用上架 Google Play 时需要声明合理的用途并可能接受审核。物理SD卡访问访问可移除SD卡上的应用私有目录Android/data/your_package/on SD card相对容易通过Context.getExternalFilesDirs()获取的第二个路径即可。但若要访问SD卡上的其他任意位置规则与访问内置共享存储一样严格通常需要MANAGE_EXTERNAL_STORAGE权限或使用文件选择器。到了 Android 13权限模型更加细化引入了独立的照片和视频权限READ_MEDIA_IMAGES,READ_MEDIA_VIDEO,READ_MEDIA_AUDIO以取代粗粒度的READ_EXTERNAL_STORAGE。用户现在可以只授予应用访问照片的权限而不授予访问视频的权限。3. 实战构建一个兼容到 Android 13 的文件读写工具类理论讲完我们进入实战环节。我将构建一个FileStorageHelper工具类它能够处理最常见的文件保存场景将应用生成的文件如日志、导出的数据保存到用户“期望”的位置——可能是下载目录也可能是用户通过系统选择器选择的SD卡上的某个文件夹。这个方案将兼容从 Android 6.0 到 Android 13 的广泛版本。3.1 基础准备清单文件与权限检查首先在AndroidManifest.xml中声明必要的权限。我们的策略是“按需声明”避免申请不必要的权限。manifest ... !-- 对于 Android 13 (API 33) 以下的设备读取共享存储中的媒体文件需要此权限 -- uses-permission android:nameandroid.permission.READ_EXTERNAL_STORAGE android:maxSdkVersion32 / !-- 在 Android 13使用更细粒度的媒体权限 -- uses-permission android:nameandroid.permission.READ_MEDIA_IMAGES / uses-permission android:nameandroid.permission.READ_MEDIA_VIDEO / uses-permission android:nameandroid.permission.READ_MEDIA_AUDIO / !-- 向共享存储写入媒体文件如图片到相册在 Android 10 不需要此权限但为了兼容旧版本和某些场景可以声明到 28 -- uses-permission android:nameandroid.permission.WRITE_EXTERNAL_STORAGE android:maxSdkVersion28 / !-- 如果应用是文件管理器类需要访问所有文件则声明此权限。普通应用慎用 -- !-- uses-permission android:nameandroid.permission.MANAGE_EXTERNAL_STORAGE / -- application ... ... !-- 为了兼容 Android 7.0 的文件共享必须配置 FileProvider -- provider android:nameandroidx.core.content.FileProvider android:authorities${applicationId}.fileprovider android:exportedfalse android:grantUriPermissionstrue meta-data android:nameandroid.support.FILE_PROVIDER_PATHS android:resourcexml/file_paths / /provider /application /manifest注意WRITE_EXTERNAL_STORAGE的maxSdkVersion设为 28是因为在 Android 10 (API 29) 及以上对于向MediaStore公共集合写入媒体文件这个权限已非必需。但如果你需要兼容 Android 9 (API 28) 及以下版本向非应用私有目录写入任意文件这个权限还是需要的。这里设置为 28 是一个平衡的兼容策略。接下来创建FileProvider的路径配置文件res/xml/file_paths.xml。这决定了你的应用可以通过FileProvider安全共享哪些目录下的文件。?xml version1.0 encodingutf-8? paths xmlns:androidhttp://schemas.android.com/apk/res/android !-- 对应 Context.getExternalFilesDir(null) -- external-files-path nameexternal_files path. / !-- 对应 Context.getExternalCacheDir() -- external-cache-path nameexternal_cache path. / !-- 对应 Context.getFilesDir() -- files-path nameinternal_files path. / !-- 对应 Context.getCacheDir() -- cache-path nameinternal_cache path. / !-- 对应 Environment.getExternalStorageDirectory() (谨慎使用Android 10可能受限) -- external-path nameexternal_storage_root path. / /paths3.2 核心工具类设计分场景处理我们的FileStorageHelper将提供两个核心方法saveFileToPublicDirectory尝试将文件保存到公共目录如Download如果失败无权限或系统限制则降级到应用私有目录。saveFileViaDocumentTree启动系统文档树选择器让用户选择一个文件夹获得长期访问权限后直接在该文件夹内读写。我们先处理权限请求的封装。这是一个在 Activity 或 Fragment 中使用的权限请求工具方法。// PermissionRequester.kt import android.app.Activity import android.content.pm.PackageManager import android.os.Build import androidx.core.app.ActivityCompat import androidx.core.content.ContextCompat object PermissionRequester { // 检查并请求读取外部存储的权限兼容 Android 13 前后 fun checkAndRequestReadStoragePermission(activity: Activity, requestCode: Int): Boolean { val permissionsToRequest mutableListOfString() if (Build.VERSION.SDK_INT Build.VERSION_CODES.TIRAMISU) { // Android 13 使用细粒度媒体权限 if (ContextCompat.checkSelfPermission(activity, android.Manifest.permission.READ_MEDIA_IMAGES) ! PackageManager.PERMISSION_GRANTED) { permissionsToRequest.add(android.Manifest.permission.READ_MEDIA_IMAGES) } // 根据应用需要添加 READ_MEDIA_VIDEO 和 READ_MEDIA_AUDIO // if (... ! ...) { permissionsToRequest.add(...) } } else { // Android 12- 使用 READ_EXTERNAL_STORAGE if (ContextCompat.checkSelfPermission(activity, android.Manifest.permission.READ_EXTERNAL_STORAGE) ! PackageManager.PERMISSION_GRANTED) { permissionsToRequest.add(android.Manifest.permission.READ_EXTERNAL_STORAGE) } } if (permissionsToRequest.isNotEmpty()) { ActivityCompat.requestPermissions(activity, permissionsToRequest.toTypedArray(), requestCode) return false } return true } // 检查并请求旧版的写入权限主要用于 Android 9 及以下 fun checkAndRequestLegacyWritePermission(activity: Activity, requestCode: Int): Boolean { if (Build.VERSION.SDK_INT Build.VERSION_CODES.P) { if (ContextCompat.checkSelfPermission(activity, android.Manifest.permission.WRITE_EXTERNAL_STORAGE) ! PackageManager.PERMISSION_GRANTED) { ActivityCompat.requestPermissions(activity, arrayOf(android.Manifest.permission.WRITE_EXTERNAL_STORAGE), requestCode) return false } } // Android 10 不需要此权限来向 MediaStore 写入媒体文件但写入其他公共目录可能需要 MANAGE_EXTERNAL_STORAGE 或使用 SAF return true } }现在我们实现核心的FileStorageHelper。// FileStorageHelper.kt import android.content.ContentValues import android.content.Context import android.net.Uri import android.os.Build import android.os.Environment import android.provider.MediaStore import android.provider.DocumentsContract import androidx.core.content.FileProvider import java.io.* import java.text.SimpleDateFormat import java.util.* object FileStorageHelper { /** * 方案A尝试保存到公共下载目录失败则保存到应用私有目录。 * 适用于保存用户可能希望方便找到的导出文件如日志、报告。 * param context 上下文 * param fileName 建议的文件名如 “log_20231027.txt”) * param fileContent 文件内容字符串 * return 返回最终保存的文件Uri和路径信息 */ fun saveFileToPublicDirectory(context: Context, fileName: String, fileContent: String): SaveResult { // 首先尝试保存到 Downloads 目录Android 10 使用 MediaStore val savedUri saveToDownloadsViaMediaStore(context, fileName, fileContent) if (savedUri ! null) { return SaveResult(success true, uri savedUri, message 文件已保存至下载目录) } // 如果 MediaStore 方式失败可能是低版本或权限问题尝试传统路径方式需要权限且 Android 10 可能失败 val legacyPath saveToDownloadsLegacy(context, fileName, fileContent) if (legacyPath ! null) { // 将 File 路径转换为可通过 FileProvider 访问的 Uri val uri FileProvider.getUriForFile(context, ${context.packageName}.fileprovider, File(legacyPath)) return SaveResult(success true, uri uri, filePath legacyPath, message 文件已保存至下载目录传统方式) } // 所有公共目录保存尝试均失败降级到应用私有外部存储目录 val fallbackDir context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS) val fallbackFile File(fallbackDir, fileName) try { BufferedWriter(FileWriter(fallbackFile)).use { it.write(fileContent) } val fallbackUri FileProvider.getUriForFile(context, ${context.packageName}.fileprovider, fallbackFile) return SaveResult(success true, uri fallbackUri, filePath fallbackFile.absolutePath, message 公共目录无权限文件已保存至应用私有目录: ${fallbackFile.absolutePath}) } catch (e: IOException) { return SaveResult(success false, message 保存到私有目录也失败: ${e.message}) } } /** * 方案B通过系统文档树选择器让用户指定一个文件夹可以是SD卡任意位置获得持久化权限。 * 适用于需要让用户自定义保存位置且可能多次读写的场景。 * 注意此方法不直接保存文件而是返回一个 Intent用于启动系统选择器。 * 保存操作在 onActivityResult 中拿到 Uri 后进行。 */ fun createOpenDocumentTreeIntent(): Intent { val intent Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).apply { // 可选设置初始目录但用户可导航到任何位置 if (Build.VERSION.SDK_INT Build.VERSION_CODES.O) { putExtra(DocumentsContract.EXTRA_INITIAL_URI, Uri.parse(content://com.android.externalstorage.documents/root/primary)) } // 添加读写权限标志以获得持久化权限 addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION or Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION) } return intent } /** * 在用户通过文档树选择器授权后使用返回的 Uri 进行文件读写。 * param treeUri 用户选择的目录的 Uri (例如 content://com.android.externalstorage.documents/tree/XXXX) * param fileName 要创建/写入的文件名 * param content 文件内容 */ fun writeFileToDocumentTree(context: Context, treeUri: Uri, fileName: String, content: String): Boolean { try { // 1. 获取持久化权限重要否则重启后权限丢失 val takeFlags Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION context.contentResolver.takePersistableUriPermission(treeUri, takeFlags) // 2. 构造目标文件的 Uri // DocumentsContract.buildDocumentUriUsingTree 已过时推荐使用 DocumentsContract.buildChildDocumentsUriUsingTree // 但更通用的方法是使用 DocumentsContract.createDocument val intent Intent(Intent.ACTION_CREATE_DOCUMENT).apply { addCategory(Intent.CATEGORY_OPENABLE) type text/plain // 根据文件类型修改 putExtra(Intent.EXTRA_TITLE, fileName) // 设置初始位置为已授权的树 Uri if (Build.VERSION.SDK_INT Build.VERSION_CODES.O) { putExtra(DocumentsContract.EXTRA_INITIAL_URI, treeUri) } } // 注意这里需要启动另一个 Activity 来创建文件。更直接的方式是使用 DocumentsContract 的 API。 // 另一种做法假设我们在已授权的目录下直接构建子文档 Uri需要权限 // 这里演示一个更“硬核”但直接的方法需要正确解析 treeUri // 由于 SAF 的复杂性通常建议在 onActivityResult 中直接使用返回的文件 Uri 进行写入。 // 因此更常见的流程是使用 ACTION_CREATE_DOCUMENT 或 ACTION_OPEN_DOCUMENT 直接获取文件 Uri而不是目录 Uri。 // 本函数更适用于你已经通过其他方式获得了某个目录下的文件 Uri。 // 简化处理如果我们已经有一个文件 Uri来自 CREATE_DOCUMENT 的结果直接写入。 // 这里假设 treeUri 已经是某个具体文件的 Uri实际场景中treeUri是目录需要先创建文件Uri // 以下代码仅为示意真实场景需要结合 onActivityResult 中获取的具体文件 Uri 操作。 context.contentResolver.openOutputStream(treeUri)?.use { outputStream - outputStream.write(content.toByteArray()) return true } } catch (e: Exception) { e.printStackTrace() } return false } // --- 内部辅助方法 --- private fun saveToDownloadsViaMediaStore(context: Context, fileName: String, content: String): Uri? { if (Build.VERSION.SDK_INT Build.VERSION_CODES.Q) { // Android 9 及以下MediaStore.Downloads 不完善用传统方式 return null } val resolver context.contentResolver val values ContentValues().apply { put(MediaStore.Downloads.DISPLAY_NAME, fileName) put(MediaStore.Downloads.MIME_TYPE, text/plain) // 相对路径设置到 Downloads/ 子目录下 put(MediaStore.Downloads.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS /MyAppLogs) } return try { val uri resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, values) uri?.let { resolver.openOutputStream(it)?.use { stream - stream.write(content.toByteArray()) } it } } catch (e: Exception) { e.printStackTrace() null } } private fun saveToDownloadsLegacy(context: Context, fileName: String, content: String): String? { // 此方法需要 WRITE_EXTERNAL_STORAGE 权限且在 Android 10 可能因分区存储而失败 if (Build.VERSION.SDK_INT Build.VERSION_CODES.Q) { return null // Android 10 不推荐此方式 } val downloadsDir Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS) if (!downloadsDir.exists()) { downloadsDir.mkdirs() } val file File(downloadsDir, fileName) return try { BufferedWriter(FileWriter(file)).use { it.write(content) } file.absolutePath } catch (e: IOException) { e.printStackTrace() null } } data class SaveResult( val success: Boolean, val uri: Uri? null, val filePath: String? null, val message: String ) }3.3 在 Activity/Fragment 中的调用示例// MainActivity.kt 示例片段 import android.app.Activity import android.content.Intent import android.net.Uri import android.os.Bundle import androidx.appcompat.app.AppCompatActivity import kotlinx.android.synthetic.main.activity_main.* class MainActivity : AppCompatActivity() { private val REQUEST_CODE_READ_STORAGE 1001 private val REQUEST_CODE_DOCUMENT_TREE 1002 private var selectedTreeUri: Uri? null override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) btn_save_log.setOnClickListener { // 示例保存日志到公共目录或私有目录 saveLogFile() } btn_pick_folder.setOnClickListener { // 示例启动文档树选择器让用户选择SD卡上的文件夹 pickFolderForSaving() } btn_save_to_picked_folder.setOnClickListener { // 示例向用户已选择的文件夹写入文件 selectedTreeUri?.let { uri - val success FileStorageHelper.writeFileToDocumentTree(this, uri, log_in_sd.txt, This is log content.) if (success) { showToast(文件保存成功到选定文件夹) } else { showToast(文件保存失败) } } ?: showToast(请先选择一个文件夹) } } private fun saveLogFile() { // 1. 检查并申请必要的读取权限用于可能需要的文件浏览或MediaStore查询 val hasPermission PermissionRequester.checkAndRequestReadStoragePermission(this, REQUEST_CODE_READ_STORAGE) if (!hasPermission) { // 等待 onRequestPermissionsResult 回调 showToast(需要权限才能保存到公共目录) return } // 2. 检查旧版写入权限对于低版本传统方式 val hasLegacyWritePerm PermissionRequester.checkAndRequestLegacyWritePermission(this, REQUEST_CODE_READ_STORAGE 1) // 使用不同的requestCode if (!hasLegacyWritePerm) { return } // 3. 执行保存操作 val logContent 这是一条测试日志\n时间${SimpleDateFormat(yyyy-MM-dd HH:mm:ss).format(Date())} val result FileStorageHelper.saveFileToPublicDirectory(this, my_app_log.txt, logContent) showToast(result.message) // 可以根据 result.uri 分享文件或通知用户 } private fun pickFolderForSaving() { val intent FileStorageHelper.createOpenDocumentTreeIntent() startActivityForResult(intent, REQUEST_CODE_DOCUMENT_TREE) } override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { super.onActivityResult(requestCode, resultCode, data) if (resultCode Activity.RESULT_OK) { when (requestCode) { REQUEST_CODE_DOCUMENT_TREE - { data?.data?.let { uri - selectedTreeUri uri showToast(已选择文件夹: $uri) // 可以持久化保存这个 uri以便下次启动应用时直接使用需调用 takePersistableUriPermission } } } } } override fun onRequestPermissionsResult(requestCode: Int, permissions: Arrayout String, grantResults: IntArray) { super.onRequestPermissionsResult(requestCode, permissions, grantResults) if (grantResults.isNotEmpty() grantResults[0] PackageManager.PERMISSION_GRANTED) { when (requestCode) { REQUEST_CODE_READ_STORAGE - { // 权限已授予可以重试保存操作 saveLogFile() } // 处理其他 requestCode... } } else { showToast(权限被拒绝部分功能可能受限) } } private fun showToast(msg: String) { android.widget.Toast.makeText(this, msg, android.widget.Toast.LENGTH_SHORT).show() } }4. 疑难杂症与深度避坑指南在实际开发中仅仅知道API调用是远远不够的。下面这些坑都是我或者身边同事真金白银踩出来的有些甚至需要翻阅AOSP源码才能理解。4.1File.mkdirs()在 Android 10 的公共目录失效现象在 Android 9 及以下使用File(Environment.getExternalStorageDirectory(), “MyApp/Logs”).mkdirs()可以在存储根目录创建文件夹。但在 Android 10 的设备上这个方法返回false并且java.io.IOException: Operation not permitted异常随之而来。根因分区存储Scoped Storage的限制。应用默认不再拥有共享存储空间根目录的直接文件系统访问权限。Environment.getExternalStorageDirectory()返回的路径虽然还存在但普通应用已无法直接在其中创建目录或文件。解决方案使用 MediaStore对于媒体文件使用MediaStore.Images.Media、MediaStore.Downloads等 ContentProvider API 插入记录系统会负责文件的创建和路径管理。使用 SAF对于非媒体文件或任意位置使用ACTION_OPEN_DOCUMENT_TREE或ACTION_CREATE_DOCUMENT让用户授权特定目录或文件。使用应用私有目录这是最安全、最推荐的方式。通过Context.getExternalFilesDir(String type)获取应用在外部存储上的私有目录无需任何权限即可读写。type参数可以是Environment.DIRECTORY_DOCUMENTS,DIRECTORY_DOWNLOADS等系统会帮你创建对应的子目录。4.2FileOutputStream或FileWriter在已授权目录仍报错现象即使通过 SAF 获得了某个目录的持久化 URI 权限尝试用传统的FileOutputStream(filePath)方式写入文件依然会抛出FileNotFoundException或Operation not permitted。根因通过 SAF 获得的权限是基于ContentResolver和Uri的而不是传统的文件系统路径java.io.File权限。你不能将一个content://协议的 Uri 直接当作文件路径来使用java.io.FileAPI。解决方案必须使用ContentResolver来打开输入/输出流。val uri: Uri ... // 从 SAF 返回的 Uri try { context.contentResolver.openOutputStream(uri)?.use { outputStream - outputStream.write(data) } } catch (e: IOException) { e.printStackTrace() }对于读取也是类似使用context.contentResolver.openInputStream(uri)。4.3 物理 SD 卡路径的获取与访问现象Environment.getExternalStorageDirectory()通常返回内置存储的路径。如何找到物理 SD 卡的路径根因Android 系统将可移除的 SD 卡视为“次级外部存储设备”其访问权限与内置存储遵循同样的分区存储规则。解决方案获取 SD 卡上的应用私有目录Context.getExternalFilesDirs()和Context.getExternalCacheDirs()返回一个File数组。通常第一个元素[0]是内置存储上的目录第二个元素[1]如果存在就是 SD 卡上的应用私有目录。你可以直接在这个目录下读写无需额外权限。val externalDirs context.getExternalFilesDirs(null) if (externalDirs.size 1 externalDirs[1] ! null) { val sdcardPrivateDir externalDirs[1] // 可能是 /storage/XXXX-XXXX/Android/data/your.package/files // 可以直接使用 }访问 SD 卡上的其他公共区域这比访问内置存储的公共区域更复杂。你需要使用 SAF (ACTION_OPEN_DOCUMENT_TREE)用户可以在选择器里导航到 SD 卡的根目录通常显示为类似 “SDCARD” 或卷标名的条目或其下的任意文件夹进行授权。授权后你就可以通过DocumentsContractAPI 在该目录下操作文件。注意路径的持久化通过 SAF 获取的 SD 卡目录 Uri 可能是这样的content://com.android.externalstorage.documents/tree/XXXX-XXXX%3A。这个 Uri 是持久的你可以通过takePersistableUriPermission保存权限并在应用重启后继续使用。不要尝试解析这个 Uri 背后的物理路径因为它可能因设备、系统版本而异并且直接使用文件路径 API 会失败。4.4 Android 13 照片/视频权限的单独申请现象在 Android 13 设备上你的应用之前申请了READ_EXTERNAL_STORAGE权限并正常工作但升级 targetSdk 到 33 后无法读取用户的照片了。根因Android 13 将READ_EXTERNAL_STORAGE权限拆分为三个独立的权限READ_MEDIA_IMAGES、READ_MEDIA_VIDEO、READ_MEDIA_AUDIO。READ_EXTERNAL_STORAGE在 Android 13 上等同于同时申请这三个新权限但如果你将targetSdkVersion设置为 33 或更高系统将不再授予READ_EXTERNAL_STORAGE权限你必须申请新的细分权限。解决方案在AndroidManifest.xml中根据应用需要声明新的细分权限。在运行时根据系统版本动态申请fun requestMediaPermissions(activity: Activity) { if (Build.VERSION.SDK_INT Build.VERSION_CODES.TIRAMISU) { // Android 13 requestPermissions(activity, arrayOf(Manifest.permission.READ_MEDIA_IMAGES), REQUEST_CODE) } else { // Android 12 及以下 requestPermissions(activity, arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE), REQUEST_CODE) } }向后兼容在清单中可以同时声明新旧权限并使用maxSdkVersion来限制如本文 3.1 节所示。4.5MANAGE_EXTERNAL_STORAGE权限最后的武器与它的代价现象你的应用是文件管理器、备份工具或类似需要完全文件系统访问的应用SAF 的交互方式每次都要用户选择严重影响了用户体验。解决方案申请MANAGE_EXTERNAL_STORAGE权限。如果用户授予此权限在系统设置中手动开启你的应用将可以访问共享存储上的所有文件包括使用传统的FileAPI。但是代价巨大上架审核严格Google Play 对申请此权限的应用审核极其严格。你必须提供充分的理由证明你的应用核心功能必须需要此权限如文件管理器、杀毒软件、备份还原工具。许多普通工具类应用会被拒绝。用户信任门槛高用户会在系统设置中看到一个非常醒目的警告提示该应用可以访问所有文件。很多用户会因此拒绝安装或关闭权限。未来不确定性谷歌一直在收紧存储权限这个“后门”权限在未来版本中可能会被进一步限制或移除。建议除非万不得已否则不要使用MANAGE_EXTERNAL_STORAGE。优先设计你的应用以适应分区存储模型使用MediaStore、SAF 和应用私有目录。5. 测试策略如何确保你的方案真正兼容适配工作完成后充分的测试是保证没有Operation not permitted异常的关键。以下是我的测试清单多版本模拟器/真机覆盖至少准备 Android 8.0 (API 26)、Android 10 (API 29)、Android 11 (API 30)、Android 13 (API 33) 这四个关键版本的测试环境。Android 9 (API 28) 和 Android 12 (API 31) 也建议覆盖。权限授予与拒绝场景测试安装后首次运行拒绝所有权限应用功能是否正常降级如保存到私有目录。测试运行中动态申请权限用户允许后的功能是否正常。测试用户去系统设置中关闭权限后返回应用时的行为应能优雅处理而不是崩溃。SAF 流程测试测试ACTION_OPEN_DOCUMENT_TREE选择不同位置内置存储、SD卡根目录、子文件夹。测试授权后应用重启是否还能访问该目录检查takePersistableUriPermission是否生效。测试ACTION_CREATE_DOCUMENT和ACTION_OPEN_DOCUMENT对单个文件的操作。文件操作验证不仅测试写入还要测试读取、列出文件、删除如果应用支持等操作。对于通过 SAF 获得的 URI删除操作需要使用DocumentsContract.deleteDocument(context.contentResolver, uri)。后台行为测试如果你的应用需要在后台服务或 WorkManager 中访问文件确保相关的 URI 权限已经持久化并且使用的是ContentResolverAPI。存储空间不足模拟存储空间已满的情况检查你的代码是否能正确处理IOException并给出友好的用户提示。解决安卓各版本 SD 卡读写问题本质上是跟随系统安全模型的演进不断调整文件访问策略。从简单粗暴的FileAPI到运行时权限再到MediaStore和Storage Access Framework我们的代码需要变得更加精细和“礼貌”。核心思路永远是优先使用应用私有空间需要共享文件时使用系统提供的“桥梁”如MediaStore插入图片、使用 SAF 让用户选择只有在极其特殊且合理的情况下才去考虑申请全盘访问的“尚方宝剑”MANAGE_EXTERNAL_STORAGE。记住java.io.IOException: Operation not permitted不是一个需要消灭的错误而是一个提醒你遵循新规则的系统信号。拥抱这些变化你的应用才能在未来更复杂的安卓生态中行稳致远。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进