背景

最近两年适配 Android 新版本的小伙伴应该都发现,Android 对于系统读写权限的管理又收窄了。

Android 13(Tiramisu, API 33)上,废弃了 READ_EXTERNAL_STORAGE 权限,新增 READ_MEDIA_IMAGESREAD_MEDIA_VIDEOREAD_MEDIA_AUDIO 三个权限分别用于控制图片、视频、音频的访问。

Android 14(UpsideDownCake, API 34)上则新增了 READ_MEDIA_VISUAL_USER_SELECTED 权限,用户可以选择性地让应用访问部分图片或视频。

由于系统选择器过于丑陋,相信大多数需要用到图片选择功能的 App,都会自行实现或者接入三方库来内置图片选择器功能,而这个行为在 Android 14 上则会遇到新的问题,当用户仅授权部分图片或视频时,整个操作路径会非常长,即用户首先需要选择授权哪些照片给应用,然后再从应用内置的图片选择器中选择照片。

Android 14 授权部分照片

另一方面,Play Store 对于权限的获取政策也在将来收紧

Timeline information

We anticipate the following timeline for the rollout of the Photo and Video Permissions policy. Note that this is subject to change; updates will be posted in this article.

  • October 2023: We announced the new Photos and Video Permissions policy.
  • Mid 2024: Apps with one-time or infrequent use of photos requested to use a system photo picker and remove READ_MEDIA_IMAGES and READ_MEDIA_VIDEO permissions from their app manifest.
  • Early 2025: Only apps with broad access core functionality can use READ_MEDIA_IMAGES and READ_MEDIA_VIDEO permissions.

时间表信息

我们预计将按照以下时间表发布“照片和视频权限”政策。请注意,此时间表可能会有变动;届时我们会在本文中发布更新。

  • 2023 年 10 月: 我们公布了新的“照片和视频权限”政策。
  • 2024 年年中: 如果应用只用一次照片,或者很少用到照片,则必须使用系统照片选择器,并从应用清单中移除 READ_MEDIA_IMAGESREAD_MEDIA_VIDEO 权限。
  • 2025 年初: 只有核心功能需要获取广泛访问权限的应用才能使用 READ_MEDIA_IMAGESREAD_MEDIA_VIDEO 权限。

Photo Picker

好消息是,从 Android 13(Tiramisu, API 33)开始,官方提供了系统级图片选择器 Photo Picker,样式和体验都非常丝滑。最重要的是,无需申请权限,只需几行代码即可轻松接入。

Photo Picker

androidx.activity 1.6.0+

首先把 androidx.activity 升级到最新版本:

dependencies {
    implementation("androidx.activity:activity:1.8.2")
}

由于项目原因无法升级的,在下文会给出解决方案。

接下来只需使用 Activity Result API 调用即可:

class MainActivity : AppCompatActivity() {
    ...
    private val pickMedia = registerForActivityResult(ActivityResultContracts.PickVisualMedia()) {
        if (it != null) {
            // 选择图片的 Uri
        } else {
            // 未选择图片
        }
    }
    private fun pickImage() = pickMedia.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly))
    private fun pickVideo() = pickMedia.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.VideoOnly))
    private fun pickImageOrVideo() = pickMedia.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageAndVideo))
    private fun pickByMime(mimeType: String) = pickMedia.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.SingleMimeType(mimeType)))
}

通过向 PickVisualMediaRequest() 中传入不同的类型来构建不同的请求对象。是的,你没看错,我们调用的 PickVisualMediaRequest() 实际上是一个方法,它会在内部帮我们构建同名类的对象:

fun PickVisualMediaRequest(
    mediaType: VisualMediaType = ImageAndVideo
) = PickVisualMediaRequest.Builder().setMediaType(mediaType).build()

class PickVisualMediaRequest internal constructor() {
    ...
    class Builder {
        ...
    }
}

VisualMediaType 是一个密封接口,由上面我们示例的几种类型实现,分别对应仅选择图片、仅选择视频、选择图片或视频和仅选择特定的 MIME 类型

效果如下:

Photo Picker 单选

如果想设置多选,Photo Picker 同样支持:

class MainActivity : AppCompatActivity() {
    ...
    private val pickMultipleMedia = registerForActivityResult(ActivityResultContracts.PickMultipleVisualMedia(5)) {
        if (it.isNotEmpty()) {
            // 选择的图片 Uri 集合
        } else {
            // 未选择图片
        }
    }
    private fun pickImages() = pickMultipleMedia.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly))
    private fun pickVideos() = pickMultipleMedia.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.VideoOnly))
    private fun pickImageOrVideos() = pickMultipleMedia.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageAndVideo))
    private fun pickByMimes(mimeType: String) = pickMultipleMedia.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.SingleMimeType(mimeType)))
}

