Android获取图片的正确姿势

前言

很多项目中都会有用户修改头像或者类似的功能。
该功能会访问用户的相册、相机来获取图片,然后显示到页面上。
实现该功能还是比较简单的,网上的资料也非常多,简单查阅之后复制粘贴便能实现,但是很多细节其实并不理解。
并且由于Android安全性的提升,包括Android6.0(API 23)的权限系统升级、Android7.0(API 24)的私有文件访问限制,很多地方稍不注意就会发生崩溃。
最近再次用到了这个功能,这次打算用一篇文章来详细记录这个功能点所对应的知识点,并解决掉之前的很多疑问。

打开相册

打开手机相册的方式有多种:
第一种:

1
2
3
4
5
Intent intent = new Intent();
intent.setAction(Intent.ACTION_PICK);
// 设置文件类型
intent.setType("image/*");
activity.startActivityForResult(intent, requestCode);

第二种:

1
2
3
4
5
Intent intent = new Intent();
intent.setAction(Intent.ACTION_GET_CONTENT);
// 设置文件类型
intent.setType("image/*");
activity.startActivityForResult(intent, requestCode);

第三种:

1
2
3
4
5
Intent intent = new Intent();
intent.setAction(Intent.ACTION_OPEN_DOCUMENT);
// 设置文件类型
intent.setType("image/*");
activity.startActivityForResult(intent, requestCode);

这几种方式都可以在获取到读取文件权限的前提下,完美实现图片选择。
第三种ACTION_OPEN_DOCUMENT是在Android5.0(API 19)之后新添加的意图,如果使用的话需要进行

1
2
3
if(Build.VERSION.SDK_INT>=Build.VERSION_CODES.KITKAT){
//TODO
}

我们这里先不介绍ACTION_OPEN_DOCUMENT
第二种ACTION_GET_CONTENT与第一种ACTION_PICK这两个意图类型的作用也非常类似,都是用来获取手机内容,包括联系人、相册等。
通过intent.setType("image/*")来指定MIME Type,让系统知道要打开的应用。
这里需要注意,必须指定MIME Type,否则项目会崩溃:

1
2
android.content.ActivityNotFoundException: No Activity found to handle Intent { act=android.intent.action.GET_CONTENT }
android.content.ActivityNotFoundException: No Activity found to handle Intent { act=android.intent.action.ACTION_PICK }

根据不同的MIME Type,可以跳转到不同的应用。
那么这两者有什么区别呢?
ACTION_GET_CONTENTACTION_PICK的官方解释在这里。
英语比较差,跟着百度翻译看了半天还是不懂。

英语好的同学可自行食用上面的链接,应该不需要翻墙。
两者的区别介绍都写在了ACTION_GET_CONTENT,大概是在说:
如果你有一些特定的集合(由URI标识)想让用户选择,使用ACTION_PICK
如果让用户基于MIME Type选择数据,使用ACTION_GET_CONTENT
在平局的情况下,建议使用ACTION_GET_CONTENT
这个还是需要各位看官自己好好理解,我也没能完全了解两者的使用区别。
并且我发现两者返回的Uri格式是不同的:
关于Android中Uri的介绍,可以参考这篇文章

两种意图分别唤起相册后,选择同一张图片的回调,也就是在onActivityResult中接收:

1
2
3
4
5
6
7
8
9
10
11
12
13
@Override
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
super.onActivityResult(requestCode, resultCode, data);
if (resultCode != RESULT_OK) {
return;
}
switch (requestCode) {
case REQUEST_CODE_ALBUM://相册
Uri dataUri = data.getData();
Log.i("mengyuanuri","uri:"+dataUri.getScheme()+":"+dataUri.getSchemeSpecificPart());
break;
}
}

接下来我们来看看两个意图类型下选择同一张照片返回的数据:
ACTION_GET_CONTENT

1
content://com.android.providers.media.documents/document/image:2116

ACTION_PICK

1
content://media/external/images/media/2116

