理解Android圖像處理-拍照、單/多圖選擇器及圖像優(yōu)化

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


圖片獲取

從某種意義上來說,通常如果我們把一個所謂的APP還原一下本質(zhì),可以發(fā)現(xiàn)其實其主體內(nèi)容就是由一系列的文字和圖像信息混搭起來的一個數(shù)據(jù)集合的呈現(xiàn),所以基本上每個應用都離不開對于圖像的使用。那么,既然我們的應用內(nèi)將要涉及到圖像,那么首先應該考慮到的就是如何去獲取圖像。粗泛一點來說,在應用內(nèi)對于圖像的主要的獲取方式 大體可以分為兩種:

  • 第一種情況:其它空間(例如網(wǎng)絡) → 應用內(nèi)存 → 設備存儲空間
    (舉例來說,假設現(xiàn)有一個新聞瀏覽的應用客戶端,我們從服務器得到了最新的新聞數(shù)據(jù),某條新聞詳情內(nèi)含有圖片內(nèi)容。顯然目前我們拿到的僅僅是圖片對應的URL,它自身只是一串文本數(shù)據(jù),所以如果我們想要在自身應用內(nèi)獲取到其對應的圖片內(nèi)容,自然就需要通過網(wǎng)絡進行下載獲取,然后寫入內(nèi)存進行顯示。最后,如果有存儲(緩存)圖像的需求,那么圖片內(nèi)容則還會再由內(nèi)存寫入設備的存儲空間)。
  • 第二種情況:設備存儲空間 → 應用內(nèi)存 → 其它空間(例如網(wǎng)絡)
    (同樣,我們也可能會使用類似微博,朋友圈等功能。在這些Social性質(zhì)的應用里,我們常常會有一些圖片想要分享給他人,那么前提則是需要首先在本地的存儲空間獲取到對應的圖片,從而才能上傳到服務器。這時圖片的行為路徑則通常是與我們說到的第一類情況是相反的。)

顯然,第一種方式的本質(zhì)其實通常就是基于HTTP協(xié)議的網(wǎng)絡通信,本文中我們的主要關(guān)注點不在這里,故不加贅述。這里主要探討一下,對于第二種情況來說,我們通常有哪些方式或者途徑 可以在自己的應用內(nèi)獲取到想要的圖片數(shù)據(jù)。

獲取單張圖片

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

  • 通過相機(攝影應用)來拍攝并獲取到一張全新的圖像。
  • 通過在本地相冊(存儲)中查找并獲取一張已存在的心儀圖像。

相機拍攝

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

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

  • 系統(tǒng)已存在的支持拍攝圖像的程序。
  • 自己編寫一個支持拍攝圖像的程序。

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

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

沒錯,其實我們只是創(chuàng)建了一個Action為"android.media.action.IMAGE_CAPTURE"的Intent,就已經(jīng)能夠讓我們打開那些能夠拍攝圖像的相機應用程序了。究其原因,我們來看看系統(tǒng)源碼中對于該Action的一段注釋說明:

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

標準的操作意圖,可以發(fā)送給相機應用程序捕獲一個圖像并返回它。

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

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

OK,既然我們已經(jīng)改為通過startActivityForResult啟動程序,那么對于返回的圖像自然就是在onActivityResult方法中進行處理了。那么,現(xiàn)在要考慮的問題自然就是:在這里的返回結(jié)果中,我們應該如何去解析出本次拍攝到的圖像呢?很簡單:

    @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()+"http://"+bitmap.getHeight());
        super.onActivityResult(requestCode, resultCode, data);
    }

現(xiàn)在運行程序,啟動某個相機應用去拍攝一張照片,會發(fā)現(xiàn)得到類似的Log打印信息:

由此我們便成功獲取到了圖像,但是也可以發(fā)現(xiàn)通過這種方式獲取到的圖像的寬高像素是很低的,如這里就僅為240和135。并且,我們可以發(fā)現(xiàn)此時我們除了獲取到了一個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()+"http://"+bitmap.getHeight());

    }

此時我們重新編譯運行程序,發(fā)現(xiàn)得到如下的輸出結(jié)果:

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

