前言
很多項目中都會有用戶修改頭像或者類似的功能。
該功能會訪問用戶的相冊、相機來獲取圖片,然后顯示到頁面上。
實現(xiàn)該功能還是比較簡單的,網(wǎng)上的資料也非常多,簡單查閱之后復制粘貼便能實現(xiàn),但是很多細節(jié)其實并不理解。
并且由于Android安全性的提升,包括Android6.0(API 23)的權(quán)限系統(tǒng)升級、Android7.0(API 24)的私有文件訪問限制,很多地方稍不注意就會發(fā)生崩潰。
最近再次用到了這個功能,這次打算用一篇文章來詳細記錄這個功能點所對應的知識點,并解決掉之前的很多疑問。
整個項目已經(jīng)上傳至GitHub,可下載安裝進行體驗。
打開相冊
打開手機相冊的方式有多種:
第一種:
Intent intent = new Intent();
intent.setAction(Intent.ACTION_PICK);
// 設(shè)置文件類型
intent.setType("image/*");
activity.startActivityForResult(intent, requestCode);
第二種:
Intent intent = new Intent();
intent.setAction(Intent.ACTION_GET_CONTENT);
// 設(shè)置文件類型
intent.setType("image/*");
activity.startActivityForResult(intent, requestCode);
第三種:
Intent intent = new Intent();
intent.setAction(Intent.ACTION_OPEN_DOCUMENT);
// 設(shè)置文件類型
intent.setType("image/*");
activity.startActivityForResult(intent, requestCode);
這幾種方式都可以在獲取到讀取文件權(quán)限的前提下,完美實現(xiàn)圖片選擇。
第三種ACTION_OPEN_DOCUMENT是在Android5.0(API 19)之后新添加的意圖,如果使用的話需要進行
if(Build.VERSION.SDK_INT>=Build.VERSION_CODES.KITKAT){
//TODO
}
我們這里先不介紹ACTION_OPEN_DOCUMENT。
第二種ACTION_GET_CONTENT與第一種ACTION_PICK這兩個意圖類型的作用也非常類似,都是用來獲取手機內(nèi)容,包括聯(lián)系人、相冊等。
通過intent.setType("image/*")
來指定MIME Type,讓系統(tǒng)知道要打開的應用。
這里需要注意,必須指定MIME Type,否則項目會崩潰:
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 }
根據(jù)不同的MIME Type,可以跳轉(zhuǎn)到不同的應用。
那么這兩者有什么區(qū)別呢?
ACTION_GET_CONTENT與ACTION_PICK的官方解釋在這里。
英語比較差,跟著百度翻譯看了半天還是不懂。
英語好的同學可自行食用上面的鏈接,應該不需要翻墻。
兩者的區(qū)別介紹都寫在了ACTION_GET_CONTENT,大概是在說:
如果你有一些特定的集合(由URI標識)想讓用戶選擇,使用ACTION_PICK。
如果讓用戶基于MIME Type選擇數(shù)據(jù),使用ACTION_GET_CONTENT。
在平局的情況下,建議使用ACTION_GET_CONTENT。
這個還是需要各位看官自己好好理解,我也沒能完全了解兩者的使用區(qū)別。
并且我發(fā)現(xiàn)兩者返回的Uri格式是不同的:
關(guān)于Android中Uri的介紹,可以參考這篇文章。
兩種意圖分別喚起相冊后,選擇同一張圖片的回調(diào),也就是在onActivityResult中接收:
@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;
}
}
接下來我們來看看兩個意圖類型下選擇同一張照片返回的數(shù)據(jù):
ACTION_GET_CONTENT:
content://com.android.providers.media.documents/document/image:2116
content://media/external/images/media/2116
沒有其他的東西,兩者都是返回一個Uri。
為什么不直接返回給我們圖片,而是一個Uri呢?
因為Intent傳輸有大小的限制。
所以我們需要根據(jù)Uri來獲取到文件的具體路徑。
但是我們發(fā)現(xiàn),就算是同一張照片,兩種意圖下,返回的Uri也是不一致的。
這主要是因為Uri在Android中的類型也分為很多種,比如這兩個意圖的Uri種類就不一致。
這里就不做贅述了,我們可以通過網(wǎng)上大神封裝的解析Uri的方法將它們統(tǒng)一轉(zhuǎn)化成File路徑:
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;
}
調(diào)用完成后,會發(fā)現(xiàn)不同的Uri對應的是同一個文件路徑:
/storage/emulated/0/temp/kouliang_avatar.jpg
斷點跟進該方法,會發(fā)現(xiàn)兩個Uri走的是不同的if判斷。
簡單來說,三種方法都可以使用,并且三種方法都是在onActivityResult中返回Uri,而不是圖片。
一般情況使用ACTION_GET_CONTENT的會多一些。
相機
打開相機的方式:
//指定相機意圖
Intent intent = new Intent(MediaStore.ACTION_IMAGE_CAPTURE);
//設(shè)置相片保存的地址
intent.putExtra(MediaStore.EXTRA_OUTPUT, Uri.fromFile(file));
activity.startActivityForResult(intent, requestCode);
相機圖片的獲取方式不同于相冊,相機圖片獲取需要先指定圖片的保存路徑,在拍攝成功后,我們只需直接去指定路徑獲取即可:
switch (requestCode) {
//相冊
case REQUEST_CODE_ALBUM:
Uri dataUri = data.getData();
Log.i("mengyuanuri", "相冊uri:" + dataUri.getScheme() + ":" + dataUri.getSchemeSpecificPart());
break;
//相機,注意,相機的回調(diào)中Intent為空,不要使用
case REQUEST_CODE_CAMER:
File bgPath = Constant.bgPath;
Bitmap bitmap = BitmapFactory.decodeFile(bgPath.getPath());
iv_bg.setImageBitmap(bitmap);
break;
}
非常簡單,在相機回調(diào)中去指定路徑中讀取圖片并顯示。
但是我們應該可以想到,有些手機沒有相機,也就是沒有MediaStore.ACTION_IMAGE_CAPTURE意圖對應的應用。
如果沒有對其進行判斷就會拋出ActivityNotFound的異常。
如何解決這個問題:
- try-catch,簡單粗暴;
- 通過PackageManager去查詢MediaStore.ACTION_IMAGE_CAPTURE意圖是否存在。
兩種做法都很簡單,這里展示如何用PackageManager:
/**
* 判斷某個意圖是否存在
*/
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的虛擬機中,打開相機崩潰了:
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)權(quán)限管理改版一致,如果build.gradle中的targetSdkVersion
<23,則會沿用以前的權(quán)限管理機制,無需進行權(quán)限管理改版,權(quán)限管理詳見這篇小文。
同理,這里如果你的targetSdkVersion
<24的話,則無需進行上述崩潰的適配。
但是更新一定是往更好的方向去的,還是建議各位看官及時更新,及時適配,保證targetSdkVersion
為最新SDK。
裁剪
裁剪功能是可選功能,如果想要對獲取到的圖片進行裁剪,我們可以繼續(xù)使用裁剪Intent來對圖片進行裁剪:
Intent intent = new Intent("com.android.camera.action.CROP");
//設(shè)置要裁剪的圖片Uri
intent.setDataAndType(cropBean.dataUri, "image/*");
//配置一系列裁剪參數(shù)
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);
//跳轉(zhuǎn)
activity.startActivityForResult(intent, requestCode);
裁剪參數(shù)的含義可以參考這篇文章:
附加選項 | 數(shù)據(jù)類型 | 描述 |
---|---|---|
crop | String | 發(fā)送裁剪信號 |
aspectX | int | X方向上的比例 |
aspectY | int | Y方向上的比例 |
outputX | int | 裁剪區(qū)的寬 |
outputY | int | 裁剪區(qū)的高 |
scale | boolean | 是否保留比例 |
return-data | boolean | 是否將數(shù)據(jù)保留在Bitmap中返回 |
data | Parcelable | 相應的Bitmap數(shù)據(jù) |
circleCrop | String | 圓形裁剪區(qū)域 |
output | URI | 將URI指向相應的file:// |
outputFormat | String | 圖片輸出格式 |
noFaceDetection | boolean | 是否取消人臉識別 |
每個屬性的解釋都很清晰,這里我將裁剪參數(shù)封裝為了一個Bean對象:
public class CropBean {
//要裁剪的圖片Uri
public Uri dataUri;
//裁剪寬度
public int outputX;
//裁剪高度
public int outputY;
//X方向上的比例
public int aspectX;
//Y方向上的比例
public int aspectY;
//是否保留比例
public boolean scale;
//是否將數(shù)據(jù)保存在Bitmap中返回
public boolean isReturnData;
//相應的Bitmap數(shù)據(jù)
public Parcelable returnData;
//如果不需要將圖片在Bitmap中返回,需要傳遞保存圖片的Uri
public Uri saveUri;
//圓形裁剪區(qū)域
public String circleCrop;
//圖片輸出格式,默認JPEG
public String outputFormat = Bitmap.CompressFormat.JPEG.toString();
//是否取消人臉識別
public boolean noFaceDetection;
/**
* 根據(jù)寬高計算裁剪比例
*/
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;
}
}
關(guān)于封裝對象中caculateAspect()
方法,因為aspectX與aspectY是用來設(shè)定裁剪框?qū)捀弑壤模晕疫x擇在指定完outputX與outputY(也就是裁剪圖片的寬度和高度)之后,直接根據(jù)寬高來計算裁剪框的大小。
caculateAspect()
中就是具體的計算過程。
還有幾個比較重要的參數(shù)需要提一下:
- intent.setData(Uri uri)是必須指定的,它代表著要裁剪的圖片的Uri。
- return-data參數(shù)代表是否要返回數(shù)據(jù),如果為true,則返回Bitmap對象,如果為false,則會將圖片直接保存到另一個參數(shù)output中。也就是說,當return-data為true時,output是沒有用的,直接在onActivityResult中取data當中的Bitmap即可。如果為false,則直接在onActivityResult中去之前指定到output中的地址取出圖片即可。
- 綜上一點,強烈建議設(shè)置return-data為false并且設(shè)置output,因為Intent傳輸是有大小限制的。為防止超出大小的現(xiàn)象發(fā)生,通過Uri傳輸最為安全。
總結(jié)
到此為止,獲取圖片顯示的功能已經(jīng)完成了。
整個項目已經(jīng)上傳至GitHub,簡單總結(jié)一下:
- 通過相冊獲取圖片的方式有很多,但是在onActivityResult中都是以Uri的方式傳遞的。
- 裁剪功能不是必要的,如果沒有裁剪需求可忽略。強烈建議不要將return-data設(shè)置為true,可能會超出Intent傳輸大小限制。
- 當你的targetSdkVersion>=23時,需要進行權(quán)限管理的升級,當你的targetSdkVersion>=24時,需要進行FileProvider的適配。強烈建議進行適配,提升應用的安全性。