Android Bitmap 初探

IAM四十二發表於2017-03-05

最近一段時間的開發中和Bitmap接觸較多,就Bitmap的使用有了一些新的認識,如何對Bitmap進行壓縮,減少記憶體佔用有了一些總結。

背景

社交類(或者說是包含使用者系統)的APP基本上都會包含使用者自定義頭像的功能,可以讓使用者從相簿選擇或拍攝一張圖片作為自己的頭像,這樣才能顯現出每個人的個性嘛!每個使用者的手機裡各種各樣不可描述的照片,從尺寸到大小各不相同,因此如何把使用者選擇的圖片正確的載入到ImageView裡就成了一件值得探討的事情。好了,廢話不說,下面就讓我們一步步揭開Bitmap的神祕面紗。

從相簿載入一張圖片

我們先從簡單的入手,看看從手機相簿載入一張圖片到ImageView的正確方式。

Android Bitmap 初探

我們就以上圖為列,這張圖片在我手機裡的資訊如下:

Android Bitmap 初探

可以看到,圖片大小不足1M。那麼把他載入到手機記憶體中時又會發生什麼呢?

開啟相簿載入圖片

    /**
     * 開啟手機相簿
     */
    private void selectFromGalley() {
        Intent intent = new Intent();
        intent.setType("image/*");
        intent.setAction(Intent.ACTION_GET_CONTENT);
        intent.addCategory(Intent.CATEGORY_OPENABLE);
        startActivityForResult(intent, REQUEST_CODE_PICK_FROM_GALLEY);
    }複製程式碼

在Android 中開啟相簿是一件非常方便的事情,選擇好圖片之後就可以在onActivityResult中接收這張圖片

                if (resultCode == Activity.RESULT_OK) {
                    Uri uri = data.getData();
                    if (uri != null) {
                        ProcessResult(uri);
                    }
                }複製程式碼

根據Uri得到Bitmap

@TargetApi(Build.VERSION_CODES.KITKAT)
    private void ProcessResult(Uri destUrl) {
        String pathName = FileHelper.stripFileProtocol(destUrl.toString());
        showBitmapInfos(pathName);
        Bitmap bitmap = BitmapFactory.decodeFile(pathName);
        if (bitmap != null) {
            mImageView.setImageBitmap(bitmap);
            float count = bitmap.getByteCount() / M_RATE;
            float all = bitmap.getAllocationByteCount() / M_RATE;
            String result = "這張圖片佔用記憶體大小:\n" +
                    "bitmap.getByteCount()== " + count + "M\n" +
                    "bitmap.getAllocationByteCount()= " + all + "M";
            info.setText(result);
            Log.e(TAG, result);
            bitmap = null;
        } else {
            T.showLToast(mContext, "fail");
        }
    }

    /**
     * 獲取Bitmap的資訊
     * @param pathName
     */
    private void showBitmapInfos(String pathName) {
        BitmapFactory.Options options = new BitmapFactory.Options();
        options.inJustDecodeBounds = true;
        BitmapFactory.decodeFile(pathName, options);
        int width = options.outWidth;
        int height = options.outHeight;

        Log.e(TAG, "showBitmapInfos: \n" +
                "width=: " + width + "\n" +
                "height=: " + height);
        options.inJustDecodeBounds = false;
    }複製程式碼

這裡的處理很簡單,需要注意的一點是onActivityResult 方法中返回的Intent返回的圖片地址是一個Uri型別,包含具體協議,為了方便使用BitmapFactory的decode方法,需要將這個個Uri型別的地址轉換為普通的地址,stripFileProtocol具體實現可參考原始碼

showBitmapInfos 這個方法就是很簡單,就是獲取一下所要載入圖片的資訊。這裡主要還是靠inJustDecodeBounds 這個引數,當此引數為true時,BitmapFactory 只會解析圖片的原始寬/高資訊,並不會去真正的載入圖片。

我們看一下輸出日誌及記憶體變化:

Android Bitmap 初探

Android Bitmap 初探

關於getByteCount和getAllocationByteCount的區別,這裡暫時不討論,只要知道他們都可以獲取Bitmap佔用記憶體大小

可以看到,由於這張圖片是放在手機內部SD卡上,所以showBitmapInfos 解析後獲取的圖片寬高資訊和之前是一致的,寬x高為 2160x1920。看到所佔用的記憶體 15M,是不是有點意外,一張658KB 的載入後居然要佔這麼大的記憶體。在看一下monitor檢測的記憶體變化,在20s後選擇圖片後,佔用記憶體有了一個明顯的上升。佔用這麼大的記憶體,顯然是不好的。可能很多人和我一樣,在這個時候想到的第一個詞是壓縮圖片,把圖片變小他佔的記憶體不就會變小了嗎?好,那就壓縮圖片

壓縮圖片

壓縮圖片方案一(Compress)