然而,照成這種差異的原因是什么呢?我們暫且不說這個。而讓人注意的另一個點在于:不難發(fā)現(xiàn)對于此時我們拍攝的圖片 其最終的存儲路徑是無法由我們掌控的,而通常是由此次負責拍攝照片的相機應用程序來決定。舉例來說,假設我們調(diào)用的是系統(tǒng)自帶的相機來進行拍攝,那么如果拍攝的文件會被寫入到存儲空間,則最后可以發(fā)現(xiàn)該圖像被存儲的位置通常就是系統(tǒng)相機對應的圖像文件夾。

但顯然很多時候,我們會希望能夠獨立管理屬于我們自身應用中的各種文件及數(shù)據(jù),所以通常我們會在手機的存儲空間中創(chuàng)建自己應用的"專屬路徑"。那么我們?nèi)绾尾拍茏屌臄z的圖片被存放在屬于我們自己的應用的路徑下面呢?這時應該怎么做呢,同樣很簡單:

    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;
    }

此時我們重新運行程序,就會發(fā)現(xiàn)拍攝的照片被存放在了我們指定的路徑下面。也就是說,其實我們要做的很簡單,就是通過在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指定的路徑當中。

可能遇到的各種坑

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

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

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

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

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

    @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,所以這樣做還能避免空指針。

但這里其實仍然還有值得我們注意的地方,那就是當我們成功啟動某個相機應用程序并拍下圖像后,我們到指定的路徑下也發(fā)現(xiàn)圖片已經(jīng)被成功存儲。但打開系統(tǒng)相冊,卻發(fā)現(xiàn)找不到我們剛剛拍攝的圖片;而重新刷新或者重啟手機后,發(fā)現(xiàn)圖片則出現(xiàn)在了相冊當中。這是為什么呢?其實是因為,雖然我們拍攝并保存了新的圖像,但并沒有通知系統(tǒng)媒體這個動作。所以,在拍照完成后,別忘記發(fā)送一條通知媒體掃描儀對我們新拍的圖像進行掃描:

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

