三亩地 三亩地SAN MU DI · CODE DIARY
ARTICLE DETAIL

日记详情

真实记录编程学习的某一天,欢迎挑你感兴趣的翻一翻。

Android分区存储适配指南:从权限模型到MediaStore与SAF实战

Android分区存储适配指南:从权限模型到MediaStore与SAF实战

1. 问题根源与权限模型演进

“我的App明明在旧手机上能正常保存文件,怎么换了个新手机就报错,说没有权限了?” 这几乎是每一位Android开发者,甚至是普通用户,在近几年都或多或少遇到过的问题。其核心,就是Android文件存储权限体系的剧烈变革。这个问题看似简单,背后却牵扯到Android系统从“粗放管理”到“精细管控”的演进史,以及开发者适配新规的阵痛。

在Android 10(API 29)之前,应用获取WRITE_EXTERNAL_STORAGE(写外部存储)权限后,几乎可以在SD卡或内置存储的公共区域(如/sdcard/)为所欲为。你可以随意创建文件夹,读写任何其他应用创建的文件(只要你知道路径)。这种模式带来了极大的便利,但也埋下了隐私和安全的地雷:一个手电筒App可以偷偷扫描并上传你的照片、文档;应用卸载后,残留的垃圾文件遍地都是,用户难以清理。

因此,从Android 10开始,谷歌引入了分区存储(Scoped Storage)。这是一个根本性的转变。它的核心理念是:应用默认只能访问自己专属的沙箱目录(App-specific directory)和系统创建的公共媒体文件(照片、视频、音频)。你不能再通过File对象和路径直接访问SD卡根目录或其他应用的私有文件。Android 11(API 30)进一步强化了这一策略,并最终在针对新应用(targetSdkVersion >= 30)的强制执行中达到顶峰。

所以,当你遇到“没有文件存储权限保存文件”的问题时,首先要问自己三个问题:1. 你的应用targetSdkVersion是多少?2. 你的设备系统版本是多少?3. 你要保存的文件是什么类型,要存到哪里去?这三个问题的答案,直接决定了你应该采用哪种解决方案。

注意:很多开发者会尝试在AndroidManifest.xml中声明android.permission.WRITE_EXTERNAL_STORAGE权限,并在Android 11+的设备上运行时动态申请。你会发现,即使申请通过了,你尝试在/sdcard/下创建文件依然会失败(FileNotFoundExceptionEACCES)。这是因为在高版本系统上,这个权限对于访问公共存储区域已经失效了。它现在仅用于访问MediaStore中的媒体文件(如图片、视频),且作用有限。这是一个最常见的认知误区。

2. 现代Android文件保存的正确姿势

理解了权限模型的变迁,我们就需要抛弃旧有的“路径+File”思维,拥抱新的API。根据不同的保存需求,现代Android提供了几条清晰的路径。

2.1 保存到应用私有目录:最安全、最推荐的方式

如果你的文件只供你的应用自己使用,例如缓存文件、用户配置、下载的临时数据等,那么保存到应用私有目录是绝对的首选。无需任何权限申请,系统保证其他应用无法访问(除非设备已Root)。

这个目录的路径可以通过Context的相关方法获取:

  • 内部存储私有目录context.getFilesDir(), 对应内部存储空间,通常空间较小但访问速度极快。context.getCacheDir()是其缓存子目录,系统在存储空间不足时可能会清理这里面的文件。
  • 外部存储私有目录context.getExternalFilesDir(String type)context.getExternalCacheDir()。这些目录位于外部存储(如SD卡或模拟的外部存储分区),空间相对宽裕。type参数可以是null(根目录)或Environment类中的常量,如Environment.DIRECTORY_DOCUMENTSEnvironment.DIRECTORY_PICTURES等,系统会帮你创建对应的分类文件夹。

实操示例:保存一个文本配置文件

// Kotlin 示例 fun saveConfigToPrivateDir(context: Context, configContent: String) { try { // 获取应用在外部存储的私有文档目录 val docsDir = context.getExternalFilesDir(Environment.DIRECTORY_DOCUMENTS) val configFile = File(docsDir, "my_app_config.json") // 使用BufferedWriter写入内容 BufferedWriter(FileWriter(configFile)).use { writer -> writer.write(configContent) } Log.d(“FileSave”, “配置文件已保存至:${configFile.absolutePath}”) } catch (e: IOException) { Log.e(“FileSave”, “保存配置文件失败”, e) // 处理异常,例如提示用户存储空间不足 } }