因為我們要處理的是Bitmap,首先從他自帶的方法出發,果然找到了一個compress方法。

    private Bitmap getCompressedBitmap(Bitmap bitmap) {
        try {
            //建立一個用於儲存壓縮後Bitmap的檔案
            File compressedFile = FileHelper.createFileByType(mContext, destType, "compressed");
            Uri uri = Uri.fromFile(compressedFile);
            OutputStream os = getContentResolver().openOutputStream(uri);
            Bitmap.CompressFormat format = destType == FileHelper.JPEG ?
                    Bitmap.CompressFormat.JPEG : Bitmap.CompressFormat.PNG;
            boolean success = bitmap.compress(format, compressRate, os);
            if (success) {
                T.showLToast(mContext, "success");
            }

            final String pathName = FileHelper.stripFileProtocol(uri.toString());
            showBitmapInfos(pathName);
            bitmap = BitmapFactory.decodeFile(pathName);
            os.close();
        } catch (FileNotFoundException e) {
            e.printStackTrace();
        } catch (IOException e) {
            e.printStackTrace();
        }
        return bitmap;
    }複製程式碼

bitmap.compress(format, compressRate, os) 會按照指定的格式和壓縮比例將壓縮後的bitmap寫入到os 所對應的檔案中。compressRate的取值在0-100之間,0表示壓縮到最小尺寸。

在ProcessResult方法中,我們獲取bitmap後,首先通過上述方法將bitmap壓縮,然後在顯示到ImageView中。我們看一下,壓縮過後的情況。

Android Bitmap 初探

上面的日誌,第一個showBitmapInfos 顯示的是選擇的圖片通過BitmapFactory解析後的資訊,第二個showBitmapInfos
顯示的壓縮後圖片的寬高資訊,最後很意外,我們的壓縮方法似乎沒起到作用,佔用的記憶體沒有任何變化,依舊是15M。
難道是compress方法沒生效嗎?其實不然,至少從UI上看compress的確生效了, 當compressRate=0時,懶羊羊的圖片顯示到ImageView上時已經非常不清晰了,失真非常嚴重。那麼到底是為什麼呢?

這裡就得從概念上說起,一開始我們提到了這張懶羊羊的圖片大小時658KB,這是它在手機儲存空間所佔的大小,而當我們在選擇這張圖片,並解析為Bitmap時,他所站的15MB是在記憶體中所佔的大小;而compress方法只能壓縮前一種大小,也就是所使用Bitmap的compress方法只是壓縮他在儲存空間的大小,結果就是導致圖片失真;而不能改變他在記憶體中所佔用的大小

那麼怎樣才能讓Bitmap所佔用的記憶體變小呢?這就的從Bitmap佔用記憶體的計算方法入手,在這篇文章中已經對bitmap所佔用記憶體大小做了深入分析,從中我們可以得出結論,決定一張圖片所佔記憶體大小的因素是圖片的寬高和Bitmap的格式。這裡我們載入的時候對Bitmap格式未做更改,也就是預設的ARGB_8888,因此我們就得從寬高入手,得出如下的壓縮方案。

壓縮圖片方案二 (Crop)

    private void CropTheImage(Uri imageUrl) {
        Intent cropIntent = new Intent("com.android.camera.action.CROP");
        cropIntent.setDataAndType(imageUrl, "image/*");
        cropIntent.putExtra("cropWidth", "true");
        cropIntent.putExtra("outputX", cropTargetWidth);
        cropIntent.putExtra("outputY", cropTargetHeight);
        File copyFile = FileHelper.createFileByType(mContext, destType, String.valueOf(System.currentTimeMillis()));
        copyUrl = Uri.fromFile(copyFile);
        cropIntent.putExtra("output", copyUrl);
        startActivityForResult(cropIntent, REQUEST_CODE_CROP_PIC);
    }複製程式碼

這裡呼叫了系統自帶的圖片裁剪控制元件,並建立了一個copyFile 的檔案,裁剪過後的圖片的地址指向就是這個檔案所對應的地址。
當cropTargetWidth=1080,cropTargetHeight=920時,我們看一下日誌:

Android Bitmap 初探

可以看到,Bitmap所佔用的記憶體終於變小了,而且由於在裁剪時寬高各縮小了1/2,整個記憶體的佔用也是縮小了1/4,變成了3.9M左右。同時圖片在手機儲存空間也變小了。

當然,這裡要注意的是,com.android.camera.action.CROP 中兩個引數 "outputX" 和"outputY",決定了壓縮後圖片的大小,因此當這兩個值的大小超過原始圖片的大小時,記憶體佔用反而會增加,這一點應該很好理解,所以需確保傳遞合適的值,否則會適得其反。

圖片壓縮方案三 (Sample )

採用Sample,也就是是取樣的方式壓縮圖片之前,我們首先需要了解一下inSampleSize 這個引數。