好吧,如果我們以為到這里就已經(jīng)說完了常見的因調(diào)用相機程序拍照可能遇到的坑,那我們就年輕了。事實上還有一種很可能會遇到的問題:那就是我們會發(fā)現(xiàn)在有的機型上,將拍攝好的圖像讀取到內(nèi)存中進行顯示過后,發(fā)現(xiàn)顯示的圖片的方向是不正確的。這是因為這些機型的系統(tǒng)相機,其拍攝出來的照片是帶有旋轉(zhuǎn)角度的。所以,其實對于這些圖片,將其讀取進內(nèi)存過后,還要獲取其旋轉(zhuǎn)角度,將bitmap對象進行對應角度的旋轉(zhuǎn)過后,才能夠正確顯示。

    /**
     * 獲取圖片的旋轉(zhuǎn)角度
     *
     * @param path 圖片路徑
     * @return 旋轉(zhuǎn)角度
     */
    public static int getBitmapDegree(String path) {
        int degree = 0;
        try {
            // 從指定路徑下讀取圖片,并獲取其EXIF信息
            ExifInterface exifInterface = new ExifInterface(path);
            // 獲取圖片的旋轉(zhuǎn)信息
            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;
    }


    /**
     * 旋轉(zhuǎn)Bitmap對象
     *
     * @param bm     Bitmap對象
     * @param degree 旋轉(zhuǎn)角度
     * @return 旋轉(zhuǎn)后的Bitmap對象
     */
    public static Bitmap rotateBitmapByDegree(Bitmap bm, int degree) {
        if (degree == 0)
            return bm;

        Bitmap returnBm = null;

        // 根據(jù)旋轉(zhuǎn)角度,生成旋轉(zhuǎn)矩陣
        Matrix matrix = new Matrix();
        matrix.postRotate(degree);
        try {
            // 將原始圖片按照旋轉(zhuǎn)矩陣進行旋轉(zhuǎn),并得到新的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方法我們可以獲取到對應路徑下的圖片文件的旋轉(zhuǎn)角度,當旋轉(zhuǎn)角度不為0的時候,我們就應該將對應的bitmap對象通過rotateBitmapByDegree方法旋轉(zhuǎn)對應的角度后,再進行顯示。

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


獲取已存儲圖片

OK,對于調(diào)用相機應用拍照的總結(jié)就到這里,更多的東西還是得我們自己實際使用到的時候能夠更好的理解。接下來,我們來看看如何獲取設備上已存儲的圖片。實際上對應于拍攝獲取圖像來說,從設備上已存儲的圖片中進行獲取可能是一種更為常用的途徑。因為它的優(yōu)勢在于:

  • 相對于拍攝獲取的單一途徑,這里的圖片 其來源更加廣泛;
  • 即使是拍攝的圖片,可能用戶也更愿意通過現(xiàn)今各種炫酷的美圖軟件進行美化,重新存儲后再使用。而非直接使用拍攝的原圖。

那么,既然是獲取設備中已經(jīng)存儲的圖片,顯然最容易想到的方法就是對手機存儲空間中的所有路徑進行遞歸遍歷,獲取到所有的圖像文件。但顯然這絕不是一種聰明的做法,并且效率低下。與之前調(diào)用相機程序的道理相同,這里我們?nèi)匀豢梢赃x擇通過隱式的Intent來實現(xiàn)我們的需求。而對于獲取圖片,我們通常有兩種方式去構(gòu)建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”,簡單來說它的意義就是允許用戶選擇指定類型的數(shù)據(jù)并返回。那么既然我們注意到了是指定類型的數(shù)據(jù),所以緊接著,我們就通過setType指定了數(shù)據(jù)的MIME-Type是圖像類型。除此之外,通過以下的另一種方式也能實現(xiàn)相同的功能:

    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,這時我們多半會有一個疑問就是關(guān)于這兩種方式之間的區(qū)別??聪驴丛创a中對于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.

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

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

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

    public static String getImagePath(Activity activity, Uri imageUri, String selection) {
        String path = null;
        // query projection
        String[] projection = {MediaStore.Images.Media.DATA};
        // 執(zhí)行查詢
        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) {
            // 從查詢結(jié)果解析path
            if (cursor.moveToFirst()) {
                path = cursor.getString(cursor.getColumnIndex(MediaStore.Images.Media.DATA));
            }
            cursor.close();
        }
        return path;
    }

而對于返回的第一種URI(即代表文件真實路徑的URI),對其解析的工作就更簡單了,調(diào)用getPath方法就可以直接獲取到對應的文件路徑:

imagePath = imageUri.getPath();

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

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

        // 根據(jù)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格式又發(fā)生了變化,舉例來說,就變?yōu)榱祟愃朴谙旅鎯煞N類似格式的URI:


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

可以看到,雖然此時返回的仍然是content://協(xié)議的URI,但是它的路徑信息等都是經(jīng)過封裝的,所以此時如果我們直接將該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")) {
                // 解析出數(shù)字格式的ID
                String id = docID.split(":")[1];
                // id用于執(zhí)行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,那么到了現(xiàn)在我們已經(jīng)知道了在自身應用中如何通過調(diào)用相機應用或者現(xiàn)有程序來獲取一張圖片,再面臨類似的需求,肯定難不倒我們了。但問題是我們也可以發(fā)現(xiàn),對于獲取已存儲的圖片內(nèi)容來說,通過類似“系統(tǒng)相冊”或者“文件選擇器”的方式其實也是有一定的限制的。例如我們想要一次選擇多張圖片或者說對于選擇圖片的操作方式有一定特殊的要求,就需要自己來實現(xiàn)了。

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

事實上實現(xiàn)該需求的難點就在于,如何去獲取手機上存儲的圖片文件。我們前面也說到了,如果選擇遞歸遍歷存儲空間肯定不是一個明智的做法。那么回想一下:在說到ACTION_PICK的例子時,我們有使用到一個叫做“MediaStore.Images.Media.EXTERNAL_CONTENT_URI”的東西,其實關(guān)鍵就是MediaStore這個類了。這是系統(tǒng)為我們提供的一個操作媒體數(shù)據(jù)庫的類,事實上我們手機上所有的媒體文件信息(不止圖片,還包括音頻,視頻)都會被存入媒體數(shù)據(jù)庫當中。所以如果我們想要獲取手機上的媒體文件,其實并不需要真的去遍歷存儲空間,只需要到該數(shù)據(jù)庫進行指定條件的查詢就搞定了。這其實也是為什么,之前我們在說調(diào)用相機應用拍照后,記得發(fā)送一條廣播讓媒體掃描儀進行掃描工作的原因之一。因為媒體掃描儀的工作就是對存儲空間中的媒體文件進行掃描,然后將相關(guān)信息存放進媒體數(shù)據(jù)庫中。

那么,就好像別人想要訪問我們應用內(nèi)的數(shù)據(jù)庫當中的數(shù)據(jù)一樣,這時的做法自然就是通過我們提供的ContentProvider來進行訪問。所以我們想要訪問系統(tǒng)的媒體數(shù)據(jù)庫的數(shù)據(jù),自然也只能通過對應的ContentProvider來進行訪問??聪旅娴姆椒ǎ?/p>

    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根據(jù)目錄分別存放
        directoryMap = new HashMap<>();
        // 獲取ContentResolver
        ContentResolver resolver = getContentResolver();
        // 執(zhí)行查詢
        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”。細心一點的朋友可能就會發(fā)現(xiàn)這個URI看上去非常眼熟,沒錯,回憶一下之前我們從系統(tǒng)相冊獲取單張圖片時返回的URI,比如“content://media/external/images/media/3227”。我們會發(fā)現(xiàn)其實二者唯一的差異就是后者相較之下多了一個路徑分隔和"3227",其實這個所謂的3227就是指圖片的ID,前面對于4.4版本以上的URI做的額外解析的核心工作實際上也就是解析得到這個ID。

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

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

當然我們最后還設置了sortOrder參數(shù),它的排序根據(jù)被我們設定為圖像文件的修改日期,默認的情況下,排序?qū)⒉捎蒙虻哪J?,這里我將其定義為desc則代表我希望采用降序的排序模式。所以總的歸納一下,我們這里的做的查詢的意義就是:從系統(tǒng)媒體數(shù)據(jù)庫中存放圖片文件信息的表中,查詢所有格式為jpg或者png的圖片,并且查詢到的結(jié)果按添加日期從最新到最舊的順序進行排序。

