1. 程式人生 > >理解Android影象處理-拍照、單/多圖選擇器及影象優化

理解Android影象處理-拍照、單/多圖選擇器及影象優化

如以上DEMO截圖所示效果,我們對於這種類似的功能肯定不算陌生,因為這可以說是實際開發中一類非常常見的功能需求了。而關於它們的實現,其實主要涉及到的知識面應該就是 Android當中的影象處理了。簡單來說就比如:影象獲取(例如常見的設定頭像(獲取單張圖片);釋出動態/朋友圈(獲取多張圖片))、影象顯示以及影象優化等等。所以理解和掌握關於這方面的原理和相關技術、手段等肯定對我們是非常有幫助的。所以在這裡儘量逐層推進的來整理一下相關的知識,及總結過程中可能會遇到的一些坑及解決方法,算是做一個簡單的回顧和歸納。

圖片獲取

從某種意義上來說,通常如果我們把一個所謂的APP還原一下本質,可以發現其實其主體內容就是由一系列的文字和影象資訊混搭起來的一個數據集合的呈現,所以基本上每個應用都離不開對於影象的使用。那麼,既然我們的應用內將要涉及到影象,那麼首先應該考慮到的就是如何去獲取影象。粗泛一點來說,在應用內對於影象的主要的獲取方式 大體可以分為兩種:

  • 第一種情況:其它空間(例如網路) → 應用記憶體 → 裝置儲存空間
    (舉例來說,假設現有一個新聞瀏覽的應用客戶端,我們從伺服器得到了最新的新聞資料,某條新聞詳情內含有圖片內容。顯然目前我們拿到的僅僅是圖片對應的URL,它自身只是一串文字資料,所以如果我們想要在自身應用內獲取到其對應的圖片內容,自然就需要通過網路進行下載獲取,然後寫入記憶體進行顯示。最後,如果有儲存(快取)影象的需求,那麼圖片內容則還會再由記憶體寫入裝置的儲存空間)。
  • 第二種情況:裝置儲存空間 → 應用記憶體 → 其它空間(例如網路)
    (同樣,我們也可能會使用類似微博,朋友圈等功能。在這些Social性質的應用裡,我們常常會有一些圖片想要分享給他人,那麼前提則是需要首先在本地的儲存空間獲取到對應的圖片,從而才能上傳到伺服器。這時圖片的行為路徑則通常是與我們說到的第一類情況是相反的。)

顯然,第一種方式的本質其實通常就是基於HTTP協議的網路通訊,本文中我們的主要關注點不在這裡,故不加贅述。這裡主要探討一下,對於第二種情況來說,我們通常有哪些方式或者途徑 可以在自己的應用內獲取到想要的圖片資料。

獲取單張圖片

好了,不再廢話,我們就以一個簡單的需求作為切入。假設現在想要實現一個常見的功能“設定使用者頭像”,分析一下我們應該如何去做。首先,我想我們可以明確的一點就是,設定頭像這種功能肯定就會涉及到圖片資料的獲取。但這時的獲取行為有一個特點就在於:本次我們需要獲取的影象的數量將是固定的,就為1張。所以,實際上我們需要實現的其實就是 對於單張圖片的獲取。那麼,接著分析的重點就在於,如果以一臺手機來說,我們想要得到一張圖片的途徑有哪些呢?簡單思考一下我們便能想到,基本上概括來就是兩種途徑:

  • 通過相機(攝影應用)來拍攝並獲取到一張全新的影象。
  • 通過在本地相簿(儲存)中查詢並獲取一張已存在的心儀影象。

相機拍攝

OK,有了之前簡單的的分析作為基礎,我們正式來看一些更加實際的東西。首先分析一下,對於“拍攝獲取圖片”這種需求究竟應當如何實現?其實簡單歸納,可以發現實際問題就是:想要在自身應用內通過拍攝獲取照片,但是自身應用內並不存在支援拍攝的元件。

那麼舉個例子,這就好比說:我們想要和一個使用英語的人進行交流,但是我自己又不會英語。這時我們的解決辦法其實通常就是兩種:要麼自己設法掌握英語;要麼找一個會說英語的人充當中間人的角色。那麼迴歸到我們這裡的功能需求,其實同樣也就可以有兩種選擇:

  • 系統已存在的支援拍攝影象的程式。
  • 自己編寫一個支援拍攝影象的程式。

那麼,且不提編寫相機應用本身就不是件容易的事情。而即使你完全具備這個能力,而對應於我們這裡本身的需求來說,也有一種“殺雞用牛刀”,“高射炮打蚊子的”的感覺。所以,顯然我們最簡單的方法就是通過系統現在已存在的相機應用去拍攝並獲取圖片。那麼,試圖在自身應用啟動其它應用的元件,如果我們沒有明確的目標資訊,顯然我們最容易想到的就是通過隱式的Intent去尋求那些能夠響應“拍攝影象”的應用,從而來實現我們的需求了。那麼什麼樣的Intent可以開啟能夠拍攝影象的程式呢?很簡單:

    private void takePicture() {
        // Action : android.media.action.IMAGE_CAPTURE
        Intent intent  = new Intent(MediaStore.ACTION_IMAGE_CAPTURE);
        startActivity(intent);
    }

沒錯,其實我們只是建立了一個Action為”android.media.action.IMAGE_CAPTURE”的Intent,就已經能夠讓我們開啟那些能夠拍攝影象的相機應用程式了。究其原因,我們來看看系統原始碼中對於該Action的一段註釋說明:

Standard Intent action that can be sent to have the camera application capture an image and return it。

標準的操作意圖,可以傳送給相機應用程式捕獲一個影象並返回它。