这段代码在任何Android版本上都可以正常运行,无需权限。文件会被保存在类似/storage/emulated/0/Android/data/你的包名/files/Documents/my_app_config.json的路径下。当用户卸载你的应用时,整个Android/data/你的包名/文件夹会被自动清除,完美避免了垃圾文件残留。

2.2 保存媒体文件到公共集合:使用MediaStore API

当你的应用需要保存图片、视频或音频文件,并希望它们能出现在系统的相册、视频播放器等应用中时,你必须使用MediaStoreAPI。这是Android官方指定的、跨版本兼容的、访问公共媒体文件的唯一标准方式。

MediaStore将媒体文件抽象为一个数据库,你通过ContentResolver进行“插入”、“查询”、“更新”、“删除”操作,而不是直接操作文件路径。

实操示例:保存一张图片到公共相册(Pictures目录)

fun saveImageToPublicGallery(context: Context, bitmap: Bitmap, displayName: String): Uri? { // 1. 设置要保存的图片信息 val contentValues = ContentValues().apply { put(MediaStore.Images.Media.DISPLAY_NAME, “${displayName}_${System.currentTimeMillis()}.jpg”) put(MediaStore.Images.Media.MIME_TYPE, “image/jpeg”) // 对于Android Q及以上,RELATIVE_PATH指定子目录 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { put(MediaStore.Images.Media.RELATIVE_PATH, Environment.DIRECTORY_PICTURES + “/MyAppAlbum”) } // 对于Android Q以下,需要WRITE_EXTERNAL_STORAGE权限,并通过File操作 } // 2. 获取ContentResolver并执行插入操作 val resolver = context.contentResolver val uri = resolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentValues) if (uri != null) { try { // 3. 通过Uri打开输出流,写入图片数据 resolver.openOutputStream(uri)?.use { outputStream -> bitmap.compress(Bitmap.CompressFormat.JPEG, 90, outputStream) } // 4. 可选:通知系统媒体库扫描新文件(对于旧版本系统或某些情况) if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { val intent = Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE) intent.data = uri context.sendBroadcast(intent) } return uri // 返回文件的Uri,这是访问该文件的“身份证” } catch (e: Exception) { Log.e(“MediaStore”, “保存图片失败”, e) // 如果写入失败,最好删除已创建的条目 resolver.delete(uri, null, null) } } return null }

关键点解析

  • ContentValues: 相当于你要创建的文件“元数据”,告诉系统文件名、类型、保存位置。
  • RELATIVE_PATH(API 29+): 这是Android 10以上指定公共目录下子文件夹的关键。例如Environment.DIRECTORY_PICTURES + “/MyAppAlbum”会在公共图片目录下创建一个MyAppAlbum文件夹。这是替代旧版直接创建File对象的核心
  • Uri: 成功插入后返回的Uri(如content://media/external/images/media/12345)是后续访问、分享或删除该文件的唯一标识。不要尝试将它解析成文件路径再使用FileAPI,应该始终通过ContentResolver和这个Uri来操作。
  • 权限: 在Android 10及以上,保存到Pictures,Movies,Music等特定公共目录,无需申请WRITE_EXTERNAL_STORAGE权限。但如果你的targetSdkVersion低于29,或者需要访问其他非媒体公共目录,情况会复杂一些,可能需要申请MANAGE_EXTERNAL_STORAGE这个特殊权限(后面会详述)。

2.3 保存非媒体文件到公共下载/文档目录:使用Storage Access Framework (SAF)

对于PDF、Word、Excel、TXT等非媒体文件,如果你希望用户能通过文件管理器直接找到,或者让用户自己选择保存位置,Storage Access Framework (SAF)是最佳选择。它通过一个系统级的文件选择器(Intent)让用户“亲自”指定保存位置,应用则获得一个代表该位置或文件的Uri的长期访问权限。

实操示例:使用SAF创建并保存一个文本文件

// 启动SAF创建文档的Intent fun createFileWithSAF(activity: Activity, mimeType: String = “text/plain”, fileName: String = “new_file.txt”) { val intent = Intent(Intent.ACTION_CREATE_DOCUMENT).apply { addCategory(Intent.CATEGORY_OPENABLE) type = mimeType putExtra(Intent.EXTRA_TITLE, fileName) // 可选:指定初始打开的目录(仅部分系统支持) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { putExtra(DocumentsContract.EXTRA_INITIAL_URI, DocumentsContract.buildRootUri( “com.android.externalstorage.documents”, “primary” )) } } // 使用startActivityForResult启动,在onActivityResult中处理返回的Uri activity.startActivityForResult(intent, REQUEST_CODE_CREATE_DOC) } // 在onActivityResult中处理返回的Uri并写入内容 override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { super.onActivityResult(requestCode, resultCode, data) if (requestCode == REQUEST_CODE_CREATE_DOC && resultCode == Activity.RESULT_OK) { data?.data?.let { uri -> // 用户已选择位置并确认,现在可以通过这个Uri写入内容 saveContentToUri(uri, “这是文件的内容”) } } } fun saveContentToUri(uri: Uri, content: String) { try { contentResolver.openOutputStream(uri)?.use { outputStream -> outputStream.write(content.toByteArray()) } // 保存成功 } catch (e: Exception) { Log.e(“SAF”, “写入文件失败”, e) } }

SAF的优势在于:

  1. 无需权限: 应用不需要申请任何存储权限,因为保存位置是用户自己选的。
  2. 用户可控: 用户体验好,用户清楚地知道文件存到了哪里。
  3. 访问持久: 通过takePersistableUriPermission可以获得对该Uri的长期读写权限,即使应用重启后依然有效。
  4. 通用性强: 可以访问手机存储、SD卡、甚至云存储服务(如果云盘应用提供了SAF接口)。

3. 应对遗留代码与特殊场景

现实开发中,我们常常需要维护老项目,或者集成一些只支持路径字符串的第三方库。这时,我们需要一些“桥接”方案。

3.1 兼容旧API:FileProvider与File API的有限使用

如果你的targetSdkVersion低于29,或者你在AndroidManifest.xml中设置了android:requestLegacyExternalStorage=”true”来暂时退出分区存储(注意:此标志在targetSdkVersion >= 30的应用上对Android 11+设备无效),你或许还能短暂地使用旧方法。但这不是长久之计。

更通用的兼容方案是使用FileProviderFileProviderContentProvider的一个特殊子类,它可以将应用私有目录下的文件,通过content://Uri安全地分享给其他应用(包括系统的图片选择器、分享功能等)。虽然它主要设计用于分享,但结合一些技巧,也能解决部分“需要文件路径”的场景。

例如,你需要调用一个第三方图片裁剪库,它要求传入一个File对象作为输出路径:

  1. 在应用私有目录(如缓存目录)创建一个临时文件。
  2. 使用FileProvider为这个文件生成一个content://Uri。
  3. 将这个Uri传递给裁剪库。裁剪库通过ContentResolver打开这个Uri对应的输出流进行写入。
  4. 裁剪完成后,你仍然通过FileProvider生成的Uri来读取裁剪后的图片。

这样,整个过程没有涉及公共存储路径,完全在分区存储的安全模型内完成。

3.2 申请所有文件访问权限:MANAGE_EXTERNAL_STORAGE

这是一个“核选项”。如果你的应用是文件管理器、备份工具、杀毒软件等确实需要广泛访问设备上所有文件的类型,你可以申请MANAGE_EXTERNAL_STORAGE权限。用户需要在系统设置中手动为你开启一个名为“允许访问所有文件”的开关。

重要限制与流程

  1. 严格审核: 上架Google Play Store时,你必须声明此权限的合理用途,并可能面临人工审核。滥用此权限的应用会被拒绝上架。
  2. 用户引导复杂: 申请此权限的代码会跳转到系统设置页,用户体验不连贯。你需要用Environment.isExternalStorageManager()来检查是否已授权。
  3. 并非万能: 即使获得此权限,你仍然无法直接访问其他应用的私有沙箱目录(Android/data/Android/obb/下的其他应用目录)。在Android 11+上,访问这些目录被完全禁止。

申请示例

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:” + context.packageName) startActivity(intent) } }