在成功獲取到查詢結(jié)果后,其實所做的工作我們就很熟悉了,無非就是遍歷查詢結(jié)果并解析,從而得到圖片的文件路徑并存放進對應的List。而我們額外做的就是,還將圖片按所屬路徑的不同分別進行存放,這樣之后如果我們想要實現(xiàn)類似分路徑查看圖片的功能也就很輕松了。

而在以上的解析工作都已經(jīng)完成后,接下來要做的其實就非常簡單了,比如用一個RecyclerView來將對應的結(jié)果進行顯示就行了。而所謂的多圖選擇器,實際就是用戶選中了某項后,并不急著返回,而是將該項的路徑保存,然后當用戶最后選擇完成后,一次性將結(jié)果返回就行了??紤]到篇幅,就不加贅述了。


圖片優(yōu)化

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

所以說,我們在將一張圖片讀取進應用內(nèi)存進行顯示的時候,如果圖片文件的像素和體積很大,那么所消耗的內(nèi)存會是非??鋸埖?。而我們要明白,其實對于移動設備來說,內(nèi)存還是非常珍貴的。所以如果我們不進行相應的處理,就很容易因為內(nèi)存占用過大導致應用運行速度變慢乃至因為內(nèi)存溢出直接崩潰。

因為Android發(fā)展到現(xiàn)在,已經(jīng)有了很多強大而且成熟的圖片加載框架。所以不像之前,如今很多時候我們甚至基本上不用考慮對于圖像的內(nèi)存優(yōu)化工作,通過這些框架往往只需要一兩行代碼就能搞定圖片的加載。而這本質(zhì)上也是因為這些框架已經(jīng)默默的幫我們完成了這些優(yōu)化工作。

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

