Android獲取圖片的正確姿勢

前言

很多項目中都會有用戶修改頭像或者類似的功能。
該功能會訪問用戶的相冊、相機來獲取圖片,然后顯示到頁面上。
實現(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_CONTENTACTION_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

ACTION_PICK

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的異常。
如何解決這個問題:

  1. try-catch,簡單粗暴;
  2. 通過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()方法,因為aspectXaspectY是用來設(shè)定裁剪框?qū)捀弑壤模晕疫x擇在指定完outputXoutputY(也就是裁剪圖片的寬度和高度)之后,直接根據(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é)一下:

  1. 通過相冊獲取圖片的方式有很多,但是在onActivityResult中都是以Uri的方式傳遞的。
  2. 裁剪功能不是必要的,如果沒有裁剪需求可忽略。強烈建議不要將return-data設(shè)置為true,可能會超出Intent傳輸大小限制。
  3. 當你的targetSdkVersion>=23時,需要進行權(quán)限管理的升級,當你的targetSdkVersion>=24時,需要進行FileProvider的適配。強烈建議進行適配,提升應用的安全性。

感謝

使用系統(tǒng)裁剪

Intent傳輸大小實戰(zhàn)

相機7.0圖片選擇適配

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

推薦閱讀更多精彩內(nèi)容