只需把 ActivityResultContract 更换为多选的 PickMultipleVisualMedia 即可,同时可以传入一个整型参数控制选择的文件数量上限。效果如下:

Photo Picker 多选

是不是非常简单?

前文提到,Photo Picker 从 Android 13(Tiramisu, API 33)开始支持,这个说法其实并不准确,官方文档中提到,支持 Google Play Service 的设备也是可以更新使用的,在不支持 Photo Picker 的低版本机型中,该库会自动调用 ACTION_OPEN_DOCUMENT 打开系统资源管理器进行选择:

资源管理器

同时,如果你使用多选模式,资源管理器会忽略所设置的数量上限。

官方也提供了一个方法用于判断 Photo Picker 在给定设备上是否可用:

class ActivityResultContracts private constructor() {
    ...
    @RequiresApi(19)
    open class PickVisualMedia : ActivityResultContract<PickVisualMediaRequest, Uri?>() {
        companion object {
            ...
            @SuppressLint("ClassVerificationFailure", "NewApi")
            @JvmStatic
            fun isPhotoPickerAvailable(context: Context): Boolean {
                return isSystemPickerAvailable() || isSystemFallbackPickerAvailable(context) ||
                        isGmsPickerAvailable(context)
            }
        }
        ...
    }
}

androidx.activity 1.5.1-

接下来说说 androidx.activity 在低版本中如何处理,低版本中存在的问题主要是 PickVisualMediaPickMultipleVisualMedia 两个 ActivityResultContract 的缺失,同时连带 isPhotoPickerAvailable() 这个判断方法也无法使用。

我们采用的策略就是当系统版本不低于 Android 13 时,使用 Photo Picker,否则使用系统资源管理器。

实现的方法有很多,我这里简单写两个 ActivityResultContracts 示例,你可以根据实际情况进行扩展:

object PicResultContract {

    class PickImage : ActivityResultContract<Void?, Uri?>() {
        override fun createIntent(context: Context, input: Void?): Intent {
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
                return Intent(MediaStore.ACTION_PICK_IMAGES).setType("image/*")
            }
            return Intent(Intent.ACTION_OPEN_DOCUMENT).setType("image/*")
        }

        override fun parseResult(resultCode: Int, intent: Intent?): Uri? {
            return intent.takeIf { resultCode == Activity.RESULT_OK }?.data
        }
    }

    class PickImages : ActivityResultContract<Int, List<Uri>>() {
        override fun createIntent(context: Context, input: Int): Intent {
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
                return Intent(MediaStore.ACTION_PICK_IMAGES)
                    .putExtra(MediaStore.EXTRA_PICK_IMAGES_MAX, input)
                    .setType("image/*")
            }
            return Intent(Intent.ACTION_OPEN_DOCUMENT)
                .putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true)
                .setType("image/*")
        }

        override fun parseResult(resultCode: Int, intent: Intent?): List<Uri> {
            val i = intent.takeIf { resultCode == Activity.RESULT_OK }
            val data = i?.data
            if (data != null) {
                return listOf(data)
            }
            val clipData = i?.clipData
            if (clipData != null && clipData.itemCount > 0) {
                return (0 until clipData.itemCount).map {
                    clipData.getItemAt(it).uri
                }
            }
            return emptyList()
        }
    }
}

示例中的多选仍然参考了高版本中的策略,当使用资源管理器时,所设置的文件数量上线也会被忽略。

使用方法与原来类似(仅做示例使用所以没有完全参照原来的写法复刻,自行改造即可):

class MainActivity : AppCompatActivity() {
    ...
    private val pickImage = registerForActivityResult(PicResultContract.PickImage()) {
        if (it != null) {
            // 选择图片的 Uri
        } else {
            // 未选择图片
        }
    }
    private val pickImages = registerForActivityResult(PicResultContract.PickImages()) {
        if (it.isNotEmpty()) {
            // 选择的图片 Uri 集合
        } else {
            // 未选择图片
        }
    }
    private fun pickImage() = pickImage.launch(null)
    private fun pickImages() = pickImages.launch(5)
}

后记

前阵子 @郭霖 发了一篇文章『Android 14新特性,选择性照片和视频访问授权』,我也在评论区探讨了交互的策略,在支持的设备(或 Android 13+)中使用 Photo Picker,否则使用应用内实现的图片选择器,或许是当下最好的方案。

互动

参考内容