我們注意到,其實註釋已經告訴我們,通過此Intent我們可以通過某個相機應用程式捕獲並返回一個影象,是不是完美符合我們的需求呢。但我們之前的程式碼還需要完善,因為此時它的意義僅僅是去開啟一個相機程式進行拍照而已,此時我們還無法獲取到返回的影象。由此則不難想到,我們肯定應該選擇通過startActivityForResult而非startActivity去啟動intent:

    private void takePicture() {
        Intent intent  = new Intent(MediaStore.ACTION_IMAGE_CAPTURE);
        startActivityForResult(intent,0x001);
    }

OK,既然我們已經改為通過startActivityForResult啟動程式,那麼對於返回的影象自然就是在onActivityResult方法中進行處理了。那麼,現在要考慮的問題自然就是:在這裡的返回結果中,我們應該如何去解析出本次拍攝到的影象呢?很簡單:

    @Override
    protected void onActivityResult(int requestCode, int resultCode, Intent data) {
        Bundle extras = data.getExtras(); 
        Bitmap bitmap = (Bitmap) extras.get("data");
        Log.d(TAG+"==>",bitmap.getWidth()+"//"+bitmap.getHeight());
        super.onActivityResult(requestCode, resultCode, data);
    }

現在執行程式,啟動某個相機應用去拍攝一張照片,會發現得到類似的Log列印資訊:

由此我們便成功獲取到了影象,但是也可以發現通過這種方式獲取到的影象的寬高畫素是很低的,如這裡就僅為240和135。並且,我們可以發現此時我們除了獲取到了一個bitmap物件之外,對於其它的資訊都無從得知。那麼,有沒有其它的方式解析返回的影象呢?當然是有的:

    @Override
    protected void onActivityResult(int requestCode, int resultCode, Intent data) {
        Log.d(TAG+"==>",data.getData().toString());

        Cursor cursor = getContentResolver().query(data.getData(),null,null,null,null);
        cursor.moveToFirst();
        String path = cursor.getString(cursor.getColumnIndex(MediaStore.Images.Media.DATA));
        Log.d(TAG+"==>",path);

        Bitmap bitmap = BitmapFactory.decodeFile(path);
        Log.d(TAG+"==>",bitmap.getWidth()+"//"+bitmap.getHeight());

    }

此時我們重新編譯執行程式,發現得到如下的輸出結果:

這裡寫圖片描述

也就是說,我們可以試圖通過呼叫返回的intent物件的getData方法去獲取一個URI。在上面的截圖中,我們可以看到獲取到的該URI 其使用的協議是“conent://”。這就代表著:其實我們利用該URI最終就可以通過對應的內容提供者解析出該URI所代表的影象檔案的檔案路徑,從而獲取到該影象。最後,我們發現本次解析獲取到的影象其寬高畫素為3920*2204,比之前一種方式獲取的圖片畫素要遠遠高出許多。而實際上,這才是拍攝的影象的原始真實畫素。

然而,照成這種差異的原因是什麼呢?我們暫且不說這個。而讓人注意的另一個點在於:不難發現對於此時我們拍攝的圖片 其最終的儲存路徑是無法由我們掌控的,而通常是由此次負責拍攝照片的相機應用程式來決定。舉例來說,假設我們呼叫的是系統自帶的相機來進行拍攝,那麼如果拍攝的檔案會被寫入到儲存空間,則最後可以發現該影象被儲存的位置通常就是系統相機對應的影象資料夾。

但顯然很多時候,我們會希望能夠獨立管理屬於我們自身應用中的各種檔案及資料,所以通常我們會在手機的儲存空間中建立自己應用的”專屬路徑”。那麼我們如何才能讓拍攝的圖片被存放在屬於我們自己的應用的路徑下面呢?這時應該怎麼做呢,同樣很簡單:

    private void takePicture() {
        File image = new File(mkAppImagesDir(),"test.jpg");
        Intent intent  = new Intent(MediaStore.ACTION_IMAGE_CAPTURE);
        // MediaStore.EXTRA_OUTPUT : "output"
        intent.putExtra(MediaStore.EXTRA_OUTPUT, Uri.fromFile(image));
        startActivityForResult(intent,0x001);
    }

    private File mkAppImagesDir(){
        String path = Environment.getExternalStorageDirectory() + "/" + getPackageName() + "/" + "Images";

        File file = new File(path);

        if(!file.exists())
            file.mkdirs();

        return file;
    }

此時我們重新執行程式,就會發現拍攝的照片被存放在了我們指定的路徑下面。也就是說,其實我們要做的很簡單,就是通過在intent的extra中指定“output”就可以指定影象的輸出路徑。同樣的,我們再看看原始碼中對於該Extra的說明:

The caller may pass an extra EXTRA_OUTPUT to control where this image will be written.If the EXTRA_OUTPUT is not present, then a small sized image is returned as a Bitmap object in the extra field. This is useful for applications that only need a small image.If the EXTRA_OUTPUT is present, then the full-sized image will be written to the Uri value of EXTRA_OUTPUT

通過註釋說明,首先我們可以明白為什麼之前通過第一種形式獲取的影象的畫素很小;另外也可以理解對於ACTION_IMAGE_CAPTURE原本的設計思想。我們可以簡單歸納為:

  • 如果沒有提供EXTRA_OUTPUT,那麼返回的intent中會以 在extra中攜帶一個small-size的bitmap的形式返回影象。
  • 而當我們提供了EXTRA_OUTPUT時,則會以full-sized(即原圖)的形式將拍攝的影象檔案寫入到我們Uri指定的路徑當中。

可能遇到的各種坑

如果僅僅是像我們以上談到的東西,那麼通過相機應用程式拍攝獲取照片的需求實現起來顯然是不難的,但其實真正的情況沒有看上去那麼輕鬆。我們知道開源是Android最大的優勢之一,但與此同時帶來的一個麻煩就是各種煩人的適配工作。

