Android 15 隐私新特性:所有文件都需要 MEDIA_VISUAL_PERMISSION

Android 15 引入了一个影响深远的隐私变更:所有访问用户媒体文件的操作都必须通过新的权限模型和系统选择器。这不仅仅是增加了一个权限声明那么简单,而是从根本上改变了应用访问媒体文件的方式——从「应用主动扫描」变成了「用户主动选择」。

如果你的应用涉及相册、文件选择、图片上传等功能,这个变更将直接影响你。本文从原理到实战,完整拆解这次变更的来龙去脉和适配方案。

一、背景:Android 媒体权限的演进

要理解 Android 15 的变更,先回顾一下媒体权限的演进史:

Android 12 及以前:宽泛的存储权限

早期 Android 提供两个粗粒度存储权限:

  • READ_EXTERNAL_STORAGE — 读取外部存储的所有文件
  • WRITE_EXTERNAL_STORAGE — 写入外部存储的所有文件

应用只要声明了这两个权限,就能遍历用户手机上的所有图片、视频、音频和文档。用户在授权时只能选择「全部允许」或「全部拒绝」,没有任何中间态。

Android 13:细分媒体权限

Android 13(API 33)将存储权限拆分为更细粒度的媒体权限:

  • READ_MEDIA_IMAGES — 读取图片
  • READ_MEDIA_VIDEO — 读取视频
  • READ_MEDIA_AUDIO — 读取音频

这看起来进步了,但问题依然存在:只要用户授权了 READ_MEDIA_IMAGES,应用仍然可以通过 MediaStore API 扫描用户相册中的所有图片,用户完全不知道应用读了哪些照片。

💡 核心矛盾:无论权限怎么细分,只要应用拿到了媒体权限,就能批量扫描用户相册。这违背了「最小必要」原则。

Android 14:Photo Picker 试水

Android 14 引入了系统级 Photo Picker(照片选择器),让用户可以选择特定照片授权给应用,而不是授权整个相册。但此时 Photo Picker 是可选的,应用仍然可以使用传统权限 + MediaStore 方案。

二、Android 15 到底变了什么

Android 15 的核心变更可以用一句话概括:READ_MEDIA_IMAGES 和 READ_MEDIA_VIDEO 权限不再授予访问全部媒体文件的能力

具体来说:

1. 新增 READ_MEDIA_VISUAL_USER_SELECTED 权限

Android 15 新增了 READ_MEDIA_VISUAL_USER_SELECTED 权限。这个权限的含义是:用户主动选择授权给应用的图片和视频。

权限组合效果如下:

权限组合效果
仅 READ_MEDIA_VISUAL_USER_SELECTED只能访问用户通过 Photo Picker 选择的媒体
READ_MEDIA_IMAGES + READ_MEDIA_VISUAL_USER_SELECTED可以访问所有图片 + 用户选择的媒体
READ_MEDIA_VIDEO + READ_MEDIA_VISUAL_USER_SELECTED可以访问所有视频 + 用户选择的媒体

2. 权限授权行为改变

在 Android 15 上,当你请求 READ_MEDIA_IMAGESREAD_MEDIA_VIDEO 时:

  • 系统不会弹出传统的权限授权对话框
  • 而是直接弹出 Photo Picker,让用户选择要授权的照片/视频
  • 用户选择后,应用获得的是 READ_MEDIA_VISUAL_USER_SELECTED 权限,而非完整的 READ_MEDIA_IMAGES

这意味着即使你在 Manifest 中声明了 READ_MEDIA_IMAGES,在 Android 15 上也拿不到完整相册访问权限,除非用户在系统设置中手动将权限升级为「允许所有」。

3. MediaStore 查询受限

这是最关键的影响点。在 Android 15 上:

  • 只有用户通过 Photo Picker 选择的媒体才会出现在 MediaStore.ImagesMediaStore.Video 的查询结果中
  • 调用 ContentResolver.query() 查询 MediaStore 时,只能查到用户授权的文件
  • 尝试访问未授权的媒体文件会抛出 SecurityException 或返回空结果