通過文件屬性我們可以看到,該張圖片的大小為1.13MB;像素為3504*2336。那么,我們首先來看一下,我們直接將原圖讀取到內(nèi)存中,并且打印其相關(guān)信息會是如何:

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

我們從如上截圖中可以看到,也就是說我們將圖片讀取到內(nèi)存過后其像素并未有何不同。而讓人留意的是,雖然我們之前已經(jīng)看到該圖像文件自身的大小是1.13MB,但將其讀取到內(nèi)存過后發(fā)現(xiàn)竟然占到了31.22MB左右的空間。那么,現(xiàn)在我們對于圖像究竟有多吃內(nèi)存應該有了一個初步但直觀的印象了。

一張圖片既然消耗了如此大的內(nèi)存,我們肯定就應該想辦法弄清楚如何進行一些優(yōu)化工作,讓其不再占用那么大的內(nèi)存消耗。那么,首先我們應該搞明白的自然就是一張圖片在Android設備中占據(jù)的內(nèi)存究竟是如何計算的呢?因為Android中圖片是以bitmap形式存在的,所以我們關(guān)注的點就變?yōu)榱耍篵itmap所占內(nèi)存大小的計算方式。

事實上Bitmap所占內(nèi)存大小的計算公式為:圖片長度 x 圖片寬度 x 一個像素點占用的字節(jié)數(shù)。其中圖片長度和寬度很好理解,就是其寬高的像素,所以其實疑問點就在于一個像素點占用的字節(jié)數(shù)到底是如何計算的呢?這其實和bitmap采取的depth(顏色深度)的計算方式有關(guān)。而在Android中,為bitmap提供的depth在Bitmap類中的Config枚舉中有定義,分別為:

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

  • ALPHA_8
    表示8位Alpha位圖,即A=8,一個像素點占用1個字節(jié),它沒有顏色,只有透明度
  • ARGB_4444
    表示16位ARGB位圖,即A=4,R=4,G=4,B=4,一個像素點占4+4+4+4=16位,2個字節(jié)
  • ARGB_8888
    表示32位ARGB位圖,即A=8,R=8,G=8,B=8,一個像素點占8+8+8+8=32位,4個字節(jié)
  • RGB_565
    表示16位RGB位圖,即R=5,G=6,B=5,它沒有透明度,一個像素點占5+6+5=16位,2個字節(jié)

那么,了解了以上相關(guān)的知識,我們就能知道為什么我們之前讀取進內(nèi)存的Bitmap占用這么大的內(nèi)存空間了。我們已經(jīng)知道了其圖片像素,而Bitmap默認會采用ARGB_8888的方式來計算深度,所以我們讀取這張圖片所占用的內(nèi)存空間的計算過程就是:

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

所以,很顯然的,如果我們希望對于這張圖片進行內(nèi)存優(yōu)化。那么我們可以考慮的顯然就是兩個方向:一個是減少它的像素;另一個自然就是從顏色深度上著手。第二種方式這里我們就不多加贅述了,簡單舉例來說,如果我們認為默認的ARGB_8888占用內(nèi)存過大,則可以考慮換為RGB_565,因為位深度減少了一半,所以最終得到的bitmap對象也就減少了一半的內(nèi)存占用。

這里我們重點看一下通過壓縮像素來優(yōu)化圖片的內(nèi)存占用,以我們的測試用圖來說,它的寬高像素分別達到了3504和2336。但顯然,很多時候我們在手機上是遠遠用不了這么高的像素的,所以我們可以根據(jù)具體情況適當?shù)娜ο袼剡M行壓縮。比如說我們現(xiàn)在讓這張圖片的寬高分別壓縮一半,變?yōu)?752*1168,那么其占用的內(nèi)存就變?yōu)榱耍?/p>

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