没有其他的东西,两者都是返回一个Uri。
为什么不直接返回给我们图片,而是一个Uri呢?
因为Intent传输有大小的限制。
所以我们需要根据Uri来获取到文件的具体路径。
但是我们发现,就算是同一张照片,两种意图下,返回的Uri也是不一致的。
这主要是因为Uri在Android中的类型也分为很多种,比如这两个意图的Uri种类就不一致。
这里就不做赘述了,我们可以通过网上大神封装的解析Uri的方法将它们统一转化成File路径:

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
public static String getPath( final Uri uri) {
// DocumentProvider
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT && DocumentsContract.isDocumentUri(App.context, uri)) {
// ExternalStorageProvider
if (isExternalStorageDocument(uri)) {
final String docId = DocumentsContract.getDocumentId(uri);
final String[] split = docId.split(":");
final String type = split[0];

if ("primary".equalsIgnoreCase(type)) {
return Environment.getExternalStorageDirectory() + "/" + split[1];
}
// TODO handle non-primary volumes
}
// DownloadsProvider
else if (isDownloadsDocument(uri)) {

final String id = DocumentsContract.getDocumentId(uri);
final Uri contentUri = ContentUris.withAppendedId(
Uri.parse("content://downloads/public_downloads"), Long.valueOf(id));

return getDataColumn(App.context, contentUri, null, null);
}
// MediaProvider
else if (isMediaDocument(uri)) {
final String docId = DocumentsContract.getDocumentId(uri);
final String[] split = docId.split(":");
final String type = split[0];

Uri contentUri = null;
if ("image".equals(type)) {
contentUri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI;
} else if ("video".equals(type)) {
contentUri = MediaStore.Video.Media.EXTERNAL_CONTENT_URI;
} else if ("audio".equals(type)) {
contentUri = MediaStore.Audio.Media.EXTERNAL_CONTENT_URI;
}

final String selection = "_id=?";
final String[] selectionArgs = new String[]{
split[1]
};

return getDataColumn(App.context, contentUri, selection, selectionArgs);
}
}
// MediaStore (and general)
else if ("content".equalsIgnoreCase(uri.getScheme())) {
return getDataColumn(App.context, uri, null, null);
}
// File
else if ("file".equalsIgnoreCase(uri.getScheme())) {
return uri.getPath();
}

return null;
}

调用完成后,会发现不同的Uri对应的是同一个文件路径:

1
/storage/emulated/0/temp/kouliang_avatar.jpg

断点跟进该方法,会发现两个Uri走的是不同的if判断。

简单来说,三种方法都可以使用,并且三种方法都是在onActivityResult中返回Uri,而不是图片。
一般情况使用ACTION_GET_CONTENT的会多一些。

相机

打开相机的方式:

1
2
3
4
5
//指定相机意图
Intent intent = new Intent(MediaStore.ACTION_IMAGE_CAPTURE);
//设置相片保存的地址
intent.putExtra(MediaStore.EXTRA_OUTPUT, Uri.fromFile(file));
activity.startActivityForResult(intent, requestCode);

相机图片的获取方式不同于相册,相机图片获取需要先指定图片的保存路径,在拍摄成功后,我们只需直接去指定路径获取即可:

1
2
3
4
5
6
7
8
9
10
11
12
13
switch (requestCode) {
//相册
case REQUEST_CODE_ALBUM:
Uri dataUri = data.getData();
Log.i("mengyuanuri", "相册uri:" + dataUri.getScheme() + ":" + dataUri.getSchemeSpecificPart());
break;
//相机,注意,相机的回调中Intent为空,不要使用
case REQUEST_CODE_CAMER:
File bgPath = Constant.bgPath;
Bitmap bitmap = BitmapFactory.decodeFile(bgPath.getPath());
iv_bg.setImageBitmap(bitmap);
break;
}

非常简单,在相机回调中去指定路径中读取图片并显示。
但是我们应该可以想到,有些手机没有相机,也就是没有MediaStore.ACTION_IMAGE_CAPTURE意图对应的应用。
如果没有对其进行判断就会抛出ActivityNotFound的异常。
如何解决这个问题:

  1. try-catch,简单粗暴;
  2. 通过PackageManager去查询MediaStore.ACTION_IMAGE_CAPTURE意图是否存在。

两种做法都很简单,这里展示如何用PackageManager:

1
2
3
4
5
6
7
8
9
/**
* 判断某个意图是否存在
*/
public static boolean isHaveCame(String intentName) {
PackageManager packageManager = App.context.getPackageManager();
Intent intent = new Intent(intentName);
List<ResolveInfo> list = packageManager.queryIntentActivities(intent, PackageManager.MATCH_DEFAULT_ONLY);
return list.size() > 0;
}

接着我们运行,十分成功。
但是在7.1的虚拟机中,打开相机崩溃了:

1
2
3
4
5
6
7
8
9
10
11
12
13
android.os.FileUriExposedException: file:///storage/emulated/0/photo_bg.jpg exposed beyond app through ClipData.Item.getUri(
at android.os.StrictMode.onFileUriExposed(StrictMode.java:1799)
at android.net.Uri.checkFileUriExposed(Uri.java:2346)
at android.content.ClipData.prepareToLeaveProcess(ClipData.java:845)
at android.content.Intent.prepareToLeaveProcess(Intent.java:8941)
at android.content.Intent.prepareToLeaveProcess(Intent.java:8926)
at android.app.Instrumentation.execStartActivity(Instrumentation.java:1517)
at android.app.Activity.startActivityForResult(Activity.java:4225)
at android.support.v4.app.BaseFragmentActivityJB.startActivityForResult(BaseFragmentActivityJB.java:54)
at android.support.v4.app.FragmentActivity.startActivityForResult(FragmentActivity.java:75)
at android.app.Activity.startActivityForResult(Activity.java:4183)
at android.support.v4.app.FragmentActivity.startActivityForResult(FragmentActivity.java:708)
at com.my.photoget.utils.AppUtils.startCamer(AppUtils.java:37)