⚠️ 重要:即使应用声明了 READ_MEDIA_IMAGES 权限并且用户「授权」了,如果用户只通过 Photo Picker 选了 3 张照片,MediaStore 查询也只会返回这 3 张照片。

三、对你的应用有什么影响

不同类型的应用受到的影响程度不同:

影响最大:需要全量扫描相册的应用

以下场景会受到严重影响:

  • 相册管理应用(需要展示所有照片)
  • 照片备份应用(需要扫描全部图片上传)
  • 图片编辑应用(需要浏览相册选择照片)
  • 社交媒体应用(需要展示相册网格供用户选择)

这些应用如果继续使用 MediaStore.query() 方案,在 Android 15 上将只能看到用户手动选择的少量照片。

影响较小:只需用户选择单张/几张照片的应用

如果应用只需要让用户选一张照片作为头像、或者选一张图片插入文档,影响较小,直接接入 Photo Picker 即可。

不受影响:不访问媒体文件的应用

如果你的应用不涉及媒体文件访问,自然不受影响。

四、适配方案详解

根据不同场景,适配策略也不同。

方案一:接入 Photo Picker(推荐大多数应用)

如果你的应用只需要让用户选择照片/视频,Photo Picker 是最简单的方案。

基本用法

使用 AndroidX Activity Result API 启动 Photo Picker:

// build.gradle.kts
dependencies {
    implementation("androidx.activity:activity-ktx:1.9.0")
    implementation("androidx.activity:activity-compose:1.9.0")
}
// 启动 Photo Picker - 选择单张图片
val pickMedia = registerForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri ->
    uri?.let {
        // 使用 uri 加载图片
        binding.imageView.setImageURI(it)
        // 注意:uri 是临时授权,需要尽快使用或持久化
        contentResolver.takePersistableUriPermission(
            it,
            Intent.FLAG_GRANT_READ_URI_PERMISSION
        )
    }
}

// 触发选择
pickMedia.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly))

多选模式

Photo Picker 支持多选,设置最大选择数量即可:

// 多选模式 - 最多选 5 张
val pickMultipleMedia = registerForActivityResult(
    ActivityResultContracts.PickMultipleVisualMedia(5)
) { uris ->
    uris.forEach { uri ->
        // 处理每个 uri
        contentResolver.takePersistableUriPermission(
            uri,
            Intent.FLAG_GRANT_READ_URI_PERMISSION
        )
    }
}

pickMultipleMedia.launch(
    PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageAndVideo)
)

指定媒体类型

你可以限制用户只能选择图片、视频,或两者皆可:

// 只选图片
PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)

// 只选视频
PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.VideoOnly)

// 图片和视频
PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageAndVideo)

// 根据 MIME 类型过滤
PickVisualMediaRequest.Builder()
    .setMediaType(ActivityResultContracts.PickVisualMedia.ImageAndVideo)
    .build()
💡 兼容性:Photo Picker 通过 AndroidX 库向后兼容到 Android 4.4(API 19)。在不支持系统 Photo Picker 的设备上,会自动降级为文件选择器(ACTION_OPEN_DOCUMENT)。

方案二:请求部分访问权限 + Photo Picker

对于需要「记住用户之前选择的照片」的应用,需要同时请求部分访问权限:

// 检查是否已经有部分访问权限
fun hasPartialAccess(): Boolean {
    return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
        checkSelfPermission(READ_MEDIA_VISUAL_USER_SELECTED) == PackageManager.PERMISSION_GRANTED
    } else false
}

// 请求权限 - Android 15+ 会弹出 Photo Picker
fun requestMediaPermission() {
    when {
        Build.VERSION.SDK_INT >= 35 -> {
            // Android 15+:请求部分访问权限
            requestPermissions(
                arrayOf(
                    READ_MEDIA_VISUAL_USER_SELECTED,
                    READ_MEDIA_IMAGES,
                    READ_MEDIA_VIDEO
                ),
                REQUEST_CODE_MEDIA
            )
        }
        Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU -> {
            // Android 13-14:请求细分媒体权限
            requestPermissions(
                arrayOf(READ_MEDIA_IMAGES, READ_MEDIA_VIDEO),
                REQUEST_CODE_MEDIA
            )
        }
        else -> {
            // Android 12 及以下:请求传统存储权限
            requestPermissions(
                arrayOf(READ_EXTERNAL_STORAGE),
                REQUEST_CODE_MEDIA
            )
        }
    }
}

