Android上图片视频选择功能的最佳实践
# 前言
app里的从相册选图选视频功能
以前一般都是通过mediastore api查询所有能查到的视频和图片,然后用自定义的ui展示出来,最典型的就是微信发图的那个界面.
但是google 先是把android.permission.READ_EXTERNAL_STORAGE拆分成了android.permission.READ_MEDIA_IMAGES和android.permission.READ_MEDIA_VIDEO,接着google play又收紧了这些权限的申请,必须特定用途的app才能申请这些权限,
面向海外的app,最省事的是使用官方推荐的库:
https://developer.android.com/training/data-storage/shared/photo-picker?hl=zh-cn
为了简化照片选择器的集成,请添加 1.7.0 版或更高版本的
androidx.activity
(opens new window) 库您可以使用以下 activity 结果协定来启动照片选择器:
PickVisualMedia
(opens new window),用于选择单张图片或单个视频 (opens new window)。PickMultipleVisualMedia
(opens new window),用于选择多张图片或多个视频 (opens new window)。如果照片选择器在设备上不可用,该库会自动调用
ACTION_OPEN_DOCUMENT
(opens new window) intent 操作。搭载 Android 4.4(API 级别 19)或更高版本的设备支持此 intent。您可以通过调用isPhotoPickerAvailable()
(opens new window) 来验证照片选择器在给定设备上是否可用。
使用官方的photopicker的好处: 不需要声明任何权限
# 整体流程图
# 核心思路简述
MediaPickUtil 是一个 Android 平台的媒体文件选择工具类,核心目标是提供统一、适配多版本的文件选择接口,支持图片、视频、音频、PDF 等多种文件类型的单 / 多文件选择,并处理权限、Uri 转换、压缩等辅助功能。
- 选择器适配:优先使用 Android 13 + 的系统照片选择器(
ActivityResultContracts.PickVisualMedia
),若不支持则降级为传统 Intent 方式(ACTION_PICK
/ACTION_OPEN_DOCUMENT
等),兼顾兼容性与用户体验。 - 类型与权限处理:
- 区分文件类型(图片、视频、音频等),针对性构建选择器参数;
- 适配 Android 权限变更:API 33 + 使用细分权限(
READ_MEDIA_IMAGES
等),低版本使用READ_EXTERNAL_STORAGE
,并在获取 Uri 失败时动态申请权限。
- 结果处理:统一通过
MyCommonCallback
回调返回 Uri(或 Uri 列表),并提供 Uri 转文件路径、输入流、图片压缩等工具方法,方便后续文件操作。 - 多场景支持:提供单文件选择(如
pickImage
)、多文件选择(如pickMultiFiles
)、特定类型选择(如pickPdf
)等接口,覆盖常见文件选择场景。
# 选择单个图片/视频
示例代码:
public static void pickOneMediaCompact(String mimeType,MyCommonCallback<Uri> callback){
if(useAndroidPhotoPicker
&& ActivityResultContracts.PickVisualMedia.isPhotoPickerAvailable()
&& ActivityUtils.getTopActivity() instanceof AppCompatActivity
&& !mimeType.startsWith("audio")
&& !mimeType.startsWith("application")){
StartActivityUtil.startActivity(ActivityUtils.getTopActivity(), TransActivity.class,null,false,
new TheActivityListener<TransActivity>(){
@Override
protected void onActivityCreated(@NonNull TransActivity activity, @Nullable Bundle savedInstanceState) {
super.onActivityCreated(activity, savedInstanceState);
ActivityResultLauncher<PickVisualMediaRequest> pickMedia =
((AppCompatActivity)ActivityUtils.getTopActivity()).registerForActivityResult(
new ActivityResultContracts.PickVisualMedia(), uri -> {
// Callback is invoked after the user selects a media item or closes the
// photo picker.
activity.finish();
if (uri != null) {
callback.onSuccess(uri);
LogUtils.d("PhotoPicker", "Selected URI: " + uri);
} else {
LogUtils.d("PhotoPicker", "No media selected");
callback.onError("canceled");
}
});
ActivityResultContracts.PickVisualMedia.VisualMediaType type = null;
if("video/*".equals(mimeType)){
type = ActivityResultContracts.PickVisualMedia.VideoOnly.INSTANCE;
}else if("*/*".equals(mimeType)){
type = ActivityResultContracts.PickVisualMedia.ImageAndVideo.INSTANCE;
}else if("image/*".equals(mimeType)){
type = ActivityResultContracts.PickVisualMedia.ImageOnly.INSTANCE;
}else {
type = new ActivityResultContracts.PickVisualMedia.SingleMimeType(mimeType);
}
//Can only use lower 16 bits for requestCode
pickMedia.launch(new PickVisualMediaRequest.Builder()
.setMediaType(type)
.build());
}
});
}else {
pickOneMedia(callback,mimeType);
}
}
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
36
37
38
39
40
41
42
43
44
45
46
47
48
# 选择多个:
public static void pickMultiMediaCompact(int count,String mimeType,MyCommonCallback<List<Uri>> callback){
if(useAndroidPhotoPicker
//&& ActivityResultContracts.PickVisualMedia.isPhotoPickerAvailable()
&& ActivityUtils.getTopActivity() instanceof AppCompatActivity
&& !mimeType.startsWith("audio")
&& !mimeType.startsWith("application")){
StartActivityUtil.startActivity(ActivityUtils.getTopActivity(), UtilsTransActivity4MainProcess.class,null,false,
new TheActivityListener<UtilsTransActivity4MainProcess>(){
@Override
protected void onActivityCreated(@NonNull UtilsTransActivity4MainProcess activity,
@Nullable Bundle savedInstanceState) {
super.onActivityCreated(activity, savedInstanceState);
//垃圾谷歌程序员,弄出屎一样的activity result api
//LifecycleOwner com.hss.utilsenhance.MainActivity@2787510 is attempting to register
// while current state is RESUMED. LifecycleOwners must call register before they are STARTED
ActivityResultLauncher<PickVisualMediaRequest> pickMedia =
((AppCompatActivity)activity).registerForActivityResult(
new ActivityResultContracts.PickMultipleVisualMedia(count), uri -> {
// Callback is invoked after the user selects a media item or closes the
// photo picker.
//TMD还没有回调....
activity.finish();
if (uri != null && !uri.isEmpty()) {
callback.onSuccess(uri);
LogUtils.d("PhotoPicker", "Selected URI: " + uri);
} else {
LogUtils.d("PhotoPicker", "No media selected");
callback.onError("canceled");
}
});
ActivityResultContracts.PickVisualMedia.VisualMediaType type = null;
if("video/*".equals(mimeType)){
type = ActivityResultContracts.PickVisualMedia.VideoOnly.INSTANCE;
}else if("*/*".equals(mimeType)){
type = ActivityResultContracts.PickVisualMedia.ImageAndVideo.INSTANCE;
}else if("image/*".equals(mimeType)){
type = ActivityResultContracts.PickVisualMedia.ImageOnly.INSTANCE;
}else {
type = new ActivityResultContracts.PickVisualMedia.SingleMimeType(mimeType);
}
//Can only use lower 16 bits for requestCode
pickMedia.launch(new PickVisualMediaRequest.Builder()
.setMediaType(type)
.build());
}
});
}else {
pickMulti(callback,false,"*/*");
}
}
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
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
# Intent.ACTION_PICK
在以前官方的photo picker不成熟时,或者它在手机上不可用时,则使用Intent.ACTION_PICK来进行单张图片/视频的选择,使用Intent.ACTION_OPEN_DOCUMENT来支持多张的选择,这两个一般也无需权限声明.
注意,使用Intent.ACTION_OPEN_DOCUMENT时,必须使用intent.setDataAndType(uri, "/"),才能调出交互最友好的那个界面
/**
* 虽然高版本Android不再需要READ_EXTERNAL_STORAGE就可以查看选择的图片,但用户不知道啊,能多拿权限就多拿
* Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q ? Manifest.permission.INTERNET : Manifest.permission.READ_EXTERNAL_STORAGE
*
* 经由系统或第三方选择器的,通通不需要权限,因为他们会自动赋予uri以read权限!!!
* @param callback
* @param mimeTypes
*/
public static void pickOneMedia(MyCommonCallback<Uri> callback, String... mimeTypes) {
String mimeType = MimeTypeUtil.buildMimeTypeWithDot(mimeTypes);
LogUtils.i("request mimetype: "+mimeType);
if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU ){
startIntent(mimeType, callback);
}else {
startIntent(mimeType, callback);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/**
* @param mimeTypes
* @param callback string可能为文件路径,可能为fileprovider形式的uri,
* 如果是content://协议的uri,那么会去查询真正的path
*/
private static void startIntent(String mimeTypes, MyCommonCallback<Uri> callback) {
//https://www.cnblogs.com/widgetbox/p/7503894.html
Intent intent = new Intent();
//intent.setType("video/*;image/*");//同时选择视频和图片
intent.setType(mimeTypes);//
//intent.setAction(Intent.ACTION_GET_CONTENT);
//打开方式有两种action,1.ACTION_PICK;2.ACTION_GET_CONTENT 区分大意为:
// ACTION_PICK 为打开特定数据一个列表来供用户挑选,其中数据为现有的数据。而 ACTION_GET_CONTENT 区别在于它允许用户创建一个之前并不存在的数据。
intent.setAction(Intent.ACTION_PICK);
//startActivityForResult(Intent.createChooser(intent,"选择图像..."), PICK_IMAGE_REQUEST);
//FragmentManager: Activity result delivered for unknown Fragment
PackageManager manager = Utils.getApp().getPackageManager();
if (manager.queryIntentActivities(intent, 0).size() <= 0) {
intent = new Intent(Intent.ACTION_OPEN_DOCUMENT);
intent.addCategory(Intent.CATEGORY_OPENABLE);
//intent.setType(mimeTypes);
String type = "*/*";
final Uri uri = Uri.parse(Environment.getExternalStorageDirectory().getPath() + File.separator);
//Log.d(TAG, "Selected type " + type);
intent.setDataAndType(uri, "*/*");
//这里的type为application/pdf时,无法显示选项,必须为*/*
//也不能加上putExtra(Intent.EXTRA_MIME_TYPES的限定,否则也没有选项
//String[] mimeTypes2 = {"application/pdf"};
//intent.putExtra(Intent.EXTRA_MIME_TYPES,mimeTypes2);
//Intent { act=android.intent.action.GET_CONTENT cat=[android.intent.category.OPENABLE] typ=image/png }
}
if (manager.queryIntentActivities(intent, 0).size() <= 0) {
intent = new Intent(Intent.ACTION_GET_CONTENT);
intent.addCategory(Intent.CATEGORY_OPENABLE);
intent.setType(mimeTypes);
// String[] mimeTypes = {doc, docx, pdf, image1,image2};
// intent.putExtra(Intent.EXTRA_MIME_TYPES,mimeTypes);
// intent.setType("*/*");
}
LogUtils.d(intent);
StartActivityUtil.startActivity(ActivityUtils.getTopActivity(),null, intent,true, new TheActivityListener<Activity>() {
@Override
public void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) {
LogUtils.i(data);
if (resultCode == RESULT_CANCELED) {
callback.onError("-1", "cancel", null);
return;
}
if (data == null || data.getData() == null) {
callback.onError("-2", "data is null", null);
return;
}
//理论上: 由系统或者第三方选择器的,通通不需要权限,因为他们会自动赋予uri以read权限!!!
//但最好是自己读一次,如何读取不到数据,就再申请一次存储权限/读图片视频权限
Uri uri = data.getData();
try {
InputStream inputStream = transUriToInputStream(uri.toString());
if(inputStream !=null){
callback.onSuccess(uri);
inputStream.close();
}else {
LogUtils.w("inputStream from uri is null,try to get permission",uri,mimeTypes);
requestPermissionOnce(uri, mimeTypes, callback);
}
} catch (Exception e) {
LogUtils.w(e,uri);
requestPermissionOnce(uri, mimeTypes, callback);
}
//后续操作:
//ContentUriUtil.getRealPath(uri);
//ContentUriUtil.getInfos(uri);
//ContentUriUtil.queryMediaStore(uri);
//content://com.android.providers.media.documents/document/video%3A114026
}
@Override
public void onActivityNotFound(Throwable e) {
callback.onError("", "ActivityNotFound", e);
}
});
}
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
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
private static void requestPermissionOnce(Uri uri, String mimeTypes, MyCommonCallback<Uri> callback) {
String[] permission = null;
if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU ){
//api 33以上,拆分
if(TextUtils.isEmpty(mimeTypes)){
}else if(mimeTypes.contains("image")){
permission = new String[]{Manifest.permission.READ_MEDIA_IMAGES};
}else if(mimeTypes.contains("video")){
permission = new String[]{Manifest.permission.READ_MEDIA_VIDEO};
}else if(mimeTypes.contains("audio")){
permission = new String[]{Manifest.permission.READ_MEDIA_AUDIO};
//TMD 设置页面根本没有对应的权限
}
if(mimeTypes.contains("image") && mimeTypes.contains("video")){
permission = new String[]{Manifest.permission.READ_MEDIA_VIDEO, Manifest.permission.READ_MEDIA_IMAGES};
}
}else {
permission = new String[]{Manifest.permission.READ_EXTERNAL_STORAGE};
}
MyPermissions.requestByMostEffort(false, true, new PermissionUtils.FullCallback() {
@Override
public void onGranted(@NonNull List<String> granted) {
callback.onSuccess(uri);
}
@Override
public void onDenied(@NonNull List<String> deniedForever, @NonNull List<String> denied) {
callback.onError("-3", "permission denied", null);
}
},permission);
}
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
# 一些后处理方法:
/**
* 将文件拷贝到app私有的cache目录,把path返回, 用于flutter,RN插件中,另一端的代码可直接操作文件路径
* @param pathOrUriString
* @return
*/
public static @Nullable File transUriToInnerFilePath(String pathOrUriString) {
if(pathOrUriString.startsWith("content://")){
Uri uri = Uri.parse(pathOrUriString);
File dir = new File(Utils.getApp().getExternalCacheDir(),"pickCache");
if(!dir.exists()){
dir.mkdirs();
}
Map<String, Object> infos = ContentUriUtil.getInfos(uri);
String name = System.currentTimeMillis()+".jpg";
if(infos !=null && infos.containsKey("_display_name")){
name = infos.get("_display_name")+"";
}
if(TextUtils.isEmpty(name) || "null".equals(name)){
name = System.currentTimeMillis()+".jpg";
}
File file = new File(dir,name);
try {
boolean success = FileIOUtils.writeFileFromIS(file, Utils.getApp().getContentResolver().openInputStream(uri));
if(success && file.exists() && file.length() >0){
return file;
}else {
return null;
}
} catch (Throwable e) {
LogUtils.w(e);
return null;
}
}else {
if(pathOrUriString.startsWith("file://")){
Uri uri = Uri.parse(pathOrUriString);
pathOrUriString = uri.getPath();
}
File file = new File(pathOrUriString);
if(file.exists() && file.length() >0 && file.canRead()){
return file;
}
return null;
}
}
public static @Nullable InputStream transUriToInputStream(String pathOrUriString) throws Exception{
if(pathOrUriString.startsWith("content://")){
Uri uri = Uri.parse(pathOrUriString);
return Utils.getApp().getContentResolver().openInputStream(uri);
}else {
if(pathOrUriString.startsWith("file://")){
Uri uri = Uri.parse(pathOrUriString);
pathOrUriString = uri.getPath();
}
File file = new File(pathOrUriString);
if(file.exists() && file.length() >0 && file.canRead()){
return new FileInputStream(file);
}
return null;
}
}
public static Uri doCompress(Uri uri) {
if("content".equals(uri.getScheme())){
Map<String, Object> infos = ContentUriUtil.getInfos(uri);
if(infos !=null ){
String type = infos.get(MediaStore.MediaColumns.MIME_TYPE)+"";
if(type.startsWith("image")){
File file = MediaPickUtil.transUriToInnerFilePath(uri.toString());
if(file !=null){
file = compressWithNoResize2(file.getAbsolutePath());
if(file.exists() && file.length() >0){
uri = Uri.fromFile(file);
}
}
}else if(type.startsWith("video")){
//todo 压缩视频
}
}
}else if("file".equals(uri.getScheme())){
String path = uri.getPath();
File file = new File(path);
String name = file.getName();
if(name.contains(".")){
String suffix = name.substring(name.lastIndexOf(".")+1);
String minetype = MimeTypeMap.getSingleton().getMimeTypeFromExtension(suffix);
if(minetype !=null && minetype.startsWith("image")){
file = compressWithNoResize2(file.getAbsolutePath());
if(file.exists() && file.length() >0){
uri = Uri.fromFile(file);
}
}else if(minetype !=null && minetype.startsWith("video")){
//todo 压缩视频
}
}
}
return uri;
}
public static File compressWithNoResize2(String absolutePath) {
File dir = new File(Utils.getApp().getExternalCacheDir().getAbsolutePath(),"web-img-compressed-cache");
if(!dir.exists()){
dir.mkdirs();
}
return Luban.with(Utils.getApp())
.ignoreBy(80)
.targetQuality(85)
.keepExif(false)
.noResize(true)
.setTargetDir(dir.getAbsolutePath())
.get(absolutePath);
}
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
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
# 源代码链接
https://github.com/hss01248/utilcodeEnhance2/blob/combine-image-loader/media/src/main/java/com/hss01248/media/pick/MediaPickUtil.java