崩溃的主要原因是因为在7.0(API 24)中对文件读取进行了安全性的提升,这篇文章详细介绍了解决方案
这里提一下,这和当初Android6.0(API 23)权限管理改版一致,如果build.gradle中的targetSdkVersion<23,则会沿用以前的权限管理机制,无需进行权限管理改版,权限管理详见这篇小文
同理,这里如果你的targetSdkVersion<24的话,则无需进行上述崩溃的适配。
但是更新一定是往更好的方向去的,还是建议各位看官及时更新,及时适配,保证targetSdkVersion为最新SDK。

裁剪

裁剪功能是可选功能,如果想要对获取到的图片进行裁剪,我们可以继续使用裁剪Intent来对图片进行裁剪:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
Intent intent = new Intent("com.android.camera.action.CROP");
//设置要裁剪的图片Uri
intent.setDataAndType(cropBean.dataUri, "image/*");
//配置一系列裁剪参数
intent.putExtra("outputX", cropBean.outputX);
intent.putExtra("outputY", cropBean.outputY);
intent.putExtra("scale", cropBean.scale);
intent.putExtra("aspectX", cropBean.aspectX);
intent.putExtra("aspectY", cropBean.aspectY);
intent.putExtra("outputFormat", cropBean.outputFormat);
intent.putExtra("return-data", cropBean.isReturnData);
intent.putExtra("output", cropBean.saveUri);
//跳转
activity.startActivityForResult(intent, requestCode);

裁剪参数的含义可以参考这篇文章

附加选项 数据类型 描述
crop String 发送裁剪信号
aspectX int X方向上的比例
aspectY int Y方向上的比例
outputX int 裁剪区的宽
outputY int 裁剪区的高
scale boolean 是否保留比例
return-data boolean 是否将数据保留在Bitmap中返回
data Parcelable 相应的Bitmap数据
circleCrop String 圆形裁剪区域
output URI 将URI指向相应的file://
outputFormat String 图片输出格式
noFaceDetection boolean 是否取消人脸识别

每个属性的解释都很清晰,这里我将裁剪参数封装为了一个Bean对象:

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
public class CropBean {

//要裁剪的图片Uri
public Uri dataUri;

//裁剪宽度
public int outputX;
//裁剪高度
public int outputY;

//X方向上的比例
public int aspectX;
//Y方向上的比例
public int aspectY;

//是否保留比例
public boolean scale;

//是否将数据保存在Bitmap中返回
public boolean isReturnData;
//相应的Bitmap数据
public Parcelable returnData;

//如果不需要将图片在Bitmap中返回,需要传递保存图片的Uri
public Uri saveUri;

//圆形裁剪区域
public String circleCrop;

//图片输出格式,默认JPEG
public String outputFormat = Bitmap.CompressFormat.JPEG.toString();

//是否取消人脸识别
public boolean noFaceDetection;

/**
* 根据宽高计算裁剪比例
*/
public void caculateAspect() {

scale = true;

if (outputX == outputY) {
aspectX = 1;
aspectY = 1;
return;
}
float proportion = (float) outputX / (float) outputY;

aspectX = (int) (proportion * 100);
aspectY = 100;
}
}

关于封装对象中caculateAspect()方法,因为aspectXaspectY是用来设定裁剪框宽高比例的,所以我选择在指定完outputXoutputY(也就是裁剪图片的宽度和高度)之后,直接根据宽高来计算裁剪框的大小。
caculateAspect()中就是具体的计算过程。
还有几个比较重要的参数需要提一下:

  • intent.setData(Uri uri)是必须指定的,它代表着要裁剪的图片的Uri。
  • return-data参数代表是否要返回数据,如果为true,则返回Bitmap对象,如果为false,则会将图片直接保存到另一个参数output中。也就是说,当return-data为true时,output是没有用的,直接在onActivityResult中取data当中的Bitmap即可。如果为false,则直接在onActivityResult中去之前指定到output中的地址取出图片即可。
  • 综上一点,强烈建议设置return-data为false并且设置output因为Intent传输是有大小限制的。为防止超出大小的现象发生,通过Uri传输最为安全。

总结

到此为止,获取图片显示的功能已经完成了。
整个项目已经上传至GitHub,简单总结一下:

  1. 通过相册获取图片的方式有很多,但是在onActivityResult中都是以Uri的方式传递的。
  2. 裁剪功能不是必要的,如果没有裁剪需求可忽略。强烈建议不要将return-data设置为true,可能会超出Intent传输大小限制。
  3. 当你的targetSdkVersion>=23时,需要进行权限管理的升级,当你的targetSdkVersion>=24时,需要进行FileProvider的适配。强烈建议进行适配,提升应用的安全性。

感谢

使用系统裁剪
Intent传输大小实战
相机7.0图片选择适配