强烈建议: 除非你的应用核心功能离不开它,否则应尽量避免使用此权限。优先考虑使用MediaStore、SAF和应用私有目录。

4. 实战避坑指南与疑难排查

在实际开发中,即使知道了正确方法,依然会踩坑。下面是一些高频问题和解决方案。

4.1 常见错误与日志分析

  • java.io.FileNotFoundException: /storage/emulated/0/... (Permission denied)这是最典型的错误。直接表明你试图用FileAPI访问一个你没有权限的路径。解决方案:立即停止使用绝对路径和FileAPI来访问公共存储。改用MediaStore或SAF。

  • java.lang.SecurityException: Permission Denial: writing ... requires android.permission.WRITE_EXTERNAL_STORAGE动态申请了WRITE_EXTERNAL_STORAGE权限却依然报错。原因:在Android 10+上,即使有这个权限,也不能在公共存储区域随意创建文件了。解决方案:检查你的targetSdkVersion和保存逻辑,转向使用MediaStoreAPI。

  • 通过MediaStore保存的图片在相册中不显示

    1. Android 10以下: 需要发送ACTION_MEDIA_SCANNER_SCAN_FILE广播通知系统刷新媒体库。
    2. 所有版本: 确保你通过ContentResolver.openOutputStream(uri)成功写入了数据,并且没有抛出异常。写入完成后,可以尝试调用MediaScannerConnection.scanFile来主动扫描。
    3. 延迟: 系统媒体库扫描可能有延迟,等待几分钟或重启“媒体存储”应用(com.android.providers.media.module)。
  • SAF返回的Uri,重启应用后无法访问通过SAF获取的Uri,默认只有一次性访问权限。如果需要持久化访问,必须在获取到Uri后立即调用contentResolver.takePersistableUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION)来获取持久化权限。获取后,可以将这个Uri的字符串形式保存到SharedPreferences或数据库中,下次启动时直接使用。

