关于安卓的新Photo Picker

从 Android 13 开始,Android 系统内置了一个功能更为强大的图片选择器,可以让应用更加灵活地访问设备中的媒体资源,且无需拥有查看设备上所有媒体文件的权限。此外,虽然官方将其命名为图片选择器,但实际上也支持选择设备中的视频文件。

引用 Google 官方的描述:

照片选择器提供了一个可浏览、可搜索的界面,其中按日期从最近到最早的顺序向用户呈现其媒体库中的文件。此工具为用户提供了一种安全的内置图片和视频选择方式,让其无需向应用授予对整个媒体库的访问权限。

如果您允许系统配置照片选择器,则该工具适用于满足以下条件的设备(Android Go 设备除外):

  • 搭载 Android 11(API 级别 30)或更高版本
  • 通过 Google 系统更新接收对模块化系统组件的更改

此外,如果您允许系统配置照片选择器,该工具会自动更新,并随着时间推移为应用的用户提供扩展的功能,而无需对代码进行任何更改。

如果你想了解更多,可以参考下面谷歌的官方文档,这里就不多做解释了。

照片选择器 | Android 开发者 | Android Developers (google.cn)

Photo Picker不方便的地方

那么如果你已经初步开始使用了Photo Picker,你应该已经知道了可以通过限制MIME种类的方式,来限制其供用户选择的文件种类。

如果你不知道MIME是什么,简单来说它是一个互联网的邮件类型标准,用来对文件类型做划分,下面是谷歌官方的参照列表,可以看到MIME本身就是一个字符串。

MimeTypes | Android Developers (google.cn)

那么如果要限制MIME种类来限制文件类型,就像下面的代码,这里我使用Java,我比较熟悉Java。

1
2
3
4
5
6
// Launch the photo picker and let the user choose only images/videos of a
// specific MIME type, such as GIFs.
String mimeType = "image/gif";
pickMedia.launch(new PickVisualMediaRequest.Builder()
.setMediaType(new PickVisualMedia.SingleMimeType(mimeType))
.build());

你可能已经发现,不仅看起来只能写一个mimeType,而且对应方法也叫做SingleMimeType,明显也是只能添加一个mimeType字符串,查看源码发现也没有方法支持字符串数组。

那么如果有如下这种场景,你想让用户上传头像,但是你不想在用户选择的时候出现动图,例如GIF类型,但是你又想同时筛选出JPEG类型和PNG类型的图片供用户选择,这时你需要填写两个mimeType了,那么该怎么办呢?

可能有人跟我一样看到上面的联级调用,认为谷歌不会做这种奇怪的鸡肋操作,所以在读源码之前先尝试了如下的写法:

1
2
3
4
5
6
7
8
// Launch the photo picker and let the user choose only images/videos of a
// specific MIME type, such as GIFs.
String mimeTypeA = "image/jpeg";
String mimeTypeB = "image/png";
pickMedia.launch(new PickVisualMediaRequest.Builder()
.setMediaType(new PickVisualMedia.SingleMimeType(mimeTypeA))
.setMediaType(new PickVisualMedia.SingleMimeType(mimeTypeB))
.build());

那么,显而易见,看看命名就能知道,这里只是set了两次mediaType,换句话说,你用png的筛选覆盖了jpeg的筛选,这自然没法达到我们的要求,那么该怎么实现上面的需求呢?

