Android Bitmap實戰技巧

希爾瓦娜斯女神發表於2015-10-15

注:本文大量參考谷歌官方文件自http://developer.android.com/intl/zh-cn/training/displaying-bitmaps/index.html。如果你自學能力還可以或者英文理解能力不錯可以直接去看原版的。

如果你時間寶貴,想直接看結論和我個人理解的心得,也可以繼續往下看。此外要著重說一下,現在網上其實有很多庫,包括facebook的fresco啊,square的那些android 上的圖片處理庫

基本上都幫我們把這些事情做好了。但是原理大致上是相同的,如果你只想最簡單的呼叫一下他們的api的話,其實這個文章可以不用看的,如果你想改寫他們的庫,或者自己寫一個輕量級的庫

這個文章還是挺有用的。

1.首先我們來看看載入大圖片的問題。

假設我們有一臺galaxy nexus手機,你看啊,用他拍照 一張畫素 2592*1936畫素,如果我們用 http://developer.android.com/intl/zh-cn/reference/android/graphics/Bitmap.Config.html

ARGB_8888來載入這個圖片,也就是一個畫素點 用4個byte來表示的話 就是 2592*1936*4 大概是19mb的記憶體,一張圖片19mb啊~~當然現在android機器900元左右的記憶體都很大,差不多每個app

能有64mb的記憶體使用,但是你一個圖片就將近20mb,就有點不講道理了。

好,我們先看看第一段程式碼的解析:

 1 BitmapFactory.Options options = new BitmapFactory.Options();
 2         //這個屬性設定為true就是deocde的時候 返回的bitmap是null,但是這種decode方法
 3         //無論你原始圖片有多大,哪怕是一億畫素 都不會oom!他的作用就是可以利用這個屬性
 4         //去讀取你原始圖片的資訊,注意是原始圖片,而不是系統載入過的圖片,我們都知道
 5         //如果你把一張圖片放在mdpi的下面,手機是xxhdpi的話 圖片在顯示的過程中會自動放大
 6         //但是在這裡用這個屬性的時候 是不care 你圖片放在哪個路徑下的,也不care你手機的dpi
 7         //他就只單純的關心原始圖片的原始屬性
 8         options.inJustDecodeBounds = true;
 9         BitmapFactory.decodeResource(getResources(), R.mipmap.dd
10                 , options);
11         //原始圖片的寬高。
12         int height = options.outHeight;
13         int width = options.outWidth;
14         //這個按照通俗的理解就是 把圖片的字尾名告訴你 比如jpeg png 這種
15         String imageType = options.outMimeType;

那這段程式碼有什麼用呢?實際上可以用他作為圖片縮放的基準標準。我們可以想一下,假設我們現在有一張1024*768的圖片。

但是我們給他的顯示區域 算出來 只有128*96。你說在這種情況下,你從resource解析出來 的bitmap還是1024*768.不是很蠢麼?

我們可以算一下 縮放對圖片佔用記憶體大小的貢獻。

我們假設現在有一張圖片是2048*1536,我們用argb8888來解析他,那他佔的記憶體是多少呢?就是2048*1536*4/1024=12.28mb,

假設我們現在縮放4倍,那就是512*384*4/1024=0.768mb.相差了16倍。

有些人可能理解不透這一點。我現在用個極簡的例子來說明下:

<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/root"
    android:layout_margin="30dp"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="@android:color/holo_red_light"
    tools:context=".MainActivity">

    <ImageView
        android:layout_width="10dp"
        android:layout_height="10dp"
        android:id="@+id/iv"
        android:src="@drawable/gg"/>

</FrameLayout>

你看啊,我用的這個圖片gg 是一張1920*1080 畫素的高清大圖,載入出來以後佔用記憶體 整個app大概是49.85mb!但是你發現沒有,我們的iv 寬高都是10dp啊 沒多大,這個就是顯示的時候極大的浪費了。

當然了 你就算把寬高全部改成wrap_content 甚至是match 佔用的記憶體也是49.85mb 不會有任何區別的~。也就是說imageview 等系統控制元件 在載入圖片的時候 是不會幫你在bitmap層面上進行縮放的

