技术经验谈 技术经验谈
首页
  • 最佳实践

    • 抓包
    • 数据库操作
  • ui

    • 《JavaScript教程》
    • 《JavaScript高级程序设计》
    • 《ES6 教程》
    • 《Vue》
    • 《React》
    • 《TypeScript 从零实现 axios》
    • 《Git》
    • TypeScript
    • JS设计模式总结
  • 总纲
  • 整体开发框架
  • 技术文档
  • GitHub技巧
  • Nodejs
  • 博客搭建
  • 学习
  • 面试
  • 心情杂货
  • 实用技巧
  • 友情链接
关于
收藏
  • 分类
  • 标签
  • 归档
GitHub (opens new window)

hss01248

一号线程序员
首页
  • 最佳实践

    • 抓包
    • 数据库操作
  • ui

    • 《JavaScript教程》
    • 《JavaScript高级程序设计》
    • 《ES6 教程》
    • 《Vue》
    • 《React》
    • 《TypeScript 从零实现 axios》
    • 《Git》
    • TypeScript
    • JS设计模式总结
  • 总纲
  • 整体开发框架
  • 技术文档
  • GitHub技巧
  • Nodejs
  • 博客搭建
  • 学习
  • 面试
  • 心情杂货
  • 实用技巧
  • 友情链接
关于
收藏
  • 分类
  • 标签
  • 归档
GitHub (opens new window)
  • 最佳实践

    • Android数据库操作最佳实践
    • 基于蒲公英平台的app发布,更新,反馈功能的实现
    • testRss
    • Android保存图片或视频到相册的最佳实践
    • Android上图片视频选择功能的最佳实践
      • 选择单个图片/视频
      • 选择多个:
      • Intent.ACTION_PICK
      • 一些后处理方法:
    • 一行代码完成startActivityForResult
  • ui

  • 优化

  • aop

  • apm

  • 架构

  • webview

  • rxjava

  • activity-fragment-view的回调和日志
  • Android加密相关
  • Android命令行操作
  • app后台任务
  • kotlin
  • kotlin漫谈
  • kotlin语言导论
  • sentry上传mapping.txt文件
  • so放于远程动态加载方案
  • states
  • Xposed模块开发
  • 一个关于manifest合并的猥琐操作
  • 玩坏android存储
  • 获取本app的安装来源信息
  • Android
  • 最佳实践
hss01248
2025-08-21
目录

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的好处: 不需要声明任何权限

# 整体流程图

image-20250821151306199

image-20250821151445294

# 核心思路简述

MediaPickUtil 是一个 Android 平台的媒体文件选择工具类,核心目标是提供统一、适配多版本的文件选择接口,支持图片、视频、音频、PDF 等多种文件类型的单 / 多文件选择,并处理权限、Uri 转换、压缩等辅助功能。

  1. 选择器适配:优先使用 Android 13 + 的系统照片选择器(ActivityResultContracts.PickVisualMedia),若不支持则降级为传统 Intent 方式(ACTION_PICK/ACTION_OPEN_DOCUMENT等),兼顾兼容性与用户体验。
  2. 类型与权限处理:
    • 区分文件类型(图片、视频、音频等),针对性构建选择器参数;
    • 适配 Android 权限变更:API 33 + 使用细分权限(READ_MEDIA_IMAGES等),低版本使用READ_EXTERNAL_STORAGE,并在获取 Uri 失败时动态申请权限。
  3. 结果处理:统一通过MyCommonCallback回调返回 Uri(或 Uri 列表),并提供 Uri 转文件路径、输入流、图片压缩等工具方法,方便后续文件操作。
  4. 多场景支持:提供单文件选择(如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);
        }
    }
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
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,"*/*");
        }
    }
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
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);
        }
    }
1
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);
            }
        });
    }
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
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);
    }
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

# 一些后处理方法:


    /**
     * 将文件拷贝到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);
    }
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
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

编辑 (opens new window)
上次更新: 2025/08/21, 15:19:48
Android保存图片或视频到相册的最佳实践
一行代码完成startActivityForResult

← Android保存图片或视频到相册的最佳实践 一行代码完成startActivityForResult→

最近更新
01
一行代码完成startActivityForResult
09-01
02
Android保存图片或视频到相册的最佳实践
08-21
03
截图后的自动压缩工具
12-27
更多文章>
Theme by Vdoing | Copyright © 2020-2025 | 粤ICP备20041795号
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式