权限回调处理:

override fun onRequestPermissionsResult(
    requestCode: Int,
    permissions: Array<String>,
    grantResults: IntArray
) {
    super.onRequestPermissionsResult(requestCode, permissions, grantResults)

    if (requestCode == REQUEST_CODE_MEDIA) {
        if (Build.VERSION.SDK_INT >= 35) {
            // Android 15+:检查用户是否授予了完整访问权限
            val hasFullAccess = checkSelfPermission(READ_MEDIA_IMAGES) == PackageManager.PERMISSION_GRANTED
            val hasPartialAccess = checkSelfPermission(READ_MEDIA_VISUAL_USER_SELECTED) == PackageManager.PERMISSION_GRANTED

            when {
                hasFullAccess -> {
                    // 用户在设置中授权了完整相册访问
                    loadAllMedia()
                }
                hasPartialAccess -> {
                    // 用户只授权了部分照片
                    loadSelectedMedia()
                    // 提示用户可以在设置中升级为完整访问
                    showUpgradePermissionHint()
                }
                else -> {
                    // 用户拒绝
                    showPermissionDeniedUI()
                }
            }
        }
    }
}

方案三:引导用户授权完整访问(特殊场景)

某些应用(如相册管理、备份应用)确实需要访问完整相册。对于这些场景,需要引导用户到系统设置中手动授权:

fun requestFullAccess() {
    if (Build.VERSION.SDK_INT >= 35) {
        // 先请求部分权限(触发 Photo Picker)
        // 然后引导用户去设置页面升级为完整访问
        val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
            data = Uri.fromParts("package", packageName, null)
        }
        startActivity(intent)
    }
}

// 在设置页面返回后检查权限
override fun onResume() {
    super.onResume()
    if (Build.VERSION.SDK_INT >= 35) {
        val hasFullAccess = checkSelfPermission(READ_MEDIA_IMAGES) == PackageManager.PERMISSION_GRANTED
        if (hasFullAccess) {
            loadAllMedia()
        }
    }
}
⚠️ 注意:Google Play 对「请求完整相册访问」有严格审核。只有在合理场景下(如相册管理、照片备份)才允许引导用户授权完整访问。如果你的应用只需要选择照片,必须使用 Photo Picker。

五、全版本权限适配对照表

以下是完整的版本适配矩阵:

Android 版本所需权限访问方式
12 及以下 (API ≤ 32)READ_EXTERNAL_STORAGEMediaStore 全量查询
13-14 (API 33-34)READ_MEDIA_IMAGES / READ_MEDIA_VIDEOMediaStore 全量查询 + Photo Picker 可选
15+ (API 35+)READ_MEDIA_VISUAL_USER_SELECTEDPhoto Picker 为主,MediaStore 仅返回用户选择的媒体

Manifest 声明最佳实践——按版本区分:

<!-- Android 15+ -->
<uses-permission android:name="android.permission.READ_MEDIA_VISUAL_USER_SELECTED" />

<!-- Android 13-14 -->
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"
    android:maxSdkVersion="34" />
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO"
    android:maxSdkVersion="34" />

<!-- Android 12 及以下 -->
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
    android:maxSdkVersion="32" />

六、从 MediaStore 迁移实战

下面是一个完整的从 MediaStore 迁移到 Photo Picker 的实战案例。

旧代码:直接查询 MediaStore

// ❌ 旧方案:直接查询 MediaStore 获取全部图片
fun loadAllImages(): List<Uri> {
    val images = mutableListOf<Uri>()
    val projection = arrayOf(
        MediaStore.Images.Media._ID,
        MediaStore.Images.Media.DISPLAY_NAME,
        MediaStore.Images.Media.DATE_ADDED
    )
    val sortOrder = "${MediaStore.Images.Media.DATE_ADDED} DESC"

    contentResolver.query(
        MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
        projection,
        null,
        null,
        sortOrder
    )?.use { cursor ->
        val idColumn = cursor.getColumnIndexOrThrow(MediaStore.Images.Media._ID)
        while (cursor.moveToNext()) {
            val id = cursor.getLong(idColumn)
            val uri = ContentUris.withAppendedId(
                MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id
            )
            images.add(uri)
        }
    }
    return images
}