他縮放只是matrix縮放,對記憶體佔用是沒有任何影響的,這一點一定要注意。那當然了,我們一般 在顯示一張圖片的時候 是可以估算他的大小的,位置什麼的 也可以固定,所以在顯示大圖的時候 我們還是

最好對他進行縮放,比如這裡 我們只想讓這個圖 顯示10dp 的區域大小麼,在我這個手機上dpi的尺寸的話 也就是20*20 畫素點的區域了,所以我們就手動載入一張大約20*20的畫素圖 就可以極大節省我們的記憶體了

public static Bitmap decodeSampledBitmapFromResource(Resources res, int resId,
                                                         int reqWidth, int reqHeight) {

        // 先把inJustDecodeBounds設定為true 取得原始圖片的屬性
        final BitmapFactory.Options options = new BitmapFactory.Options();
        options.inJustDecodeBounds = true;
        BitmapFactory.decodeResource(res, resId, options);

        // 然後算一下我們想要的最終的屬性
        options.inSampleSize = calculateInSampleSize(options, reqWidth, reqHeight);

        // 在decode的時候 別忘記直接 把這個屬性改為false 否則decode出來的是null
        options.inJustDecodeBounds = false;
        return BitmapFactory.decodeResource(res, resId, options);
    }
    public static int calculateInSampleSize(
            BitmapFactory.Options options, int reqWidth, int reqHeight) {
        // 先從options 取原始圖片的 寬高
        final int height = options.outHeight;
        final int width = options.outWidth;
        int inSampleSize = 1;

        if (height > reqHeight || width > reqWidth) {

            final int halfHeight = height / 2;
            final int halfWidth = width / 2;

            //一直對這個圖片進行寬高 縮放,每次都是縮放1倍,然後這麼疊加,當發現疊加以後 也就是縮放以後的寬或者高小於我們想要的寬高
            //這個縮放就結束 跳出迴圈 然後就可以得到我們極限的inSampleSize值了。
            while ((halfHeight / inSampleSize) > reqHeight
                    && (halfWidth / inSampleSize) > reqWidth) {
                inSampleSize *= 2;
            }
        }

        return inSampleSize;
    }

然後開始載入:

1  iv=(ImageView)this.findViewById(R.id.iv);
2         //這種載入方式最終我們的app 佔用記憶體大小僅僅是9.85mb左右,而下面那個註釋掉的載入方式,就和你在xml裡直接寫id的方式是一樣的
3         //佔用記憶體將近50mb!
4         iv.setImageBitmap(decodeSampledBitmapFromResource(getResources(),R.drawable.gg,20,20));
5         //iv.setImageBitmap(BitmapFactory.decodeResource(getResources(),R.drawable.gg));

 

2.如何正確載入Bitmap。

上文,我們講述了 如何在android裡 正確的載入大圖,但是實際上那部分程式碼還是有不完善的地方,我們都知道bitmap的decode方法 有很多種,除了能decode本地的資源圖片以外,還可以decode byte。

直接了當的說 就是可以decode 流,可以從網路中獲取圖片。試想一下 如果還是按照我們上文所說的直接在ui 執行緒裡decode 那就很容易發生anr了。

於是有人就說 我們可以用aysnctask。然後很多新手就會這麼寫:

 1  class BitmapWorkerTask extends AsyncTask<Integer, Void, Bitmap> {
 2         private final ImageView iv;
 3         private int data = 0;
 4 
 5         public BitmapWorkerTask(ImageView imageView) {
 6             iv = imageView;
 7         }
 8 
 9         // Decode image in background.
10         @Override
11         protected Bitmap doInBackground(Integer... params) {
12             data = params[0];
13             return decodeSampledBitmapFromResource(getResources(), data, 500, 500);
14         }
15 
16         @Override
17         protected void onPostExecute(Bitmap bitmap) {
18             iv.setImageBitmap(bitmap);
19         }
20     }

可以看一下這段程式碼有什麼問題,首先你這個task 是一個內部類,大家都知道內部類物件是持有外部類的引用的。我們可以設想一個場景,假設你doInBackGround 這個方法裡 decode 是從網路中decode 耗時10s

好,這個時候使用者點選跳轉 跳轉到你這個介面了,然後不到10s中 他又點了返回,此時你的邏輯是點選返回 就finish這個activity。但是此時這個task還在後臺跑,他裡面還持有著這個imageview的強引用!