4.2 第三方库兼容性处理

很多图片加载(如Glide)、视频处理、文档预览库都支持Uri输入。这是最佳实践。如果某个库强制要求File或路径字符串,你可以尝试以下步骤:

  1. 寻找替代库: 优先寻找支持InputStreamUri的现代库。
  2. 使用缓存桥接: 如果必须使用,先将你的Uri内容读取出来,写入到应用私有缓存目录的一个临时File中,再将这个临时File的路径传给第三方库。处理完成后,记得清理临时文件。
    fun createTempFileFromUri(context: Context, uri: Uri): File? { val tempFile = File(context.externalCacheDir, “temp_${System.currentTimeMillis()}”) try { context.contentResolver.openInputStream(uri)?.use { input -> FileOutputStream(tempFile).use { output -> input.copyTo(output) } } return tempFile } catch (e: Exception) { tempFile.delete() // 清理失败的文件 return null } }
  3. 联系库作者: 向库的维护者反馈,请求增加UriContentResolver支持。

4.3 版本兼容代码的优雅写法

你的代码可能需要覆盖从Android 4.4到Android 14的设备。一个清晰的兼容策略是:

  • Android 10 (API 29) 及以上: 强制使用MediaStore(媒体文件)或SAF(非媒体文件)。
  • Android 9 (API 28) 及以下: 如果targetSdkVersion < 29,可以继续使用旧版FileAPI配合WRITE_EXTERNAL_STORAGE权限。但建议即使在这里,也优先尝试使用MediaStoreAPI,因为它是向前兼容的。

在代码中,可以使用Build.VERSION.SDK_INT进行分支判断。但更好的架构是,创建一个FileRepository接口,然后为不同API级别提供不同的实现(如LegacyFileRepositoryScopedStorageFileRepository),通过依赖注入在运行时选择正确的实现。这样核心业务逻辑就不需要关心底层存储细节。

5. 架构设计与未来展望

处理文件存储不再是一个简单的File.save()调用,而应该被视为应用架构中的一个重要模块。一个健壮的文件存储模块应该:

  1. 抽象接口: 定义统一的saveFile,loadFile,deleteFile等接口,参数使用Uri或应用内自定义的标识符,而非绝对路径。
  2. 策略分离: 将“保存到私有目录”、“通过MediaStore保存图片”、“通过SAF保存文档”等不同策略封装成独立的类。
  3. 错误处理: 统一处理IOExceptionSecurityException等,并转化为对用户友好的提示(如“存储空间不足”、“权限被拒绝”)。
  4. 生命周期管理: 妥善管理通过SAF获取的持久化Uri权限,在应用卸载时,这些权限会被系统自动回收。

从Android的发展趋势来看,分区存储的政策只会越来越严格。谷歌正在推动应用更规范、更安全地访问用户数据。作为开发者,尽早拥抱MediaStore、SAF和应用私有目录,不仅是规避权限问题的技术手段,更是构建尊重用户隐私、体验良好、符合平台规范的现代Android应用的必然要求。每一次因为存储权限而焦头烂额的调试,其实都是在为应用融入这个更安全的生态系统而铺路。

← 返回列表