因為我們是通過隱式Intent的方式去啟動相機應用程式,那麼:首先且不提使用者的裝置上可能會同時存在多個可以響應該Intent的相機程式,誰也不知道這些應用對於intent的響應處理方式究竟是否相同。而即使都同樣選擇通過系統自帶的相機程式去拍攝照片,也會因為各種機型的不同,版本的不同而導致響應處理方式不同。所以,這裡我們就來總結一下,在這裡我們可能會遇到的坑:

  • 通過從extra中讀取Bitmap獲取影象導致空指標異常
    (導致這種異常的原因並不難理解,之前通過對於註釋的說明,可以知道當我們提供了EXTRA_OUTPUT的時候,採用的響應方式是將full-size的影象寫入到指定路徑下,但同時要記住的是:按照其設計思想,此時就不會返回small-size的bitmap縮圖了。所以在這種情況下,就會導致空指標異常。值得注意的就是,在有的機型上這又是行的通的,例如我前兩年用過的Sony-L36H其響應的方式就是無論是否設定了EXTRA_OUTPUT,都會返回small-size的bitmap,所以則不會導致異常。做這個說明是因為如果你是一個剛接觸Android,剛接觸這類需求的開發的朋友,如果恰好使用了這種方式,則千萬不要因為恰好在某個機型上發現它能完美執行,就認為它是沒問題的。就如同原始碼中所描述的,這種方式最適合的場景是:只是需要拍攝得到一張size很小的影象,並且不需要它寫入到儲存空間當中。)

  • 通過從getData讀取URI獲取影象導致空指標異常
    (導致這種異常的原因的可能性更多一點,首先前面說到了:當沒有提供EXTRA_OUTPUT的時候,會直接返回一個small-size的bitmap物件。同樣,需要明白的,按照本身的設計思想,這時拍攝的圖片自身則並不會被寫入到手機儲存空間。那麼,既然根本沒有進行過儲存,自然無法提供其對應的URI,從而自然將導致空指標異常。但是!同理,這裡仍然又可能存在不同的處理方式,例如有的機型的相機即使沒有提供EXTRA_OUTPUT,它仍然會將拍攝的檔案進行儲存,就像我之前的截圖裡體現的一樣,雖然我沒有設定EXTRA_OUTPUT,但通過系統相機拍攝的照片仍然被寫入到了系統相簿的檔案路徑下,所以這個時候我仍然能成功拿到返回對應的URI。但是呢,選擇這種響應方式的機型對另一種情況仍然又可能存在不同的處理方式,那就是反之當提供了EXTRA_OUTPUT的時候,有的機型會將檔案寫入到對應的路徑後,返回正確的URI;有的則雖然會正確寫入儲存,但卻不會返回URI,所以這個時候又可能導致空指標異常)

所以,其實看似簡單的一個拍照獲取影象的功能,其實可能遇到的坑也是不少的。那麼我們應該如何避免這些可能出現的異常呢?本質上肯定是加強解析程式碼的健壯性判斷;而解析思路上我們可以選擇的一種方式則是:預設通過讀取Uri的方式獲取影象,如果獲取Uri為空,我們再通過讀取bitmap物件的方式獲取。如果兩者都為空,則代表本次獲取影象的行為失敗了。其體現在程式碼上就大概類似於:

    @Override
    protected void onActivityResult(int requestCode, int resultCode, Intent data) {
        if (resultCode == RESULT_OK) {
            Bitmap bitmap = null;

            Uri imageUri = data.getData();
            if (imageUri != null) {
                Cursor cursor = getContentResolver().query(data.getData(), null, null, null, null);
                if (cursor != null)
                    if (cursor.moveToFirst()) {
                        String path = cursor.getString(cursor.getColumnIndex(MediaStore.Images.Media.DATA));
                        bitmap = BitmapFactory.decodeFile(path);
                    }
            } else {
                Bundle extras = data.getExtras();
                bitmap = (Bitmap) extras.get("data");
            }

            if (bitmap != null) {
                // 成功獲取
            }
        }
    }

但如果是使用了EXTRA_OUTPUT的情況,我們就有更好的處理方案了,因為此時我們還多了一種選擇:

    private String mCurrentPath;
    private void takePicture() {
        File image = new File(mkAppImagesDir(), "test.jpg");
        mCurrentPath = image.getAbsolutePath();
        ...
    }

    @Override
    protected void onActivityResult(int requestCode, int resultCode, Intent data) {
        Bitmap bitmap = BitmapFactory.decodeFile(mCurrentPath);
        super.onActivityResult(requestCode, resultCode, data);
    }

可以看到:因為此時可以明確得知本次拍攝的影象的路徑,所以我們在onActivityResult中則可直接使用該path,這樣一來我們不再做Uri的解析;二來前面我們說到有的機型,當我們自己指定了EXTRA_OUTPUT的時候雖然會將檔案寫入到對應路徑,但卻不會返回對應的URI,所以這樣做還能避免空指標。

但這裡其實仍然還有值得我們注意的地方,那就是當我們成功啟動某個相機應用程式並拍下影象後,我們到指定的路徑下也發現圖片已經被成功儲存。但開啟系統相簿,卻發現找不到我們剛剛拍攝的圖片;而重新重新整理或者重啟手機後,發現圖片則出現在了相簿當中。這是為什麼呢?其實是因為,雖然我們拍攝並儲存了新的影象,但並沒有通知系統媒體這個動作。所以,在拍照完成後,別忘記傳送一條通知媒體掃描器對我們新拍的影象進行掃描:

    public static void informMediaScanner(Context context, Uri uri) {
        Intent localIntent = new Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE, uri);
        context.sendBroadcast(localIntent);
    }