我们先看一下关于PickVisualMedia类如何createIntent的一部分代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
@CallSuper
override fun createIntent(context: Context, input: PickVisualMediaRequest): Intent {
// Check if Photo Picker is available on the device
return if (isSystemPickerAvailable()) {
Intent(MediaStore.ACTION_PICK_IMAGES).apply {
type = getVisualMimeType(input.mediaType)
}
} else if (isSystemFallbackPickerAvailable(context)) {
val fallbackPicker = checkNotNull(getSystemFallbackPicker(context)).activityInfo
Intent(ACTION_SYSTEM_FALLBACK_PICK_IMAGES).apply {
setClassName(fallbackPicker.applicationInfo.packageName, fallbackPicker.name)
type = getVisualMimeType(input.mediaType)
}
} else if (isGmsPickerAvailable(context)) {
val gmsPicker = checkNotNull(getGmsPicker(context)).activityInfo
Intent(GMS_ACTION_PICK_IMAGES).apply {
setClassName(gmsPicker.applicationInfo.packageName, gmsPicker.name)
type = getVisualMimeType(input.mediaType)
}
} else {
// For older devices running KitKat and higher and devices running Android 12
// and 13 without the SDK extension that includes the Photo Picker, rely on the
// ACTION_OPEN_DOCUMENT intent
Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
type = getVisualMimeType(input.mediaType)

if (type == null) {
// ACTION_OPEN_DOCUMENT requires to set this parameter when launching the
// intent with multiple mime types
type = "*/*"
putExtra(Intent.EXTRA_MIME_TYPES, arrayOf("image/*", "video/*"))
}
}
}
}

稍微读了一下源码我发现,安卓的开发者在系统的Picker可用的情况下,用了MediaStore类的一个Intent,直接把我们设定的mediaType丢给它来进行筛选,下面则是一些低版本适配代码,但读到最后关于太旧系统,以及系统不含PhotoPicker的情况下,使用ACTION_OPEN_DOCUMENT这个Intent兼容图片选择功能的时候,发现在下面type为空的时候,有一句putExtra(Intent.EXTRA_MIME_TYPES, arrayOf("image/*", "video/*")),给到我启发,这里将type置为*/*之后,往Intent中放了一组字符串数组,那么我们是不是也可以依葫芦画瓢,重载对应的PickVisualMedia类的createIntent方法,来实现我们所需要的功能呢。

重载PickVisualMedia类的createIntent

PickVisualMedia类有不少createIntent,这里我们要处理上面对应的和PickVisualMediaRequest类相关的createIntent,虽然源码使用Kotlin,但由于对于Kotlin的不熟悉,我还是使用Java做重载。

1
2
3
4
5
6
7
8
@NonNull
@Override
public Intent createIntent(@NonNull Context context, @NonNull PickVisualMediaRequest input) {
Intent intent = super.createIntent(context, input);
String[] mimeTypes = {"image/jpeg", "image/png"};
intent.putExtra(Intent.EXTRA_MIME_TYPES, mimeTypes);
return intent;
}

这里我们先调用父类的createIntent,拿到中间的Intent,然后可以有样学样,调用Intent类的putExtra,加上我们的mimeType字符串数组,再把修改好的Intent传递给下一个步骤,这也是很常用的一种重载包装方法,在父类的处理层次上,加上一点我们自己的处理。

那么还有一点需要注意,安卓官方关于常用Intent的文档里面提到:

EXTRA_MIME_TYPES

An array of MIME types corresponding to the types of files your app is requesting. When you use this extra, you must set the primary MIME type in setType() to "*/*".

简单来说就是,使用这个附加Intent的时候,需要把主要的MIME种类置为*/*,这也解释了上面官方代码的写法。

那也很简单,在SetMediaType的时候调整就行了。

最后给出两段修改之后的调用业务代码,首先这里记得把PickVisualMedia()改成我们重载之后的子类PickImage()

1
2
3
4
5
6
7
pickMedia = registerForActivityResult(new PickImage(), uri -> {
if (uri != null) {
Log.d("PhotoPicker", "Selected URI: " + uri);
} else {
Log.d("PhotoPicker", "No media selected");
}
});

然后对应把launch方法里面的MediaType改成*/*即可。

1
2
3
pickMedia.launch(new PickVisualMediaRequest.Builder()
.setMediaType(new ActivityResultContracts.PickVisualMedia.SingleMimeType("*/*"))
.build());

最后调试运行一下,应该就能看到我们所需要的jpeg和png并存的效果了。

后记

最后还是感慨搞安卓开发果然还是应该转Kotlin,特别是在做官方代码自定义的时候。安卓开发Java和Kotlin并存的情况应该还会不可避免地持续很长一段时间,毕竟安卓还有很多第三方Java库,而谷歌的官方库则是全面转向Kotlin,开发项目的时候自然很难去完全倒向任何一边。

不过现在流行跨平台的时代还在搞安卓,恐怕是真没啥前途了吧(笑