這回導致什麼問題?這就會導致這個activity永遠釋放不掉了,這是很嚴重的記憶體洩露。

所以建議的寫法是:

 1  class BitmapWorkerTask extends AsyncTask<Integer, Void, Bitmap> {
 2         private final WeakReference<ImageView> imageViewReference;
 3         private int data = 0;
 4 
 5         public BitmapWorkerTask(ImageView imageView) {
 6             // 用弱引用來關聯這個imageview。大家一定要記住,弱引用是避免android 在各種callback回撥裡發生記憶體洩露的最佳方法!
 7             //而軟引用則是做快取的最佳方法 兩者不要搞混了!
 8             imageViewReference = new WeakReference<ImageView>(imageView);
 9         }
10 
11         // Decode image in background.
12         @Override
13         protected Bitmap doInBackground(Integer... params) {
14             data = params[0];
15             return decodeSampledBitmapFromResource(getResources(), data, 100, 100);
16         }
17 
18         @Override
19         protected void onPostExecute(Bitmap bitmap) {
20             //當你background執行緒跑完以後 先看看imageview還在不在,不在 就什麼也不做 等著系統回收他的資源
21             //在的話 再賦值
22             if (imageViewReference != null && bitmap != null) {
23                 final ImageView imageView = imageViewReference.get();
24                 if (imageView != null) {
25                     imageView.setImageBitmap(bitmap);
26                 }
27             }
28         }
29     }

好 到這裡看上去 已經比較完美了,但是在很早以前 那些開源控制元件出來之前,在顯示一個以圖片imageview 為主的listview或者gridview的時候 這種方法 會有很嚴重的問題。

因為這會導致 圖片顯示錯亂。我們可以想象一種場景,假設你一屏 顯示5個imageview對吧,按照我們剛才的方法就是5個task 在跑。跑完的時候 5個imageview 分別set

他們自己的bitmap。但是。很多時候會發生這樣一種情況。當你這5個task 還在跑的時候,使用者又滑動了,比如一開始是標號0-4的 5個imageview 在螢幕中。

然後你有5個task在跑。還沒有跑完。此時使用者滑動了。0這個imageview 出去了,新進來一個標號為5的imageview。假設我們標號為0的imageview是想顯示圖片a的,

標號為5的imageview是想顯示圖片B的。當你滑動的時候 0的task還沒有跑完,5的imageview剛準備進來,注意啊,0滑出去的時候 這個imageview是沒有被系統回收的

而是進入的listview的 回收站了,此時進來的5 實際上就是listview 回收站裡的0. 當你標號為5的imageview 完全進入的時候,此時1開始標號為0的那個task跑完了。

那你5顯示的圖片就是a了。。雖然最終可能5的task跑完如果5還在介面上,最終還是會顯示b,但是這樣做的體驗就太2了。而且一堆錯誤。

谷歌呢,也就順勢給了我們一種官方的解決方法,大家可以參考一下。我略做註釋:

 1   //在listview或者gridview的getview方法裡 我們就可以直接呼叫這個方法了
 2     public void loadBitmap(int resId, ImageView imageView) {
 3         //如果取得的task為空 就代表這個iv是新的iv 不是從listview回收站裡取的 就可以新建一個task 然後
 4         //用這個task 去新建一個drawable。然後用這個新的imageview去set 這個drawable即可
 5         if (cancelPotentialWork(resId, imageView)) {
 6             final BitmapWorkerTask task = new BitmapWorkerTask(imageView);
 7             final AsyncDrawable asyncDrawable =
 8                     new AsyncDrawable(getResources(), mPlaceHolderBitmap, task);
 9             imageView.setImageDrawable(asyncDrawable);
10             task.execute(resId);
11         }
12     }
13 
14     //從imageview裡取得他的drawable。然後從取得的drawable裡取得他的task
15     //這裡實際上就可以看出來imageview-drawable-task是1對1的關係了
16     private static BitmapWorkerTask getBitmapWorkerTask(ImageView imageView) {
17         if (imageView != null) {
18             final Drawable drawable = imageView.getDrawable();
19             if (drawable instanceof AsyncDrawable) {
20                 final AsyncDrawable asyncDrawable = (AsyncDrawable) drawable;
21                 return asyncDrawable.getBitmapWorkerTask();
22             }
23         }
24         return null;
25     }
26 
27     public static boolean cancelPotentialWork(int data, ImageView imageView) {
28         final BitmapWorkerTask bitmapWorkerTask = getBitmapWorkerTask(imageView);
29         //如果這個task 不為空 就代表這個iv已經有task了 那這種情況
30         if (bitmapWorkerTask != null) {
31             final int bitmapData = bitmapWorkerTask.data;
32             // 如果這個task還沒有跑完 那就直接cancel這個task。因為沒有跑完就肯定是iv 還沒有設定值,所以直接cancel
33             //cancel以後就跳出這個括號 直接返回true了,等同於這個iv是一個新的iv 可以重新繫結新的task
34             if (bitmapData == 0 || bitmapData != data) {
35                 // Cancel previous task
36                 bitmapWorkerTask.cancel(true);
37             } else {
38                 // 如果已經跑完了 那就別繫結了否則會錯亂的。所以返回false把 這裡返回false loadBitmap就什麼都不做的。圖形就從根本上
39                 //不會錯亂了。
40                 return false;
41             }
42         }
43         // task為空的話就返回true了。
44         return true;
45     }

