Android 10/11 分区存储适配完全指南

5 阅读3分钟

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/包名/
  • 访问媒体文件(图片/视频/音频)需要通过 MediaStore API
  • 访问其他应用的文件需要用户通过系统文件选择器授权

二、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 时临时使用)
  • 访问媒体文件已改用 MediaStore API
  • 保存图片到相册使用 MediaStore 而非直接写文件
  • 应用专属文件使用 getExternalFilesDir() 访问
  • 文件分享使用 FileProvider 生成 content:// URI
  • 需要访问外部文件时使用系统文件选择器
  • 在 Android 10、11、12、13 设备上分别测试
  • 不需要的存储权限已移除或替换为精细化权限

八、参考资源


分区存储适配是 Android 开发中的经典难题,如果本文对你有帮助,欢迎点赞收藏。如有疑问,欢迎在评论区交流。