新代码:条件分支适配

// ✅ 新方案:根据 Android 版本选择不同的访问方式
fun loadImages() {
    when {
        Build.VERSION.SDK_INT >= 35 -> {
            // Android 15+:检查权限级别
            val hasFullAccess = checkSelfPermission(READ_MEDIA_IMAGES) == PERMISSION_GRANTED
            val hasPartialAccess = checkSelfPermission(READ_MEDIA_VISUAL_USER_SELECTED) == PERMISSION_GRANTED

            when {
                hasFullAccess -> loadAllImagesViaMediaStore()  // 完整访问
                hasPartialAccess -> loadSelectedImagesViaMediaStore()  // 部分访问
                else -> launchPhotoPicker()  // 无权限,弹出 Photo Picker
            }
        }
        Build.VERSION.SDK_INT >= 33 -> {
            // Android 13-14:使用 MediaStore + 可选 Photo Picker
            if (checkSelfPermission(READ_MEDIA_IMAGES) == PERMISSION_GRANTED) {
                loadAllImagesViaMediaStore()
            } else {
                launchPhotoPicker()
            }
        }
        else -> {
            // Android 12 及以下
            if (checkSelfPermission(READ_EXTERNAL_STORAGE) == PERMISSION_GRANTED) {
                loadAllImagesViaMediaStore()
            } else {
                requestPermissions(arrayOf(READ_EXTERNAL_STORAGE), RC_STORAGE)
            }
        }
    }
}

private fun launchPhotoPicker() {
    pickMedia.launch(PickVisualMediaRequest(
        ActivityResultContracts.PickVisualMedia.ImageOnly
    ))
}

// Android 15+ 部分访问时,MediaStore 只返回用户选择的图片
private fun loadSelectedImagesViaMediaStore(): List<Uri> {
    // 查询逻辑与 loadAllImagesViaMediaStore 相同
    // 但在 Android 15+ 部分访问模式下,MediaStore 自动只返回授权的图片
    return loadAllImagesViaMediaStore()
}

七、URI 持久化与生命周期

Photo Picker 返回的 URI 是临时授权的,需要注意生命周期管理。

URI 授权时效

  • Photo Picker 返回的 URI 默认授权到设备重启
  • 重启后 URI 失效,需要重新获取
  • 通过 takePersistableUriPermission() 可以获得持久授权
// 持久化 URI 授权
private fun persistUri(uri: Uri) {
    try {
        contentResolver.takePersistableUriPermission(
            uri,
            Intent.FLAG_GRANT_READ_URI_PERMISSION
        )
    } catch (e: SecurityException) {
        // 某些 URI 不支持持久化,需要保存内容到应用私有目录
        saveToPrivateStorage(uri)
    }
}

// 保存文件到应用私有目录(更可靠的方式)
private fun saveToPrivateStorage(sourceUri: Uri): Uri? {
    val fileName = "media_${System.currentTimeMillis()}.jpg"
    val outputFile = File(filesDir, "selected_media").apply { mkdirs() }
    val destFile = File(outputFile, fileName)

    contentResolver.openInputStream(sourceUri)?.use { input ->
        FileOutputStream(destFile).use { output ->
            input.copyTo(output)
        }
    }

    return Uri.fromFile(destFile)
}

检查持久化 URI 是否仍然有效

fun isUriValid(uri: Uri): Boolean {
    return try {
        contentResolver.openInputStream(uri)?.close()
        true
    } catch (e: Exception) {
        false
    }
}

// 使用前检查
fun displayImage(uri: Uri) {
    if (isUriValid(uri)) {
        binding.imageView.setImageURI(uri)
    } else {
        // URI 失效,重新获取
        launchPhotoPicker()
    }
}

八、测试与验证

适配完成后,需要在多个场景下测试:

测试矩阵