inSampleSize 是BitmapFactory.Options 的一個引數,當他為1時,取樣後的圖片大小為圖片原始大小;當inSampleSize 為2時,那麼取樣後的圖片其寬/高均為原圖大小的1/2,而畫素數為原圖的1/4,其佔有的記憶體大小也為原圖的1/4。inSampleSize 的取值應該是2的指數。

    private Bitmap getRealCompressedBitmap(String pathName, int reqWidth, int reqHeight) {
        Bitmap bitmap;
        BitmapFactory.Options options = new BitmapFactory.Options();
        options.inJustDecodeBounds = true;
        BitmapFactory.decodeFile(pathName, options);
        int width = options.outWidth / 2;
        int height = options.outHeight / 2;
        int inSampleSize = 1;

        while (width / inSampleSize >= reqWidth && height / inSampleSize >= reqHeight) {
            inSampleSize = inSampleSize * 2;
        }

        options.inSampleSize = inSampleSize;
        options.inJustDecodeBounds = false;
        bitmap = BitmapFactory.decodeFile(pathName, options);
        showBitmapInfos(pathName);
        return bitmap;
    }複製程式碼

可以如下呼叫這個方法:

            if (needSample) {
                bitmap = getRealCompressedBitmap(pathName, 200, 200);
            }複製程式碼

我們希望將2160x1920畫素的原圖壓縮到200x200 畫素的大小,因此在getRealCompressedBitmap方法中,通過while迴圈inSampleSize的值最終為8,因此記憶體佔用率將變為原來的1/64,這是一個很大的降幅。我們看一下日誌,看看到底是否能夠如我們所願:

Android Bitmap 初探

可以看到,使用這種方法進行圖片壓縮後,增加的記憶體只有0.24M,幾乎可以忽略不計了。當然前提是我們要使用的圖片的確不需要很大,比如這裡,需要用這張圖片作為使用者頭像的話,那麼將原圖縮略成200x200 px的大小是沒有問題的。

三種方案對比

上面提到的三種壓縮方案,通過對比可以發現,第一種方案適用於進行純粹的檔案壓縮,而不適用進行影象處理壓縮;第二種方案壓縮方案適用於進行影象編輯時的壓縮,就像手機自帶相簿的編輯功能,可以隨著裁剪區域的大小進行最終的壓縮;第三種方案相對來說,適應性較強,各種場景都會符合。

從Camera 獲取Bitmap

有時候,我們除了從相簿獲取圖片之外,還可以通過手機自帶的相機拍攝圖片。

    private void openCamera() {
        Intent takePictureIntent = new Intent(MediaStore.ACTION_IMAGE_CAPTURE);
        //建立一個臨時資料夾儲存拍攝的照片
        File file = FileHelper.createFileByType(mContext, destType, "test");
        imageUrl = Uri.fromFile(file);
        takePictureIntent.putExtra(MediaStore.EXTRA_OUTPUT, imageUrl);
        if (takePictureIntent.resolveActivity(getPackageManager()) != null) {
            startActivityForResult(takePictureIntent, REQUEST_CODE_TAKE_PIC_CAMERA);
        }
    }複製程式碼

不同於從相簿選取圖片,開啟相機之前需要我們自己定義一個儲存圖片的臨時檔案file,這個臨時檔案既可以在應用的臨時儲存區也可以在手機儲存的臨時儲存區;通過這個檔案就可以生成一個Uri物件,有了這個Uri物件,相機拍攝完照片之後就可以在onActivityResult方法中通過這個Uri獲取到Bitmap了。

這裡我們可以試一下,隨便用手機拍攝一張圖片轉為Bitmap載入會佔多大的手機記憶體(以我用的小米手機5為列,拍攝一張圖片):

Android Bitmap 初探

可以看到這張圖片的解析度達到了3456x4608 畫素,而他載入到記憶體是所佔的大小居然達到了60M,這是非常不科學的做法,也是毫無意義的做法,因為我們的手機可見區域並沒有這麼大,將整張照片完全載入是沒有意義的。因此可以按照之前的壓縮方案進行壓縮。

bitmap = getRealCompressedBitmap(pathName, screenWidth, screenHeight);複製程式碼

我們可以將原來的圖片壓縮到手機螢幕大小的圖片

Android Bitmap 初探

可以看到佔用記憶體有了明顯的減少。

將拍攝的圖片新增到手機相簿中

有時需要將拍攝出來的照片新增到手機相簿中,方便從相簿直接檢視

    private void insertToGallery(Uri imageUrl) {
        Uri galleryUri = Uri.fromFile(new File(FileHelper.getPicutresPath(destType)));
        boolean result = FileHelper.copyResultToGalley(mContext, imageUrl, galleryUri);
        if (result) {
            Intent mediaScanIntent = new Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE);
            mediaScanIntent.setData(galleryUri);
            sendBroadcast(mediaScanIntent);
        }
    }複製程式碼

copyResultToGalley 方法的實現很簡單,就是將imageUri 這個地址的檔案複製到galleryUri 這個地址,複製成功後傳送一條
action="ACTION_MEDIA_SCANNER_SCAN_FILE" 的廣播即可。

好了,關於Bitmap的初探就說到這裡,對於上面提到的各種壓縮方案,有興趣的同學可結合一下demo測試。Github 地址

Android Bitmap 初探

總結

用了很久的ImageView,發現Bitmap才是Android中影象處理最核心的東西,有很多東西值得去深入瞭解。

相關文章