好吧,如果我們以為到這裡就已經說完了常見的因呼叫相機程式拍照可能遇到的坑,那我們就年輕了。事實上還有一種很可能會遇到的問題:那就是我們會發現在有的機型上,將拍攝好的影象讀取到記憶體中進行顯示過後,發現顯示的圖片的方向是不正確的。這是因為這些機型的系統相機,其拍攝出來的照片是帶有旋轉角度的。所以,其實對於這些圖片,將其讀取進記憶體過後,還要獲取其旋轉角度,將bitmap物件進行對應角度的旋轉過後,才能夠正確顯示。

    /**
     * 獲取圖片的旋轉角度
     *
     * @param path 圖片路徑
     * @return 旋轉角度
     */
    public static int getBitmapDegree(String path) {
        int degree = 0;
        try {
            // 從指定路徑下讀取圖片,並獲取其EXIF資訊
            ExifInterface exifInterface = new ExifInterface(path);
            // 獲取圖片的旋轉資訊
            int orientation = exifInterface.getAttributeInt(ExifInterface.TAG_ORIENTATION,
                    ExifInterface.ORIENTATION_NORMAL);
            switch (orientation) {
                case ExifInterface.ORIENTATION_ROTATE_90:
                    degree = 90;
                    break;
                case ExifInterface.ORIENTATION_ROTATE_180:
                    degree = 180;
                    break;
                case ExifInterface.ORIENTATION_ROTATE_270:
                    degree = 270;
                    break;
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
        return degree;
    }


    /**
     * 旋轉Bitmap物件
     *
     * @param bm     Bitmap物件
     * @param degree 旋轉角度
     * @return 旋轉後的Bitmap物件
     */
    public static Bitmap rotateBitmapByDegree(Bitmap bm, int degree) {
        if (degree == 0)
            return bm;

        Bitmap returnBm = null;

        // 根據旋轉角度,生成旋轉矩陣
        Matrix matrix = new Matrix();
        matrix.postRotate(degree);
        try {
            // 將原始圖片按照旋轉矩陣進行旋轉,並得到新的Bitmap物件
            returnBm = Bitmap.createBitmap(bm, 0, 0, bm.getWidth(), bm.getHeight(), matrix, true);
        } catch (OutOfMemoryError e) {
            e.printStackTrace();
        }
        if (returnBm == null) {
            returnBm = bm;
        }
        if (bm != returnBm) {
            bm.recycle();
        }
        return returnBm;
    }

如上述程式碼隨時,通過getBitmapDegree方法我們可以獲取到對應路徑下的圖片檔案的旋轉角度,當旋轉角度不為0的時候,我們就應該將對應的bitmap物件通過rotateBitmapByDegree方法旋轉對應的角度後,再進行顯示。

注:在6.0以後,比如使用相機以及讀取/寫入儲存空間都需要實現執行時許可權;同時在7.0以後,拍照時為MediaStore.EXTRA_OUTPUT指定的URI如果是代表檔案真實路徑的URI,則需要使用FileProvider。所以實際開發中我們還要記得這些版本適配的工作,但是因為這不是本文關注的重點,所以我們就不加以整理了。

獲取已儲存圖片

OK,對於呼叫相機應用拍照的總結就到這裡,更多的東西還是得我們自己實際使用到的時候能夠更好的理解。接下來,我們來看看如何獲取裝置上已儲存的圖片。實際上對應於拍攝獲取影象來說,從裝置上已儲存的圖片中進行獲取可能是一種更為常用的途徑。因為它的優勢在於:

  • 相對於拍攝獲取的單一途徑,這裡的圖片 其來源更加廣泛;
  • 即使是拍攝的圖片,可能使用者也更願意通過現今各種炫酷的美圖軟體進行美化,重新儲存後再使用。而非直接使用拍攝的原圖。

那麼,既然是獲取裝置中已經儲存的圖片,顯然最容易想到的方法就是對手機儲存空間中的所有路徑進行遞迴遍歷,獲取到所有的影象檔案。但顯然這絕不是一種聰明的做法,並且效率低下。與之前呼叫相機程式的道理相同,這裡我們仍然可以選擇通過隱式的Intent來實現我們的需求。而對於獲取圖片,我們通常有兩種方式去構建Intent物件,首先來看第一種:

    private void getImages() {
        // Action : android.intent.action.GET_CONTENT
        Intent intent = new Intent(Intent.ACTION_GET_CONTENT);
        intent.setType("image/*");
        startActivityForResult(intent, 0x002);
    }

這裡我們首先將Action指定為了“android.intent.action.GET_CONTENT”,簡單來說它的意義就是允許使用者選擇指定型別的資料並返回。那麼既然我們注意到了是指定型別的資料,所以緊接著,我們就通過setType指定了資料的MIME-Type是影象型別。除此之外,通過以下的另一種方式也能實現相同的功能:

    private void getImage(){
        // Action : android.intent.action.PICK
        Intent intent = new Intent(Intent.ACTION_PICK);
        intent.setDataAndType(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, "image/*");
        startActivityForResult(intent, 0x002);
    }

OK,這時我們多半會有一個疑問就是關於這兩種方式之間的區別。看下看原始碼中對於ACTION_GET_CONTENT的註釋中的一段描述:

This is different than {@link #ACTION_PICK} in that here we just say what kind of data is desired, not a URI of existing data from which the user can pick.

也就是說,從字面上來理解,可以簡單總結為:這兩者雖然都可以允許使用者選擇指定型別的資料,但不同在於:ACTION_GET_CONTENT只需要我們告訴它需要什麼型別(MIME-TYPE)的資料就行了;而ACTION_PICK則可以通過提供 已存在的可選擇資料 的URI來獲取相應資料。

而事實上,對於我們這裡選擇圖片的需求來說,其實它們最大的不同之處就在於:在onActivityResult當中對於返回的Uri的解析工作。為什麼這麼說呢?因為通常能夠響應ACTION_GET_CONTENT的應用會更廣泛,比如其不僅僅能被那些用於瀏覽圖片的類似於相簿的應用程式響應,還能通過檔案管理器等方式響應。這也就意味著它返回的URI可能有兩種不同的格式:

如上述截圖所示,當我通過系統相簿選擇了一張圖片時,返回的URI是第一種格式;而通過檔案管理器選擇的一張圖片,返回的URI則是第二種。相反,而對於通過ACTION_PICK來選擇的圖片檔案,因為我們傳入的URI是MediaStore.Images.Media.EXTERNAL_CONTENT_URI,它其實代表的是提供給查詢媒體資料庫的內容提供者的URI,所以其返回的的URI則都將是第二種。實際上對於第二種URI的解析工作我們已經很熟悉了,之前也已經寫過了通過ContentResovler來解析它的程式碼。但如果覺得麻煩,系統其實也有相應的工具類已經封裝了相關的解析,所以我們也可以選擇直接使用它們來解析這種URI:

    public static String getImagePath(Activity activity, Uri imageUri, String selection) {
        String path = null;
        // query projection
        String[] projection = {MediaStore.Images.Media.DATA};
        // 執行查詢
        Cursor cursor;
        if (Build.VERSION.SDK_INT < 11) {
            cursor = activity.managedQuery(imageUri, projection, selection, null, null);
        } else {
            CursorLoader cursorLoader = new CursorLoader(activity, imageUri, projection, selection, null, null);
            cursor = cursorLoader.loadInBackground();
        }

        if (cursor != null) {
            // 從查詢結果解析path
            if (cursor.moveToFirst()) {
                path = cursor.getString(cursor.getColumnIndex(MediaStore.Images.Media.DATA));
            }
            cursor.close();
        }
        return path;
    }

而對於返回的第一種URI(即代表檔案真實路徑的URI),對其解析的工作就更簡單了,呼叫getPath方法就可以直接獲取到對應的檔案路徑:

imagePath = imageUri.getPath();

所以其實我們面臨的問題就是,如果我們是通過ACTION_GET_CONTENT來啟動圖片選擇器,那麼我們在onActivityResult對於解析URI來獲取圖片檔案路徑的程式碼邏輯會更復雜一點,因為我們需要判斷返回的到底是哪種格式的URI:

    private void handleWithChooseFromAlbum(Intent data) {
        // 獲取Uri
        Uri imageUri = data.getData();

        // 根據Uri獲取檔案路徑
        String imagePath = null;
        if (imageUri.getScheme().equalsIgnoreCase("content")) {
            imagePath = getImagePath(this,imageUri, null);
        } else if (imageUri.getScheme().equalsIgnoreCase("file")) {
            imagePath = imageUri.getPath();
        }

        // displayImage(imagePath);
    }

然而這還不算完,因為在 Android4.4 以後,這裡返回的URI格式又發生了變化,舉例來說,就變為了類似於下面兩種類似格式的URI:


content://com.android.providers.media.documents/document/image%3A50
// download目錄下的圖片
content://com.android.providers.downloads.documents/document/1

可以看到,雖然此時返回的仍然是content://協議的URI,但是它的路徑資訊等都是經過封裝的,所以此時如果我們直接將該URI傳入到我們之前封裝的解析方法中,是會導致異常的。所以我們還需要針對於4.4以上版本的URI做額外的解析後,再通過ContentResovler解析出路徑:

    @RequiresApi(api = Build.VERSION_CODES.KITKAT)
    private void handleWithChooseFromAlbumAPI19(Intent data) {
        Uri imageUri = data.getData();
        String imagePath = null;

        if (DocumentsContract.isDocumentUri(this, imageUri)) {
            String docID = DocumentsContract.getDocumentId(imageUri);

            if (imageUri.getAuthority().equals("com.android.providers.media.documents")) {
                // 解析出數字格式的ID
                String id = docID.split(":")[1];
                // id用於執行query的selection當中
                String selection = MediaStore.Images.Media._ID + " = " + id;
                // 查詢path
                imagePath = getImagePath(this,MediaStore.Images.Media.EXTERNAL_CONTENT_URI, selection);
            } else if (imageUri.getAuthority().equals("com.android.providers.downloads.documents")) {
                Uri contentUri = ContentUris.withAppendedId(Uri.parse("content://downloads/public_downloads/"), Long.valueOf(docID));
                imagePath = getImagePath(this,contentUri, null);
            }
           // displayImage(imagePath);
        } else {
            handleWithChooseFromAlbum(data);
        }
    }

這就是使用兩種不同Action的Intent所帶來的不同的解析工作,所以究竟是選擇哪種方式來選擇圖片,就看自己的想法了。

多圖選擇器

OK,那麼到了現在我們已經知道了在自身應用中如何通過呼叫相機應用或者現有程式來獲取一張圖片,再面臨類似的需求,肯定難不倒我們了。但問題是我們也可以發現,對於獲取已儲存的圖片內容來說,通過類似“系統相簿”或者“檔案選擇器”的方式其實也是有一定的限制的。例如我們想要一次選擇多張圖片或者說對於選擇圖片的操作方式有一定特殊的要求,就需要自己來實現了。

這裡就以一個比較實用的“多圖選擇器”的功能為例,簡單的來分析一下其實現思路。實際上只要我們真正理解了之前通過隱式Intent去啟動圖片選擇的本質,其實就會發現這種功能並不難實現。因為說到底我們現在要做的仍然就是獲取手機儲存空間當中的所有圖片進行顯示,就類似系統相簿所做的一樣。不同的就是在於,之前我們一次只能選擇一張圖片,現在需要支援一次選擇多張圖片。

事實上實現該需求的難點就在於,如何去獲取手機上儲存的圖片檔案。我們前面也說到了,如果選擇遞迴遍歷儲存空間肯定不是一個明智的做法。那麼回想一下:在說到ACTION_PICK的例子時,我們有使用到一個叫做“MediaStore.Images.Media.EXTERNAL_CONTENT_URI”的東西,其實關鍵就是MediaStore這個類了。這是系統為我們提供的一個操作媒體資料庫的類,事實上我們手機上所有的媒體檔案資訊(不止圖片,還包括音訊,視訊)都會被存入媒體資料庫當中。所以如果我們想要獲取手機上的媒體檔案,其實並不需要真的去遍歷儲存空間,只需要到該資料庫進行指定條件的查詢就搞定了。這其實也是為什麼,之前我們在說呼叫相機應用拍照後,記得傳送一條廣播讓媒體掃描器進行掃描工作的原因之一。因為媒體掃描器的工作就是對儲存空間中的媒體檔案進行掃描,然後將相關資訊存放進媒體資料庫中。

那麼,就好像別人想要訪問我們應用內的資料庫當中的資料一樣,這時的做法自然就是通過我們提供的ContentProvider來進行訪問。所以我們想要訪問系統的媒體資料庫的資料,自然也只能通過對應的ContentProvider來進行訪問。看下面的方法:

    private Map<String, List<String>> directoryMap;
    private final Uri EXTERNAL_IMAGE_URI = MediaStore.Images.Media.EXTERNAL_CONTENT_URI;
    private final String IMAGE_SELECTION = MediaStore.Images.Media.MIME_TYPE + " =? or " + MediaStore.Images.Media.MIME_TYPE + " =?";
    private final String[] IMAGE_SELECTION_ARGS = new String[] {"image/jpeg","image/png"};

    public void scanImage(){
        // Map根據目錄分別存放
        directoryMap = new HashMap<>();
        // 獲取ContentResolver
        ContentResolver resolver = getContentResolver();
        // 執行查詢
        Cursor cursor = resolver.query(EXTERNAL_IMAGE_URI ,null,IMAGE_SELECTION,IMAGE_SELECTION_ARGS, MediaStore.Images.Media.DATE_MODIFIED+" desc");

        if(cursor == null)
            return;

        while(cursor.moveToNext()){
            // 獲取圖片路徑
            String path = cursor.getString(cursor
                    .getColumnIndex(MediaStore.Images.Media.DATA));

            // 獲取該圖片的父路徑名
            String parentFileName = new File(path).getParentFile().getName();

            if (!directoryMap.containsKey(parentFileName)) {
                List<String> directoryList = new ArrayList<>();
                directoryList.add(path);
                directoryMap.put(parentFileName, directoryList);
            } else {
                directoryMap.get(parentFileName).add(path);
            }
        }
    }

簡單的分析一下上面的程式碼,首先看到對於query方法我們傳入的URI其實就是MediaStore.Images.Media.EXTERNAL_CONTENT_URI,它的實際值其實就是“content://media/external/images/media”。細心一點的朋友可能就會發現這個URI看上去非常眼熟,沒錯,回憶一下之前我們從系統相簿獲取單張圖片時返回的URI,比如“content://media/external/images/media/3227”。我們會發現其實二者唯一的差異就是後者相較之下多了一個路徑分隔和”3227”,其實這個所謂的3227就是指圖片的ID,前面對於4.4版本以上的URI做的額外解析的核心工作實際上也就是解析得到這個ID。

那麼,這兩個URI之間的區別在哪呢?簡單來說,只要我們有一點點的sql基礎,就可以理解為:前者做的查詢是”select * from table”,而後者則是”select * from table where id = 3227”。沒錯,其實就是查詢整張表的內容和查詢該表內id為某個指定值的內容的區別。因為這裡我們本身就是意圖獲取所有的影象檔案,所以肯定是使用第一種URI了。那麼同理,我們也就不難理解之前使用ACTION_PICK獲取圖片時,傳入的URI為MediaStore.Images.Media.EXTERNAL_CONTENT_URI的原因了。

接著,我們在query中還傳入了selection和selectionArgs,簡單來說,在上述程式碼中這兩者結合起來,其實就可以理解為本次sql的查詢條件是“where mime_type = image/jpeg or mime_type = image/png”。也就是說我們本次只查詢那些格式為jpg或者png的圖片,這樣做的目的自然是排除其他格式(例如gif等)的圖片。

當然我們最後還設定了sortOrder引數,它的排序根據被我們設定為影象檔案的修改日期,預設的情況下,排序將採用升序的模式,這裡我將其定義為desc則代表我希望採用降序的排序模式。所以總的歸納一下,我們這裡的做的查詢的意義就是:從系統媒體資料庫中存放圖片檔案資訊的表中,查詢所有格式為jpg或者png的圖片,並且查詢到的結果按新增日期從最新到最舊的順序進行排序

在成功獲取到查詢結果後,其實所做的工作我們就很熟悉了,無非就是遍歷查詢結果並解析,從而得到圖片的檔案路徑並存放進對應的List。而我們額外做的就是,還將圖片按所屬路徑的不同分別進行存放,這樣之後如果我們想要實現類似分路徑檢視圖片的功能也就很輕鬆了。

而在以上的解析工作都已經完成後,接下來要做的其實就非常簡單了,比如用一個RecyclerView來將對應的結果進行顯示就行了。而所謂的多圖選擇器,實際就是使用者選中了某項後,並不急著返回,而是將該項的路徑儲存,然後當用戶最後選擇完成後,一次性將結果返回就行了。考慮到篇幅,就不加贅述了。

圖片優化

那麼,到目前為止,其實我們談到的主要都是關於如何獲取和選擇圖片的內容。而其實對於Android中的影象處理工作,還有一個很重要的內容就是對於影象的壓縮和優化。我們都知道現在手機的配置都越來越好,在這個攝像頭畫素動則千萬級的年代,帶來的一個結果就是,圖片的畫素變得越來越高。但同時也就意味著一張圖片的體積也就越來越大。

所以說,我們在將一張圖片讀取進應用記憶體進行顯示的時候,如果圖片檔案的畫素和體積很大,那麼所消耗的記憶體會是非常誇張的。而我們要明白,其實對於移動裝置來說,記憶體還是非常珍貴的。所以如果我們不進行相應的處理,就很容易因為記憶體佔用過大導致應用執行速度變慢乃至因為記憶體溢位直接崩潰。

因為Android發展到現在,已經有了很多強大而且成熟的圖片載入框架。所以不像之前,如今很多時候我們甚至基本上不用考慮對於影象的記憶體優化工作,通過這些框架往往只需要一兩行程式碼就能搞定圖片的載入。而這本質上也是因為這些框架已經默默的幫我們完成了這些優化工作。

但是,顯然我們也不能因為有了這些框架的出現,就直接不去了解和掌握關於圖片的優化方面的相關知識了。所以在這裡我們就來看看,摒棄這些框架,使用原汁原味的方式時,是否對圖片進行優化會有多大的影響。首先,我們在相關的路徑放置一張名為“test.jpg”的圖片:

這裡寫圖片描述

通過檔案屬性我們可以看到,該張圖片的大小為1.13MB;畫素為3504*2336。那麼,我們首先來看一下,我們直接將原圖讀取到記憶體中,並且列印其相關資訊會是如何:

        Bitmap rawBitmap = BitmapFactory.decodeFile(filePath);
        ivBeforeCompress.setImageBitmap(rawBitmap);
        float byteCount = (float) rawBitmap.getByteCount() / 1024 / 1024;
        tvBeforeCompress.setText("原圖所佔的記憶體空間為:" + (float) (Math.round(byteCount * 100)) / 100 + "MB"
                + "\n width pixel is : " + rawBitmap.getWidth() + "\n height pixel is" + rawBitmap.getHeight());

這裡寫圖片描述

我們從如上截圖中可以看到,也就是說我們將圖片讀取到記憶體過後其畫素並未有何不同。而讓人留意的是,雖然我們之前已經看到該影象檔案自身的大小是1.13MB,但將其讀取到記憶體過後發現竟然佔到了31.22MB左右的空間。那麼,現在我們對於影象究竟有多吃記憶體應該有了一個初步但直觀的印象了。

一張圖片既然消耗瞭如此大的記憶體,我們肯定就應該想辦法弄清楚如何進行一些優化工作,讓其不再佔用那麼大的記憶體消耗。那麼,首先我們應該搞明白的自然就是一張圖片在Android裝置中佔據的記憶體究竟是如何計算的呢?因為Android中圖片是以bitmap形式存在的,所以我們關注的點就變為了:bitmap所佔記憶體大小的計算方式。

事實上Bitmap所佔記憶體大小的計算公式為:圖片長度 x 圖片寬度 x 一個畫素點佔用的位元組數。其中圖片長度和寬度很好理解,就是其寬高的畫素,所以其實疑問點就在於一個畫素點佔用的位元組數到底是如何計算的呢?這其實和bitmap採取的depth(顏色深度)的計算方式有關。而在Android中,為bitmap提供的depth在Bitmap類中的Config列舉中有定義,分別為:

在這之中,A代表透明度;R代表紅色;G代表綠色;B代表藍色。而它們具體的意義以及depth的計算方式為:

  • ALPHA_8
    表示8位Alpha點陣圖,即A=8,一個畫素點佔用1個位元組,它沒有顏色,只有透明度
  • ARGB_4444
    表示16位ARGB點陣圖,即A=4,R=4,G=4,B=4,一個畫素點佔4+4+4+4=16位,2個位元組
  • ARGB_8888
    表示32位ARGB點陣圖,即A=8,R=8,G=8,B=8,一個畫素點佔8+8+8+8=32位,4個位元組
  • RGB_565
    表示16位RGB點陣圖,即R=5,G=6,B=5,它沒有透明度,一個畫素點佔5+6+5=16位,2個位元組

那麼,瞭解了以上相關的知識,我們就能知道為什麼我們之前讀取進記憶體的Bitmap佔用這麼大的記憶體空間了。我們已經知道了其圖片畫素,而Bitmap預設會採用ARGB_8888的方式來計算深度,所以我們讀取這張圖片所佔用的記憶體空間的計算過程就是:

  • 3504 * 2336 * 4 / 1024 / 1024 = 31.224609375MB ≈ 31.22MB。

所以,很顯然的,如果我們希望對於這張圖片進行記憶體優化。那麼我們可以考慮的顯然就是兩個方向:一個是減少它的畫素;另一個自然就是從顏色深度上著手。第二種方式這裡我們就不多加贅述了,簡單舉例來說,如果我們認為預設的ARGB_8888佔用記憶體過大,則可以考慮換為RGB_565,因為位深度減少了一半,所以最終得到的bitmap物件也就減少了一半的記憶體佔用。

這裡我們重點看一下通過壓縮畫素來優化圖片的記憶體佔用,以我們的測試用圖來說,它的寬高畫素分別達到了3504和2336。但顯然,很多時候我們在手機上是遠遠用不了這麼高的畫素的,所以我們可以根據具體情況適當的去對畫素進行壓縮。比如說我們現在讓這張圖片的寬高分別壓縮一半,變為1752*1168,那麼其佔用的記憶體就變為了:

  • 1752 * 1168 * 4 / 1024 / 1024 = 7.80615234375MB ≈ 7.81MB

也就是說,因為寬高各減少了一半,所以最後整個bitmap所佔的記憶體空間就直接減少了4倍左右。我們可以通過程式碼來驗證一下我們的理解是否有誤:

        BitmapFactory.Options options = new BitmapFactory.Options();
        options.inSampleSize = 2;

        Bitmap sampledBitmap = BitmapFactory.decodeFile(filePath,options);

        ...

這裡寫圖片描述

由此可以驗證,我們的理解完全沒有問題。而對bitmap進行畫素壓縮的方式也很簡單,就是在option中設定好inSampleSize再讀取bitmap就行了。inSampleSize的值就是我們進行壓縮的比例,這裡設定為2,我們可以看到寬高畫素則分別被壓縮為了原來的一半。同理,設定為4,則會被壓縮值原本的1/4,以此類推。

並且我們可以看到,儘管將寬高畫素分別壓縮了一半,但是其實將其顯示到ImageView上後,其實效果並沒什麼影響。這就是我們前面說到的,很多時候,手機上其實用不到那麼高的畫素。所以,其實我們究竟將畫素壓縮到什麼程度最為合適呢?其實讓其畫素和用於顯示它的控制元件的寬高相近是最好的。也就是說,我們需要配合控制元件的寬高來計算inSampleSize,而這其實也不復雜,我們定義如下兩個方法:

    public static Bitmap decodeSampledBitmapFromFile(String filePath, int reqWidth, int reqHeight) {
        BitmapFactory.Options options = new BitmapFactory.Options();
        options.inJustDecodeBounds = true; // 第一次測量時,只讀取圖片的畫素寬高,但不將點陣圖寫入記憶體
        BitmapFactory.decodeFile(filePath, options);
        // 計算畫素壓縮的比例
        options.inSampleSize = calculateSampleSize(options, reqWidth, reqHeight);
        options.inJustDecodeBounds = false;
        // 將壓縮過畫素後的點陣圖讀入記憶體
        return BitmapFactory.decodeFile(filePath, options);
    }

    /**
     * 根據需求的寬高計算點陣圖的畫素壓縮比例
     *
     * @param options
     * @param reqWidth
     * @param reqHeight
     * @return
     */
    private static int calculateSampleSize(BitmapFactory.Options options, int reqWidth, int reqHeight) {
        int sampleSize = 1;

        int srcWidth = options.outWidth;
        int srcHeight = options.outHeight;

        if (srcWidth > reqWidth || srcHeight > reqHeight) {
            int widthRatio = Math.round((float) srcWidth / (float) reqWidth);
            int heightRatio = Math.round((float) srcHeight / (float) (reqHeight));
            sampleSize = widthRatio > heightRatio ? widthRatio : heightRatio;
        }

        return sampleSize;
    }

好的,以上的程式碼認真看下應該不難理解,思路就是我們配合最終需要的寬高以及圖片本身的寬高來計算出sampleSize,然後根據該sampleSize對bitmap物件進行畫素上的壓縮。可以看到,在這個過程中我們其實會執行兩次decodeFile,但需要注意的就是,第一次的時候,我們把option的inJustDecodeBounds設定為了true。它的意思就是指定本次decode行為不會真的將bitmap進行讀取進記憶體,所以說此時我們如果去獲取bitmap物件將返回null。但是儘管如此,它卻仍然會將圖片相關的資訊,比如寬高讀取進option當中。這其實就代表著第一次decode行為既不會消耗任何記憶體,但又能成功獲取圖片本身的寬高畫素,之後我們就能根據它們計算出需要的sampleSize值,從而我們也就能decode得到畫素壓縮後的bitmap物件了。當然不要忘了的就是,在第二次decodeFile的時候,我們要記得把option當中的inJustDecodeBounds重新設定為false。然後我們仍然通過對應程式碼來驗證一下:

如上圖所示,在這個佈局中,我將兩個ImageView的寬高設定為了200dp,因為我測試的手機的螢幕密度是480,所以其最終實際的寬高就是600px。配合該寬高畫素,我們發現最終的bitmap的畫素則被壓縮為了876*584,其所佔記憶體空間則變為了僅僅1.95MB。我們可以看到壓縮前後的bitmap在ImageView上的顯示效果其實沒有太大區別,但是佔用的記憶體空間卻減少了將近30MB。

我們來分析一下這裡可能會出現的疑問,首先如果我們注意看,就會發現在這裡:以我們定義的對於計算sampleSize的方法來說,最終計算的結果應該是6才對。但是我們通過壓縮後的bitmap的畫素來說,會發現似乎sampleSize應該是4才會得到這種畫素。通過檢視原始碼,發現BitmapFactory進行decodeFile其實最終是通過底層的native方法完成的。所以這裡我也不太確定具體造成這種結果的原因。但是我自己通過測試發現:應該是當按照option中設定的sampleSize的值來進行計算時,如果得到的結果不為整數,就會將sampleSize減小,直到能計算出寬高都為整數的畫素。也就是說,這裡我們無論將sampleSize設為5,6,7,最終得到的畫素都仍然是876*584。而如果將sampleSize設為8,才會得到438*292的畫素。

另外一方面,本次我將兩個ImageView的scaleType都改為了fit_xy,也就是說此時會不按比例的去縮放圖片,讓其寬高正好填充滿整個ImageView。那麼以原圖來說,雖然其本來的畫素高達3504*2336,但其實最終仍然要被壓縮到600*600的畫素,所以簡單來說我們也可以理解為有2904*1736的畫素其實都可以視作是無效的。而以我們壓縮至876*584的影象來說,則在寬度方面仍然有276個畫素點是多餘的,而在