测试场景预期行为
Android 15 首次请求权限弹出 Photo Picker,不弹出传统权限对话框
用户选择部分照片后查询 MediaStore只返回用户选择的照片
用户在设置中升级为完整访问MediaStore 返回全部照片
用户撤销部分访问权限MediaStore 返回空结果,应用应引导重新授权
设备重启后访问持久化 URI持久化的 URI 仍可访问,未持久化的失效
Android 13-14 向后兼容使用 READ_MEDIA_* 权限 + 可选 Photo Picker
Android 12 及以下向后兼容使用 READ_EXTERNAL_STORAGE

使用 ADB 模拟权限状态

# 授予部分访问权限
adb shell pm grant com.example.app android.permission.READ_MEDIA_VISUAL_USER_SELECTED

# 授予完整访问权限
adb shell pm grant com.example.app android.permission.READ_MEDIA_IMAGES

# 撤销所有媒体权限
adb shell pm revoke com.example.app android.permission.READ_MEDIA_IMAGES
adb shell pm revoke com.example.app android.permission.READ_MEDIA_VIDEO
adb shell pm revoke com.example.app android.permission.READ_MEDIA_VISUAL_USER_SELECTED

九、最佳实践总结

1. 能用 Photo Picker 就用 Photo Picker

如果你的应用只是需要用户选择照片/视频(头像、上传、分享等),直接使用 Photo Picker,不需要申请任何运行时权限。这是最简单、最安全、用户体验最好的方案。

2. 避免主动请求 READ_MEDIA_IMAGES

在 Android 15 上,请求 READ_MEDIA_IMAGES 会触发系统弹窗引导用户选择照片,而不是传统的权限授权对话框。这可能会让用户困惑。更好的做法是先使用 Photo Picker,只在必要时才请求完整访问。

3. 做好降级处理

用户可能随时撤销之前授权的照片。应用需要处理 SecurityException 和空查询结果,不要假设之前能访问的 URI 永远可用。

fun queryMediaSafely(): List<Uri> {
    return try {
        loadAllImagesViaMediaStore()
    } catch (e: SecurityException) {
        // 权限被撤销,降级到 Photo Picker
        Log.w(TAG, "Media access revoked, falling back to Photo Picker")
        emptyList()
    }
}

4. 适配 Compose

如果你使用 Jetpack Compose,推荐用 rememberLauncherForActivityResult

@Composable
fun PhotoPickerScreen() {
    var selectedImageUri by remember { mutableStateOf<Uri?>(null) }

    val pickMedia = rememberLauncherForActivityResult(
        ActivityResultContracts.PickVisualMedia()
    ) { uri ->
        selectedImageUri = uri
        uri?.let {
            // 持久化 URI
            context.contentResolver.takePersistableUriPermission(
                it, Intent.FLAG_GRANT_READ_URI_PERMISSION
            )
        }
    }

    Column {
        Button(onClick = {
            pickMedia.launch(
                PickVisualMediaRequest(
                    ActivityResultContracts.PickVisualMedia.ImageOnly
                )
            )
        }) {
            Text("选择照片")
        }

        selectedImageUri?.let { uri ->
            AsyncImage(
                model = uri,
                contentDescription = "Selected photo",
                modifier = Modifier.size(200.dp)
            )
        }
    }
}

十、总结

Android 15 的媒体权限变更是 Android 隐私演进的必然结果。核心思路是:把选择权交给用户

适配要点:

  • 首选 Photo Picker:大多数应用只需要用户选择照片,直接用 Photo Picker 即可,零权限
  • 权限适配分层:Android 15 用 READ_MEDIA_VISUAL_USER_SELECTED,13-14 用 READ_MEDIA_*,12 及以下用 READ_EXTERNAL_STORAGE
  • MediaStore 查询受限:Android 15 上只返回用户授权的媒体,不要假设能查到全部
  • URI 生命周期管理:Photo Picker 返回的 URI 是临时的,重要数据要持久化或保存到私有目录
  • 做好降级处理:权限随时可能被撤销,所有媒体访问都要 try-catch

这次变更短期内确实增加了适配成本,但从用户隐私角度看是巨大的进步。越早适配,你的应用在 Android 15 上就越稳定。