也就是說,因為寬高各減少了一半,所以最后整個bitmap所占的內(nèi)存空間就直接減少了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; // 第一次測量時,只讀取圖片的像素寬高,但不將位圖寫入內(nèi)存
        BitmapFactory.decodeFile(filePath, options);
        // 計算像素壓縮的比例
        options.inSampleSize = calculateSampleSize(options, reqWidth, reqHeight);
        options.inJustDecodeBounds = false;
        // 將壓縮過像素后的位圖讀入內(nèi)存
        return BitmapFactory.decodeFile(filePath, options);
    }
    
    /**
     * 根據(jù)需求的寬高計算位圖的像素壓縮比例
     *
     * @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,然后根據(jù)該sampleSize對bitmap對象進行像素上的壓縮??梢钥吹剑谶@個過程中我們其實會執(zhí)行兩次decodeFile,但需要注意的就是,第一次的時候,我們把option的inJustDecodeBounds設置為了true。它的意思就是指定本次decode行為不會真的將bitmap進行讀取進內(nèi)存,所以說此時我們?nèi)绻カ@取bitmap對象將返回null。但是盡管如此,它卻仍然會將圖片相關(guān)的信息,比如寬高讀取進option當中。這其實就代表著第一次decode行為既不會消耗任何內(nèi)存,但又能成功獲取圖片本身的寬高像素,之后我們就能根據(jù)它們計算出需要的sampleSize值,從而我們也就能decode得到像素壓縮后的bitmap對象了。當然不要忘了的就是,在第二次decodeFile的時候,我們要記得把option當中的inJustDecodeBounds重新設置為false。然后我們?nèi)匀煌ㄟ^對應代碼來驗證一下:

如上圖所示,在這個布局中,我將兩個ImageView的寬高設置為了200dp,因為我測試的手機的屏幕密度是480,所以其最終實際的寬高就是600px。配合該寬高像素,我們發(fā)現(xiàn)最終的bitmap的像素則被壓縮為了876*584,其所占內(nèi)存空間則變?yōu)榱藘H僅1.95MB。我們可以看到壓縮前后的bitmap在ImageView上的顯示效果其實沒有太大區(qū)別,但是占用的內(nèi)存空間卻減少了將近30MB。

我們來分析一下這里可能會出現(xiàn)的疑問,首先如果我們注意看,就會發(fā)現(xiàn)在這里:以我們定義的對于計算sampleSize的方法來說,最終計算的結(jié)果應該是6才對。但是我們通過壓縮后的bitmap的像素來說,會發(fā)現(xiàn)似乎sampleSize應該是4才會得到這種像素。通過查看源碼,發(fā)現(xiàn)BitmapFactory進行decodeFile其實最終是通過底層的native方法完成的。所以這里我也不太確定具體造成這種結(jié)果的原因。但是我自己通過測試發(fā)現(xiàn):應該是當按照option中設定的sampleSize的值來進行計算時,如果得到的結(jié)果不為整數(shù),就會將sampleSize減小,直到能計算出寬高都為整數(shù)的像素。也就是說,這里我們無論將sampleSize設為5,6,7,最終得到的像素都仍然是876584。而如果將sampleSize設為8,才會得到438292的像素。

另外一方面,本次我將兩個ImageView的scaleType都改為了fit_xy,也就是說此時會不按比例的去縮放圖片,讓其寬高正好填充滿整個ImageView。那么以原圖來說,雖然其本來的像素高達35042336,但其實最終仍然要被壓縮到600600的像素,所以簡單來說我們也可以理解為有29041736的像素其實都可以視作是無效的。而以我們壓縮至876584的圖像來說,則在寬度方面仍然有276個像素點是多余的,而在高度方面因為不足以鋪滿ImageView,則需要進行拉伸。我們需要知道的是:這種拉伸行為肯定會在一定程度上導致圖像清晰度的丟失,但是顯然這里的拉伸量非常小,所以實際上最終我們在視覺上其實基本體會不到差異。

當然,只是通過這樣的文字描述,可能有時候我們還是不太容易理解這個原理。所以這里我畫了一張非常簡易的草圖,可以一起來看一下:

假設這里的圖1和圖2分別代表我們的原圖和壓縮過后的圖,其中的圓圈我們就看作圖中的像素點。那么圖3和圖4就分別對應于它們面臨fit_xy的縮放處理方式,由此也可以看到對于寬度上的縮放,其實它們的效果是相同的,但是因為圖2在高度上的像素本就已經(jīng)低于標準,所以圖4想要和圖3保持相同的尺寸顯示圖像,就只能選擇進行拉伸,自然也就導致其高度上的像素點之間的間隔變大;而另一層面上,如果更加專業(yè)一點的來說,現(xiàn)在的情況就是:在相同尺寸內(nèi)的一塊區(qū)域中,圖3能夠打印的像素點數(shù)比圖4更多,所以就代表圖4相對于圖3,在圖像顯示的清晰度肯定會較差一些。

所以,簡單來說也可以這樣理解,如果我們想要對一張圖像進行像素上的壓縮,但是同時又不希望影響任何一點點的圖像顯示的質(zhì)量。那么就需要保證原圖的像素在經(jīng)過壓縮之后,仍然至少要大于等于顯示它的ImageView的尺寸。舉例來說,假設我們有一張20001500的原圖,用于顯示它的ImageView的寬高則是500500。那么我們肯定也可以選擇將sampleSize設置為4,壓縮后的圖像的像素則變?yōu)榱?00375。從而因為壓縮后的圖像的高度已經(jīng)不足以填滿ImageView,所以如果想要完全顯示這張圖片,就肯定會導致精度的丟失。所以如果不希望出現(xiàn)這樣的情況,我們的做法就應該是將sampleSize設置為2,從而將圖像的像素壓縮為1000750。所以總的來說,這個平衡本質(zhì)上其實就是在精度丟失和內(nèi)存開銷中做取舍了。

到了這里,我相信我們一定能夠非常清楚的體會到對圖片進行合理的優(yōu)化有多么的重要了。OK,這是我們談到的第一種非常重要和實用的圖像優(yōu)化方案。接著我們來看另一種優(yōu)化方案,即所謂的“質(zhì)量壓縮”,這是什么意思呢?我們暫且不提,先看一看這種壓縮應該如何實現(xiàn):

            fos = new FileOutputStream(path);
            bitmap.compress(Bitmap.CompressFormat.JPEG, quality, fos);

沒錯,其實非常簡單,通過對原本的bitmap對象調(diào)用compress方法就能夠?qū)崿F(xiàn)所謂的質(zhì)量壓縮。這里的參數(shù)quality就代表質(zhì)量壓縮的程度,它的值我們可以在1-100之間進行選擇,值越高質(zhì)量也就越高。那么,這里我將quality設置為25來進行測試,看看最終的效果如何:

沒錯,經(jīng)過質(zhì)量壓縮之后,我們驚奇的發(fā)現(xiàn)不管是占用的內(nèi)存空間還是bitmap寬高像素都沒有任何變化,這不是坑爹嗎?其實并沒有錯,質(zhì)量壓縮并不會對bitmap的這些內(nèi)容產(chǎn)生影響,但我們可以看到compress的第三個參數(shù)其實是一個輸出流對象,這是不是意味著我們可以將該輸出流輸出到外部文件呢?答案是可以的。這里我將FileOutputStream的路徑設置為了與test.jpg相同路徑下的compress.jpg??纯磿l(fā)生什么:

沒錯,可以看到經(jīng)過質(zhì)量壓縮后重新寫入存儲空間的相同圖片,在寬高沒有發(fā)生變化的情況,文件大小由原本的1.13MB變?yōu)榱?01KB。這其實就是質(zhì)量壓縮的意義所在。如果我們將quality參數(shù)設置得更低,壓縮后的文件大小將更小,但同時圖片失真也會更嚴重,所以選擇一個合適的quality也是比較重要的。關(guān)于質(zhì)量壓縮的具體方式可能就涉及更多關(guān)于jpeg圖片自身的體積計算原理了,而我們需要記得的是因為jpeg格式是有損壓縮格式,所以才能支持這種質(zhì)量壓縮;而例如png則是無損壓縮格式,所以做這種壓縮是沒有用的。

同時這里也可以看到,通常我們對圖像進行質(zhì)量壓縮的時候,都會先將原圖的bitmap讀取進內(nèi)存,前面我們也說到讀取像素越高的bitmap將會占用越多的內(nèi)存,但其實同時還意味著讀取行為耗費的時間也相對越長,所以很多時候我們最好采用異步加載的方式去讀取圖片,以避免因UI線程阻塞導致ANR。

好的,到了這里我們可以知道,對圖片進行像素壓縮可以減少將其讀取進內(nèi)存后的占用空間;而進行質(zhì)量壓縮則可以減小圖片文件的體積。而實際上,將這兩者配合使用好,按一定比例去對圖像進行壓縮,可以在最大程度保證圖片效果不丟失過多的情況下減小圖片的體積。所以,如何巧妙的利用好這兩種技術(shù)來對圖片進行優(yōu)化是非常重要的,當然具體的使用還是根據(jù)實際的需求而定。

那么,我們不免會想對圖像進行像素壓縮是為了節(jié)約內(nèi)存空間。那么,通過壓縮減小圖像文件的體積,意義又在哪呢?可能最顯著的應用途徑就是,能夠減小圖片上傳時的流量消耗以及提高傳輸速度。但我們也知道對圖片進行壓縮肯定會在某種程度上降低圖片原本的效果。所以,有的時候我們也會提供類似“發(fā)送原圖”的選項。比如微信,這是因為它們并不只一個平臺的客戶端,雖然在手機上往往需要不了那么高的像素。但如果你的圖片將會發(fā)送給電腦端的用戶,那么情況就不同了。

最后值得一提的就是,請回憶一下,前面我們說到某些機型的相機拍攝的照片將會帶有一定的旋轉(zhuǎn)角度。那么,可能我們有時候因為手機上并不需要使用過高像素的圖像,所以會被要求對拍攝到的照片進行一定程度的像素和質(zhì)量壓縮后重新存儲,以減小圖像文件的體積。這個時候就要注意了,通過我們之前說到的方法對圖片完成壓縮重新存儲后,這個時候新的圖片文件將會丟失掉旋轉(zhuǎn)角度的信息。所以這個時候即使用我們之前所講過的方法也無法讓圖像正確顯示,所以當我們完成壓縮重新存儲后,一定要記得為新的圖像文件寫入對應的旋轉(zhuǎn)角度信息。

public static void setBitmapDegree(String path, int degree) {
        try {
            // 從指定路徑下讀取圖片,并獲取其EXIF信息
            ExifInterface exifInterface = new ExifInterface(path);

            switch (degree) {
                case 90:
                    exifInterface.setAttribute(ExifInterface.TAG_ORIENTATION, String.valueOf(ExifInterface.ORIENTATION_ROTATE_90));
                    break;
                case 180:
                    exifInterface.setAttribute(ExifInterface.TAG_ORIENTATION, String.valueOf(ExifInterface.ORIENTATION_ROTATE_180));
                    break;
                case 270:
                    exifInterface.setAttribute(ExifInterface.TAG_ORIENTATION, String.valueOf(ExifInterface.ORIENTATION_ROTATE_270));
                    break;
            }
            exifInterface.saveAttributes();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

好了,大致就總結(jié)到這里吧。關(guān)于文章開頭的演示動圖里的DEMO,已經(jīng)上傳到了github,地址為:
https://github.com/RawnHwang/AndroidLaboratory/tree/master/AndroidPictureProcessing。
如果有興趣或者有需要 了解類似效果如何實現(xiàn)的朋友直接自己查看源碼就行了,希望對你有所幫助。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 228,345評論 6 531
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 98,494評論 3 416
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 176,283評論 0 374
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經(jīng)常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,953評論 1 309
  • 正文 為了忘掉前任,我火速辦了婚禮,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,714評論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 55,186評論 1 324
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,255評論 3 441
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 42,410評論 0 288
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 48,940評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 40,776評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發(fā)現(xiàn)自己被綠了。 大學時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,976評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,518評論 5 359
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點故事閱讀 44,210評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,642評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,878評論 1 286
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,654評論 3 391
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,958評論 2 373

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