當然了 task 我們也要略微最終調整一下:

 1 class BitmapWorkerTask extends AsyncTask<Integer, Void, Bitmap> {
 2     ...
 3 
 4     @Override
 5     protected void onPostExecute(Bitmap bitmap) {
 6         if (isCancelled()) {
 7             bitmap = null;
 8         }
 9 
10         if (imageViewReference != null && bitmap != null) {
11             final ImageView imageView = imageViewReference.get();
12             final BitmapWorkerTask bitmapWorkerTask =
13                     getBitmapWorkerTask(imageView);
14             if (this == bitmapWorkerTask && imageView != null) {
15                 imageView.setImageBitmap(bitmap);
16             }
17         }
18     }
19 }

 

3.圖片快取 http://developer.android.com/intl/zh-cn/training/displaying-bitmaps/cache-bitmap.html 這個我就不細講了,網上資料太多了。有興趣的可以自己看一下,開源的那些框架使用的技術原理實際上也就是這個,大差不差。

臉書的fresco 比這個稍微高階一些。貌似是在native層進行記憶體管理的。

 

4.管理Bitmap的記憶體。

這個要分成2個部分來講。 在3.0 以前的版本bitmap的記憶體 就是各自存放的,唯一的區別就是2.2的時候 bitmap 還存在native裡,而2.3 就一起存放在java heap裡了。我們那會釋放bitmap記憶體的時候 都是呼叫recyle這個方法的。

但是很多時候 我們很多地方會複用一張圖片,要知道 bitmap的建立和銷燬 是要很多開銷的。所以 我們實際上可以自定義一個drawable 到實在沒有人用他的時候 我們在通過這個drawble來recyle掉 bitmap 的記憶體。

 1 //其實這裡程式碼思路很簡單的,就是擴充套件了一下drawable而已。你每次指定他顯示 或者暫時做快取的時候
 2 //就改動一下計數器,然後check他的狀態,在歸0的時候 就可以徹底recyle這個資源了
 3 public class RecyclingBitmapDrawable extends BitmapDrawable {
 4 
 5     static final String TAG = "CountingBitmapDrawable";
 6 
 7     private int mCacheRefCount = 0;
 8     private int mDisplayRefCount = 0;
 9 
10     private boolean mHasBeenDisplayed;
11 
12     public RecyclingBitmapDrawable(Resources res, Bitmap bitmap) {
13         super(res, bitmap);
14     }
15 
16     
17     public void setIsDisplayed(boolean isDisplayed) {
18         synchronized (this) {
19             if (isDisplayed) {
20                 mDisplayRefCount++;
21                 mHasBeenDisplayed = true;
22             } else {
23                 mDisplayRefCount--;
24             }
25         }
26 
27         checkState();
28     }
29 
30     
31     
32     public void setIsCached(boolean isCached) {
33         synchronized (this) {
34             if (isCached) {
35                 mCacheRefCount++;
36             } else {
37                 mCacheRefCount--;
38             }
39         }
40 
41         checkState();
42     }
43 
44     private synchronized void checkState() {
45       
46         if (mCacheRefCount <= 0 && mDisplayRefCount <= 0 && mHasBeenDisplayed
47                 && hasValidBitmap()) {
48             if (BuildConfig.DEBUG) {
49                 Log.d(TAG, "No longer being used or cached so recycling. "
50                         + toString());
51             }
52 
53             getBitmap().recycle();
54         }
55     }
56 
57     private synchronized boolean hasValidBitmap() {
58         Bitmap bitmap = getBitmap();
59         return bitmap != null && !bitmap.isRecycled();
60     }
61 
62 }

 

 那在3.0以後,因為bitmap 都在 java層的 heap中處理了,所以你要釋放一個bitmap 只要將引用置為null 就行了 不需要如此麻煩,除此之外3.0以後的版本 還提供了一個很好用的引數 叫

