Android 10/11 分区存储适配完全指南
Android 10(API 29)引入了 Scoped Storage(分区存储),从根本上改变了应用访问外部存储的方式。Android 11(API 30)进一步强化了这个限制。本文系统梳理适配方案,帮助开发者解决实际开发中的存储访问问题。
一、什么是分区存储(Scoped Storage)
传统存储模型(Android 9 及以下):
- 应用通过
READ_EXTERNAL_STORAGE/WRITE_EXTERNAL_STORAGE权限,可以访问外部存储中的所有文件 - 文件存储在
sdcard/根目录下的任意位置 - 问题:应用可以随意读取/删除其他应用的文件,存在隐私和安全风险
分区存储模型(Android 10+):
- 应用只能访问自己专属的存储区域(
Android/data/包名/) - 访问媒体文件(图片/视频/音频)需要通过
MediaStoreAPI - 访问其他应用的文件需要用户通过系统文件选择器授权
二、Android 10(API 29)适配方案
2.1 临时退出分区存储(不推荐长期使用)
Android 10 提供了临时退出方案,在 Manifest 中添加:
<application
android:requestLegacyExternalStorage="true"
... >
限制:
- 仅对 targetSdk = 29 有效
- Android 11 及以上强制启用分区存储,此标志无效
- 仅作为过渡方案,建议尽快完成正式适配
2.2 访问媒体文件(图片/视频/音频)
使用 MediaStore API 访问媒体文件:
// 查询图片
fun queryImages(context: Context): List<Uri> {
val images = mutableListOf<Uri>()
val collection = MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL)
val projection = arrayOf(
MediaStore.Images.Media._ID,
MediaStore.Images.Media.DISPLAY_NAME,
MediaStore.Images.Media.SIZE
)
val selection = "${MediaStore.Images.Media.SIZE} > 0"
val sortOrder = "${MediaStore.Images.Media.DATE_ADDED} DESC"
context.contentResolver.query(
collection,
projection,
selection,
null,
sortOrder
)?.use { cursor ->
val idColumn = cursor.getColumnIndexOrThrow(MediaStore.Images.Media._ID)
while (cursor.moveToNext()) {
val id = cursor.getLong(idColumn)
val contentUri = ContentUris.withAppendedId(
MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
id
)
images.add(contentUri)
}
}
return images
}
2.3 保存图片到相册
suspend fun saveImageToGallery(context: Context, bitmap: Bitmap): Uri? = withContext(Dispatchers.IO) {
val contentValues = ContentValues().apply {
put(MediaStore.Images.Media.DISPLAY_NAME, "image_${System.currentTimeMillis()}.jpg")
put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg")
// Android 10+:指定存储相对路径(相册)
put(MediaStore.Images.Media.RELATIVE_PATH, Environment.DIRECTORY_PICTURES)
put(MediaStore.Images.Media.IS_PENDING, 1)
}
val collection = MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY)
val itemUri = context.contentResolver.insert(collection, contentValues)
if (itemUri != null) {
try {
context.contentResolver.openOutputStream(itemUri)?.use { outputStream ->
bitmap.compress(Bitmap.CompressFormat.JPEG, 90, outputStream)
}
// 写入完成后,将 IS_PENDING 设为 0(可见)
contentValues.clear()
contentValues.put(MediaStore.Images.Media.IS_PENDING, 0)
context.contentResolver.update(itemUri, contentValues, null, null)
return@withContext itemUri
} catch (e: Exception) {
// 写入失败,删除占位记录
context.contentResolver.delete(itemUri, null, null)
}
}
return@withContext null
}
三、Android 11(API 30)适配方案
Android 11 强制执行分区存储,且增加了更多限制和新的 API。
3.1 管理存储权限(MANAGE_EXTERNAL_STORAGE)
如果应用需要访问大量文件(如文件管理器、备份应用),可以申请 MANAGE_EXTERNAL_STORAGE 权限。
Manifest 声明:
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />
请求权限:
fun requestManageStoragePermission(context: Context) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
if (!Environment.isExternalStorageManager()) {
val intent = Intent(Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION).apply {
data = Uri.fromParts("package", context.packageName, null)
}
context.startActivity(intent)
}
}
}
⚠️ 重要提示: Google Play 对使用 MANAGE_EXTERNAL_STORAGE 的应用有严格审核,只有特定类型的应用(文件管理器、备份应用、防病毒应用)才能使用。普通应用使用此权限会被拒审。
3.2 使用系统文件选择器(推荐方案)
对于大多数应用,推荐使用系统文件选择器来访问文件,无需任何存储权限。
选择单个文件:
private val selectFileLauncher = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) { result ->
if (result.resultCode == Activity.RESULT_OK) {
val uri = result.data?.data
// 使用 ContentResolver 读取文件内容
uri?.let { handleSelectedFile(it) }
}
}
fun selectFile(context: Context) {
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
type = "*/*" // 所有类型,或指定如 "image/*"
// 允许选择多个文件
putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true)
}
selectFileLauncher.launch(intent)
}
private fun handleSelectedFile(uri: Uri) {
requireContext().contentResolver.openInputStream(uri)?.use { inputStream ->
// 读取文件内容
val content = inputStream.bufferedReader().readText()
// 处理内容...
}
}
持久化访问权限:
// 获取持久化权限(下次启动应用后仍然有效)
private fun takePersistableUriPermission(uri: Uri, context: Context) {
val contentResolver = context.contentResolver
val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or
Intent.FLAG_GRANT_WRITE_URI_PERMISSION
contentResolver.takePersistableUriPermission(uri, flags)
}
3.3 创建文档(保存文件)
private val createFileLauncher = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) { result ->
if (result.resultCode == Activity.RESULT_OK) {
val uri = result.data?.data
uri?.let { saveContentToUri(it) }
}
}
fun createFile(context: Context) {
val intent = Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
type = "text/plain"
putExtra(Intent.EXTRA_TITLE, "my_file.txt")
}
createFileLauncher.launch(intent)
}
private fun saveContentToUri(uri: Uri) {
requireContext().contentResolver.openOutputStream(uri)?.use { outputStream ->
outputStream.write("Hello, World!".toByteArray())
}
}
四、访问应用专属目录(无需权限)
应用专属目录(Android/data/包名/)的访问方式在分区存储下也有变化。
4.1 访问应用专属外部存储
// ✅ 推荐:使用 Context 提供的方法(无需权限)
val externalDir = context.getExternalFilesDir(null) // → /sdcard/Android/data/包名/files/
val externalCacheDir = context.externalCacheDir // → /sdcard/Android/data/包名/cache/
// ❌ 不推荐:直接拼接路径(可能在不同设备上失效)
val wrongPath = "/sdcard/Android/data/${context.packageName}/files/"
4.2 访问应用专属媒体目录
// Android 10+ 推荐使用 MediaStore,但也可以直接写入专属目录
val picturesDir = context.getExternalFilesDir(Environment.DIRECTORY_PICTURES)
val file = File(picturesDir, "my_photo.jpg")
// 写入文件(无需任何权限)
FileOutputStream(file).use { outputStream ->
bitmap.compress(Bitmap.CompressFormat.JPEG, 90, outputStream)
}
五、适配常见问题 FAQ
Q:Android 10/11 分区存储适配后,还需要申请 READ_EXTERNAL_STORAGE 吗?
A:需要,但要区分情况:
- targetSdk ≤ 32:仍然需要声明
READ_EXTERNAL_STORAGE(Android 13 以下) - targetSdk ≥ 33:不需要
READ_EXTERNAL_STORAGE,改用READ_MEDIA_IMAGES等精细化权限
Q:如何访问 DCIM/Camera/ 目录下的照片?
A:使用 MediaStore 查询,或引导用户通过系统文件选择器选择。
// 查询 DCIM 目录下的图片
val selection = "${MediaStore.Images.Media.BUCKET_DISPLAY_NAME} = ?"
val selectionArgs = arrayOf("Camera")
// ... 使用 contentResolver.query()
Q:应用升级到 Android 11 后,原来保存在外部存储的文件无法访问了怎么办?
A:需要迁移文件到应用专属目录,或引导用户通过文件选择器重新选择文件。
// 迁移示例:将旧文件复制到应用专属目录
fun migrateFile(context: Context, oldFile: File) {
val newDir = context.getExternalFilesDir(null)
val newFile = File(newDir, oldFile.name)
oldFile.inputStream().use { input ->
newFile.outputStream().use { output ->
input.copyTo(output)
}
}
}
Q:分区存储下如何分享文件给其他应用?
A:使用 FileProvider 生成 content:// URI,而不是 file:// URI。
<!-- AndroidManifest.xml -->
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
<!-- res/xml/file_paths.xml -->
<paths>
<external-files-path name="my_files" path="." />
<external-cache-path name="my_cache" path="." />
</paths>
fun shareFile(context: Context, file: File) {
val uri = FileProvider.getUriForFile(
context,
"${context.packageName}.fileprovider",
file
)
val intent = Intent(Intent.ACTION_SEND).apply {
type = "image/jpeg"
putExtra(Intent.EXTRA_STREAM, uri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
context.startActivity(Intent.createChooser(intent, "分享到"))
}
六、Gradle 配置建议
// app/build.gradle
android {
compileSdk 34
defaultConfig {
// 逐步升级 targetSdk,不要一次跨太多版本
targetSdk 34
minSdk 21
}
}
dependencies {
// 使用 AndroidX 的 FileProvider
implementation "androidx.core:core-ktx:1.12.0"
}
七、适配检查清单
完成分区存储适配后,建议对照以下清单进行检查:
- 已移除
requestLegacyExternalStorage(或仅在 targetSdk=29 时临时使用) - 访问媒体文件已改用
MediaStoreAPI - 保存图片到相册使用
MediaStore而非直接写文件 - 应用专属文件使用
getExternalFilesDir()访问 - 文件分享使用
FileProvider生成content://URI - 需要访问外部文件时使用系统文件选择器
- 在 Android 10、11、12、13 设备上分别测试
- 不需要的存储权限已移除或替换为精细化权限
八、参考资源
分区存储适配是 Android 开发中的经典难题,如果本文对你有帮助,欢迎点赞收藏。如有疑问,欢迎在评论区交流。