options.inBitmap。

實際上總結起來就是,如果你使用了這個屬性,那麼使用這個屬性的decode過程中 會直接參考 inBitmap 所引用的那塊記憶體,,大家都知道 很多時候ui卡頓是因為gc 操作過多而造成的。使用這個屬性 能避免大記憶體塊的申請和釋放。帶來的好處就是gc 操作的數量減少。這樣cpu會有更多的時間 做ui執行緒,介面會流暢很多,同時還能節省大量記憶體!

 

使用inBitmap屬性以後:

 

 

 

 

1  final BitmapFactory.Options options = new BitmapFactory.Options();
2         options.inSampleSize = 1;
3         options.inMutable = true;

注意第三行 一定要設定為true 這樣返回的bitmap 才是mutable 也就是可重用的,否則是不能重用的。這個屬性你以後設定了也沒用的。

看如下程式碼:

 1 final BitmapFactory.Options options = new BitmapFactory.Options();
 2         //size必須為1 否則是使用inBitmap屬性會報異常
 3         options.inSampleSize = 1;
 4         //這個屬性一定要在用在src Bitmap decode的時候 不然你再使用哪個inBitmap屬性去decode時候會在c++層面報異常
 5         //BitmapFactory: Unable to reuse an immutable bitmap as an image decoder target.
 6         options.inMutable = true;
 7         inBitmap2 = BitmapFactory.decodeFile(path1,options);
 8         iv.setImageBitmap(inBitmap2);
 9         options.inBitmap = inBitmap2;
10         long start=System.currentTimeMillis();
11         iv2.setImageBitmap(BitmapFactory.decodeFile(path2,options));
12         iv3.setImageBitmap(BitmapFactory.decodeFile(path3,options));
13         iv4.setImageBitmap(BitmapFactory.decodeFile(path4,options));

此時佔用的記憶體大概是11mb 

如果我們把第九行註釋掉: 發現佔用記憶體暴增到18mb。

此外就是版本號不同 inBitmap使用要注意的地方稍微也不同。英文原版如下:

Android 3.0 (API level 11) introduces the BitmapFactory.Options.inBitmap field. If this option is set, decode methods that take the Options object will attempt to reuse an existing bitmap when loading content. This means that the bitmap's memory is reused, resulting in improved performance, and removing both memory allocation and de-allocation. However, there are certain restrictions with how inBitmap can be used. In particular, before Android 4.4 (API level 19), only equal sized bitmaps are supported. For details, please see the inBitmap documentation.

簡單來說 就是4.4 以前 你要使用這個屬性 那圖片大小必須一樣,但是4.4 以後只要decode的圖片 比inBitmap的圖片要小 就可以使用這個屬性了。

但是這個屬性在使用的時候一定要當心:

如果你不同的imageview 使用的scaletype 不同,但是你這些不同的imageview的bitmap 在decode時候 如果都是引用的同一個inBitmap的話,

這些圖片會相互影響,所以大家一定要注意,使用inBitmap這個屬性的時候 一定要小心小心再小心。

最後如果谷歌的官方教程 DisplayBitmaps 這個demo 你如果能完全吃透的話,相信你對bitmap操作就完全沒有問題了,如果有閱讀原始碼困難的同學可以在留言裡告訴我

人數多的話 我會再寫一篇文章 幫助分析DisplayBitmaps 這個官方demo裡的所有細節幫助大家理解。

 

相關文章