一、背景
在Android開發中,任何一個APP都離不開圖片的載入和顯示問題。這裡的圖片來源分為三種:專案圖片資原始檔(一般為res/drawable目錄下的圖片檔案)、手機本地圖片檔案、網路圖片資源等。圖片的顯示我們一般採用ImageView作為載體,通過ImageView的相應API即可設定其顯示的圖片內容。
我們知道:如果是需要展示專案中的圖片資原始檔,我們只需要呼叫ImageView的setImageResource(int id)方法並傳入該圖片資源的id(一般為R.drawable.xxx)即可。但是如果是需要展示手機本地的某張圖片或者網路上的某個圖片資源,又該怎麼辦呢?——問題A
為了回答問題A,我們先思考一個更深的問題B:Android中是如何將某一張圖片的內容載入到記憶體中繼而由ImageView顯示的呢?
我們知道:如果我們想通過TextView展示一個本地txt檔案的內容,我們只需要由該檔案建立幷包裝一個輸入流物件。通過該輸入流物件即可得到一個代表該檔案內容的字串物件,再將該字串物件交由TextView展示即可。換句話說,這個txt檔案的內容在記憶體中的表達形式就是這個字串物件。
類推一下,雖然圖片檔案也是檔案,但是我們顯然不可能對圖片檔案也採用這種方式:即通過該圖片建立幷包裝一個輸入流物件再獲取一個字串物件。畢竟無論如何我們都無法將某個圖片的內容表示為一個字串物件(細想一下就知道了,你能通過一段話100%準確地描述一張圖片嗎?顯然不現實)。那麼,這就引入了問題C:既然字串物件不行,那麼我們該以哪種物件來在記憶體中表示某個圖片的內容呢?答案就是:Bitmap物件!
二、基本概述
Bitmap,即點陣圖。它本質上就是一張圖片的內容在記憶體中的表達形式。那麼,Bitmap是通過什麼方式表示一張圖片的內容呢?
Bitmap原理:從純數學的角度,任何一個面都由無數個點組成。但是對於圖片而言,我們沒必要用無數個點來表示這個圖片,畢竟單獨一個微小的點人類肉眼是看不清的。換句話說,由於人類肉眼的能力有限,我們只需要將一張圖片表示為 有限但足夠多的點即可。點的數量不能無限,因為無限的點資訊量太大無法儲存;但是點的數量也必須足夠多,否則視覺上無法形成連貫性。這裡的點就是畫素。比如說,某個1080*640的圖片,這裡的畫素總數即為1080X640個。
將圖片內容表示為有限但足夠多的畫素的集合,這個“無限→有限”的思想極其迷人。所以,我們只需要將每個畫素的資訊儲存起來,就意味著將整個圖片的內容進行了表達。
畫素資訊:每個畫素的資訊,無非就是ARGB四個通道的值。其中,A代表透明度,RGB代表紅綠藍三種顏色通道值。每個通道的值範圍在0~255之間,即有256個值,剛好可以通過一個位元組(8bit)進行表示。所以,每個通道值由一個位元組表示,四個位元組表示一個畫素資訊,這似乎是最好的畫素資訊表示方案。
但是這裡忽略了兩個現實的需求問題:
①在實際需求中,我們真的需要這麼多數量的顏色嗎?上述方案是256X256X256種。有的時候,我們並不需要這麼豐富的顏色數量,所以可以適當減少表示每個顏色通道的bit位數。這麼做的好處是節省空間。也就是說,每個顏色通道都採用8bit來表示是代表全部顏色值的集合;而我們可以採用少於8bit的表示方式,儘管這會缺失一部分顏色值,但是隻要顏色夠用即可,並且這還可以節省記憶體空間。
②我們真的需要透明度值嗎?如果我們需要某個圖片作為背景或者圖示,這個圖片透明度A通道值是必要的。但是如果我們只是普通的圖片展示,比如拍攝的照片,透明度值毫無意義。細想一下,你希望你手機自拍的照片透明或者半透明嗎?hell no! 因此,透明度這個通道值是否有必要表示也是根據需求自由變化的。
具體每個畫素點儲存ARGB值的方案介紹,後面會詳細介紹。
總結:Bitmap物件本質是一張圖片的內容在記憶體中的表達形式。它將圖片的內容看做是由儲存資料的有限個畫素點組成;每個畫素點儲存該畫素點位置的ARGB值。每個畫素點的ARGB值確定下來,這張圖片的內容就相應地確定下來了。
現在回答一下問題A和問題B:Android就是將所有的圖片資源(無論是何種來源)的內容以Bitmap物件的形式載入到記憶體中,再通過ImageView的setImageBitmap(Bitmap b)方法即可展示該Bitmap物件所表示的圖片內容。
三、詳細介紹
1、Bitmap.Config
Config是Bitmap的一個列舉內部類,它表示的就是每個畫素點對ARGB通道值的儲存方案。取值有以下四種:
ARGB_8888:這種方案就是上面所說的每個通道值採8bit來表示,每個畫素點需要4位元組的記憶體空間來儲存資料。該方案圖片質量是最高的,但是佔用的記憶體也是最大的
ARGB_4444:這種方案每個通道都是4位,每個畫素佔用2個位元組,圖片的失真比較嚴重。一般不用這種方案。
RGB_565:這種方案RGB通道值分別佔5、6、5位,但是沒有儲存A通道值,所以不支援透明度。每個畫素點佔用2位元組,是ARGB_8888方案的一半。
ALPHA_8:這種方案不支援顏色值,只儲存透明度A通道值,使用場景特殊,比如設定遮蓋效果等。
比較分析:一般我們在ARGB_8888方式和RGB_565方式中進行選取:不需要設定透明度時,比如拍攝的照片等,RGB_565是個節省記憶體空間的不錯的選擇;既要設定透明度,對圖片質量要求又高,就用ARGB_8888。
2、Bitmap的壓縮儲存
Bitmap是圖片內容在記憶體中的表示形式,那麼如果想要將Bitmap物件進行持久化儲存為一張本地圖片,需要對Bitmap物件表示的內容進行壓縮儲存。根據不同的壓縮演算法可以得到不同的圖片壓縮格式(簡稱為圖片格式),比如GIF、JPEG、BMP、PNG和WebP等。這些圖片的(壓縮)格式可以通過圖片檔案的字尾名看出。
換句話說:Bitmap是圖片在記憶體中的表示,GIF、JPEG、BMP、PNG和WebP等格式圖片是持久化儲存後的圖片。記憶體中的Bitmap到磁碟上的GIF、JPEG、BMP、PNG和WebP等格式圖片經過了”壓縮”過程,磁碟上的GIF、JPEG、BMP、PNG和WebP等格式圖片到記憶體中的Bitmap經過了“解壓縮”的過程。
那麼,為什麼不直接將Bitmap物件進行持久化儲存而是要對Bitmap物件進行壓縮儲存呢?這麼做依據的思想是:當圖片持久化儲存在磁碟上時,我們應該儘可能以最小的體積來儲存同一張圖片的內容,這樣有利於節省磁碟空間;而當圖片載入到記憶體中以顯示的時候,應該將磁碟上壓縮儲存的圖片內容完整地展開。前者即為壓縮過程,目的是節省磁碟空間;後者即為解壓縮過程,目的是在記憶體中展示圖片的完整內容。
3、有失真壓縮和無失真壓縮
Bitmap壓縮儲存時的演算法有很多種,但是整體可分為兩類:有失真壓縮和無失真壓縮。
無可否認,利用有失真壓縮技術可以在點陣圖持久化儲存的過程中大大地壓縮圖片的儲存大小,但是會影響影象質量,這一點在壓縮率很高時尤其明顯。所以需要選擇恰當的壓縮率。
②無失真壓縮
無失真壓縮的基本原理是:相同的顏色資訊只需儲存一次。具體過程是:首先會確定影象中哪些區域是相同的,哪些是不同的。包括了重複資料的區域就可以被壓縮,只需要記錄該區域的起始點即可。
從本質上看,無失真壓縮的方法通過刪除一些重複資料,也能在點陣圖持久化儲存的過程中減少要在磁碟上儲存的圖片大小。但是,如果將該圖片重新讀取到記憶體中,重複資料會被還原。因此,無失真壓縮的方法並不能減少圖片的記憶體佔用量,如果要減少圖片佔用記憶體的容量,就必須使用有失真壓縮方法。
無失真壓縮方法的優點是能夠比較好地儲存影象的質量,但是相對來說這種方法的壓縮率比較低。
該圖片在記憶體中所佔大小為:100 * 100 * (32 / 8) Byte
在檔案中所佔大小為 100 * 100 * ( 24/ 8 ) * 壓縮率 Byte
compress(Bitmap.CompressFormat format, int quality, OutputStream stream)
介紹一下比較不好理解的屬性:
①inJustDecodeBounds:這個屬性表示是否只掃描輪廓,預設為false。如果該屬性為true,decodeXXXX方法不會返回一個Bitmap物件(即不會為Bitmap分配記憶體)而是返回null。那如果decodeXXXX方法不再分配記憶體以建立一個Bitmap物件,那麼還有什麼用呢?答案就是:掃描輪廓。
BitmapFactory.Options物件的outWidth和outHeight屬性分別代表Bitmap物件的寬和高,但是這兩個屬性在Bitmap物件未建立之前顯然預設為0,預設只有在Bitmap物件建立後才能被賦予正確的值。而當inJustDecodeBounds屬性為true,雖然不會分配記憶體建立Bitmap物件,但是會掃描輪廓來給outWidth和outHeight屬性賦值,就相當於繞過了Bitmap物件建立的這一步提前獲取到Bitmap物件的寬高值。那這個屬性到底有啥用呢?具體用處體現在Bitmap的取樣率計算中,後面會詳細介紹。
②inSample:這個表示Bitmap的取樣率,預設為1。比如說有一張圖片是2048畫素X1024畫素,那麼預設情況下該圖片載入到記憶體中的Bitmap物件尺寸也是2048畫素X1024畫素。如果採用的是ARGB_8888方式,那麼該Bitmap物件載入所消耗的記憶體為2048X1024X4/1024/1024=8M。這只是一張圖片消耗的記憶體,如果當前活動需要載入幾張甚至幾十張圖片,那麼會導致嚴重的OOM錯誤。
OOM錯誤:儘管Android裝置記憶體大小可能達到好幾個G(比如4G),但是Andorid中每個應用其執行記憶體都有一個閾值,超過這個閾值就會引發out of memory即OOM錯誤(記憶體溢位錯誤)。因為現在市場上流行的手機裝置其作業系統都是在Andori原生作業系統基礎上的擴充,所以不同的裝置環境中這個記憶體閾值不一樣。可以通過以下方法獲取到當前應用所分配的記憶體閾值大小,單位為位元組: Runtime.getRuntime().maxMemory();
儘管我們確實可以通過設定來修改這個閾值大小以提高應用的最大分配記憶體(具體方式是在在Manifest中設定android.largeHeap="true"),但是需要注意的是:記憶體是一種很寶貴的資源,不加考慮地無腦給每個應用提高最大分配記憶體是一個糟糕的選擇。因為手機總記憶體相比較每個應用預設的最大分配記憶體雖然高很多,但是手機中的應用數量是非常多的,每個應用都修改其執行記憶體閾值為幾百MB甚至一個G,這很嚴重影響手機效能!另外,如果應用的最大分配記憶體很高,這意味著其垃圾回收工作也會變得更加耗時,這也會影響應用和手機的效能。所以,這個方案需要慎重考慮不能濫用。
關於這個方案的理解可以參考一位大神的解釋:“在一些特殊的情景下,你可以通過在manifest的application標籤下新增largeHeap=true的屬性來為應用宣告一個更大的heap空間。然後,你可以通過getLargeMemoryClass()來獲取到這個更大的heap size閾值。然而,宣告得到更大Heap閾值的本意是為了一小部分會消耗大量RAM的應用(例如一個大圖片的編輯應用)。不要輕易的因為你需要使用更多的記憶體而去請求一個大的Heap Size。只有當你清楚的知道哪裡會使用大量的記憶體並且知道為什麼這些記憶體必須被保留時才去使用large heap。因此請謹慎使用large heap屬性。使用額外的記憶體空間會影響系統整體的使用者體驗,並且會使得每次gc的執行時間更長。在任務切換時,系統的效能會大打折扣。另外, large heap並不一定能夠獲取到更大的heap。在某些有嚴格限制的機器上,large heap的大小和通常的heap size是一樣的。因此即使你申請了large heap,你還是應該通過執行getMemoryClass()來檢查實際獲取到的heap大小。”
綜上,我們已經知道了Bitmap的載入是一個很耗記憶體的操作,特別是在大點陣圖的情況下。這很容易引發OOM錯誤,而我們又不能輕易地通過修改或提供應用的記憶體閾值來避免這個錯誤。那麼我們該怎麼做呢?答案就是:利用這裡所說的取樣率屬性來建立一個原Bitmap的子取樣版本。這也是官方推薦的對於大點陣圖載入的OOM問題的解決方案。其具體思想為:比如還是那張尺寸為2048畫素X1024畫素圖片,在inSample值預設為1的情況下,我們現在已經知道它載入到記憶體中預設是一個2048畫素X1024畫素大點陣圖了。我們可以將inSample設定為2,那麼該圖片載入到記憶體中的點陣圖寬高都會變成原寬高的1/2,即1024畫素X512畫素。進一步,如果inSample值設定為4,那麼點陣圖尺寸會變成512畫素X256畫素,這個時候該點陣圖所消耗的記憶體(假設還是ARGB_8888方式)為512X256X4/1024/1024=0.5M,可以看出從8M到0.5M,這極大的節省了記憶體資源從而避免了OOM錯誤。
切記:官方對於inSample值的要求是,必須為2的冪,比如2、4、8...等整數值。
這裡會有兩個疑問:第一:通過設定inSample屬性值來建立一個原大點陣圖的子取樣版本的方式來降低記憶體消耗,聽不上確實很不錯。但是這不會導致圖片嚴重失真嗎?畢竟你丟失了那麼多畫素點,這意味著你丟失了很多顏色資訊。對這個疑問的解釋是:儘管在取樣的過程確實會丟失很多畫素點,但是原點陣圖的尺寸也在減小,其畫素密度是不變的。比如說如果inSample值為2,那麼子取樣版本的畫素點數量是原來的1/4,但是子取樣版本的顯示尺寸(區域面積)也會變成原來的1/4,這樣的話畫素密碼是不變的因此圖片不用擔心嚴重失真問題。第二:inSample值如何選取才是最佳?這其實取決於ImageView的尺寸,具體取樣率的計算方式後面會詳細介紹。
③inPreferredConfig:該屬性指定Bitmap的色深值,該屬性型別為Bitmap.Config值。
例如你可以指定某圖片載入為Bitmap物件的色深模式為ARGB_8888,即:options.inPreferredConfig=Bitmap.Config.ARGB_8888;
④isMutable:該屬性表示通過decodeXXXX方法建立的Bitmap物件其代表的圖片內容是否允許被外部修改,比如利用Canvas重新繪製其內容等。預設為false,即不允許被外部操作修改。
利用這些屬性定製BitmapFactory.Options物件,從而靈活地按照自己的需求配置建立的Bitmap物件。
五、Bitmap的進階使用
1、高效地載入大點陣圖
上面剛說了大點陣圖載入時的OOM問題,解決方式是通過inSample屬性建立一個原點陣圖的子取樣版本以減低記憶體。那麼這裡的取樣率inSample值如何選取最好呢?這裡我們利用官方推薦的取樣率最佳計算方式:基本步驟就是:①獲取點陣圖原尺寸 ②獲取ImageView即最終圖片顯示的尺寸 ③依據兩種尺寸計算取樣率(或縮放比例)。
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; //當要顯示的目標大小和影象的實際大小比較接近時,會產生沒必要的取樣,先除以2再判斷以防止過度取樣 while ((halfHeight / inSampleSize) >= reqHeight && (halfWidth / inSampleSize) >= reqWidth) { inSampleSize *= 2; } } return inSampleSize; }
依據上面的最佳取樣率計算方法,進一步可以封裝出利用最佳取樣率建立子取樣版本再建立點陣圖物件的方法,這裡以從專案圖片資原始檔載入Bitmap物件為例:
public static Bitmap decodeSampledBitmapFromResource(Resources res, int resId, int reqWidth, int reqHeight) { final BitmapFactory.Options options = new BitmapFactory.Options(); options.inJustDecodeBounds = true; //因為inJustDecodeBounds為true,所以不會建立Bitmap物件只會掃描輪廓從而給options物件的寬高屬性賦值 BitmapFactory.decodeResource(res, resId, options); // 計算最佳取樣率 options.inSampleSize = calculateInSampleSize(options, reqWidth, reqHeight); // 記得將inJustDecodeBounds屬性設定回false值 options.inJustDecodeBounds = false; return BitmapFactory.decodeResource(res, resId, options); }
2、Bitmap載入時的非同步問題
由於圖片的來源有三種,如果是專案圖片資原始檔的載入,一般採取了子取樣版本載入方案後不會導致ANR問題,畢竟每張圖載入消耗的記憶體不會很大了。但是對於本地圖片檔案和網路圖片資源,由於分別涉及到檔案讀取和網路請求,所以屬於耗時操作。為了避免ANR的產生,必須將圖片載入為Bitmap物件的過程放入工作執行緒中;獲取到Bitmap物件後再回到UI執行緒設定ImageView的顯示。舉個例子,如果採用AsyncTask作為我們的非同步處理方案,那麼程式碼如下:
class BitmapWorkerTask extends AsyncTask<Integer, Void, Bitmap> { private final ImageView iv; private int id = 0; public BitmapWorkerTask(ImageView imageView) { iv = imageView; } // Decode image in background. @Override protected Bitmap doInBackground(Integer... params) { id = params[0]; //假設ImageView尺寸為500X500,為了方便還是以專案資原始檔的載入方式為例,因為這可以複用上面封裝的方法 return decodeSampledBitmapFromResource(getResources(), id, 500, 500); } @Override protected void onPostExecute(Bitmap bitmap) { iv.setImageBitmap(bitmap); } }
該方案中,doInBackground方法執行在子執行緒,用來處理 ”圖片檔案讀取操作+Bitmap物件的高效載入操作” 或 ”網路請求圖片資源操作+Bimap物件的高效載入操作”等兩種情形下的耗時操作。onPostExecute方法執行在UI執行緒,用於設定ImageView的顯示內容。看上去這個方案很完美,但是有一個很隱晦的嚴重問題:
由當前活動啟動了BitmapWorkerTask任務後:當我們退出當前活動時,由於非同步任務只依賴於UI執行緒所以BitmapWorkerTask任務會繼續執行。正常的操作是遍歷當前活動例項的物件圖來釋放各物件的記憶體以銷燬該活動,但是由於當前活動例項的ImageView引用被BitmapWorkerTask物件持有,而且還是強引用關係。這會導致Activity例項無法被銷燬,引發記憶體洩露問題。記憶體洩露問題會進一步導致記憶體溢位錯誤。
為了解決這個問題,我們只需要讓BitmapWorkerTask類持有ImageView的弱引用即可。這樣當活動退出時,BitmapWorkerTask物件由於持有的是ImageView的弱引用,所以ImageView物件會被回收,繼而Activity例項得到銷燬,從而避免了記憶體洩露問題。具體修改後的程式碼如下:
class BitmapWorkerTask extends AsyncTask<Integer, Void, Bitmap> { private final WeakReference<ImageView> imageViewReference; private int data = 0; public BitmapWorkerTask(ImageView imageView) { // 用弱引用來關聯這個imageview!弱引用是避免android 在各種callback回撥裡發生記憶體洩露的最佳方法! //而軟引用則是做快取的最佳方法 兩者不要搞混了! imageViewReference = new WeakReference<ImageView>(imageView); } // Decode image in background. @Override protected Bitmap doInBackground(Integer... params) { data = params[0]; return decodeSampledBitmapFromResource(getResources(), data, 100, 100); } @Override protected void onPostExecute(Bitmap bitmap) { //當後臺執行緒結束後 先看看ImageView物件是否被回收:如果被回收就什麼也不做,等著系統回收他的資源 //如果ImageView物件沒被回收的話,設定其顯示內容即可 if (imageViewReference != null && bitmap != null) { final ImageView imageView = imageViewReference.get(); if (imageView != null) { imageView.setImageBitmap(bitmap); } } } }
擴充:①WeakReference是弱引用,其中儲存的物件例項可以被GC回收掉。這個類通常用於在某處儲存物件引用,而又不干擾該物件被GC回收,可以用於避免記憶體洩露。②SoftReference是軟引用,它儲存的物件例項,不會被GC輕易回收,除非JVM即將OutOfMemory,否則不會被GC回收。這個特性使得它非常適合用於設計Cache快取。快取可以省去重複載入的操作,而且快取屬於記憶體因此讀取資料非常快,所以我們自然不希望快取內容被GC輕易地回收掉;但是因為快取本質上就是一種記憶體資源,所以在記憶體緊張時我們需要能釋放一部分快取空間來避免OOM錯誤。綜上,軟引用非常適合用於設計快取Cache。但是,這只是早些時候的快取設計思想,比如在Android2.3版本之前。在Android2.3版本之後,JVM的垃圾收集器開始更積極地回收軟引用物件,這使得原本的快取設計思想失效了。因為如果使用軟引用來實現快取,那麼動不動快取物件就被GC回收掉實在是無法接受。所以,Android2.3之後對於快取的設計使用的是強引用關係(也就是普通物件引用關係)。很多人會問這樣不會由於強引用的快取物件無法被回收從而導致OOM錯誤嗎?確實會這樣,但是我們只需要給快取設定一個合理的閾值就好了。將快取大小控制在這個閾值範圍內,就不會引發OOM錯誤了。
3、列表載入Bitmap時的圖片顯示錯亂問題
我們已經知道了如何高效地載入點陣圖以避免OOM錯誤,還知道了如何合理地利用非同步機制來避免Bitmap載入時的ANR問題和記憶體洩露問題。現在考慮另一種常見的Bitmap載入問題:當我們使用列表,如ListView、GridView和RecyclerView等來載入多個Bitmap時,可能會產生圖片顯示錯亂的問題。先看一下該問題產生的原因。以ListView為例:
①ListView為了提高列表展示內容在滾動時的流暢性,使用了一種item複用機制,即:在螢幕中顯示的每個ListView的item對應的佈局只有在第一次的時候被載入,然後快取在convertView裡面,之後滑動改變ListView時呼叫的getView就會複用快取在converView中的佈局和控制元件,所以可以使得ListView變得流暢(因為不用重複載入佈局)。
②每個Item中的ImageView載入圖片時往往都是非同步操作,比如在子執行緒中進行圖片資源的網路請求再載入為一個Bitmap物件最後回到UI執行緒設定該item的ImageView的顯示內容。
③ 聽上去①是一種非常合理有效的提高列表展示流暢性的機制,②看起來也是圖片載入時很常見的一個非同步操作啊。其實①和②本身都沒有問題,但是①+②+使用者滑動列表=圖片顯示錯亂!具體而言:當我們在其中一個itemA載入圖片A的時候,由於載入過程是非同步操作需要耗費一定的時間,那麼有可能圖片A未被載入完該itemA就“滾出去了”,這個itemA可能被當做快取應用到另一個列表項itemB中,這個時候剛好圖片A載入完成顯示在itemB中(因為ImageView物件在快取中被複用了),原本itemB該顯示圖片B,現在顯示圖片A。這只是最簡單的一種情況,當滑動頻繁時這種圖片顯示錯亂問題會愈加嚴重,甚至讓人毫無頭緒。
那麼如何解決這種圖片顯示錯亂問題呢?解決思路其實非常簡單:在圖片A被載入到ImageView之前做一個判斷,判斷該ImageView物件是否還是對應的是itemA,如果是則將圖片載入到ImageView當中;如果不是則放棄載入(因為itemB已經啟動了圖片B的載入,所以不用擔心控制元件出現空白的情況)。
那麼新的問題出現了,如何判斷ImageView物件對應的item已經改變了?我們可以採取下面的方式:
①在每次getView的複用佈局控制元件時,對會被複用的控制元件設定一個標籤(在這裡就是對ImageView設定標籤)。標籤內容必須可以標識不同的item!這裡使用圖片的url作為標籤內容,然後再非同步載入圖片。
②在圖片下載完成後要載入到ImageView之前做判斷,判斷該ImageView的標籤內容是否和圖片的url一樣:如果一樣說明ImageView沒有被複用,可以將圖片載入到ImageView當中;如果不一樣,說明ListView發生了滑動,導致其他item呼叫了getView從而將該ImageView的標籤改變,此時放棄圖片的載入(儘管圖片已經被下載成功了)。
總結:解決ListView非同步載入Bitmap時的圖片錯亂問題的方式是:為被複用的控制元件物件(即ImageView物件)設定標籤來標識item,非同步任務結束後要將圖片載入到ImageView時取出標籤值進行比對是否一致:如果一致意味著沒有發生滑動,正常載入圖片;如果不一樣意味著發生了滑動,取消載入。
4、Android中的Bitmap快取策略
如果只是載入若干張圖片,上述的Bitmap使用方式已經絕對夠用了;但是如果在應用中需要頻繁地載入大量的圖片,特別是有些圖片會被重複載入時,這個時候利用快取策略可以很好地提高圖片的載入速度。比如說有幾張圖片被重複載入的頻率很高,那麼可以在快取中保留這幾張圖片的Bitmap物件;後續如果需要載入這些圖片,則不需要花費很多時間去重新在網路上獲取並載入這些圖片的Bitmap物件,只需要直接向快取中獲取之前保留下來的Bitmap物件即可。
Android中對Bitmap的快取策略分為兩種:
- 記憶體快取:影象儲存在裝置記憶體中,因此訪問速度非常快。事實上,比影象解碼過程要快得多,所以將影象儲存在這裡是讓app更快更穩定的一個好主意。記憶體快取的唯一缺點是:它只存活於app的生命週期,這意味著一旦app被Android作業系統記憶體管理器關閉或殺死(全部或部分),那麼儲存在那裡的所有影象都將丟失。由於記憶體快取本質上就是一種記憶體資源,所以切記:記憶體快取必須設定一個最大可用的記憶體量。否則可能會導致臭名昭著的outOfMemoryError。
- 磁碟快取:影象儲存在裝置的物理儲存器上(磁碟)。磁碟快取本質上就是裝置SD卡上的某個目錄。只要app不被解除安裝,其磁碟快取可以一直安全地儲存圖片,只要有足夠的磁碟空間即可。缺點是,磁碟讀取和寫入操作可能會很慢,而且總是比訪問記憶體快取慢。由於這個原因,因此所有的磁碟操作必須在工作執行緒執行,UI執行緒之外。否則,app會凍結,並導致ANR警報。
在實際使用中,我們不需要強行二選一,可以二者都使用,畢竟各有優勢。所以Android中完整的圖片快取策略為:先嚐試在記憶體快取中查詢Bitmap物件,如果有直接載入使用;如果沒有,再嘗試在磁碟快取中查詢圖片檔案是否存在,如果有將其載入至記憶體使用;如果還是沒有,則老老實實傳送網路請求獲取圖片資源並載入使用。需要注意的是,後面兩種情況下的操作都必須使用非同步機制以避免ANR的發生。
Android中通過LruCache實現記憶體快取,通過DiskLruCache實現磁碟快取,它們採用的都是LRU(Least Recently Used)最近最少使用演算法來移除快取中的最近不常訪問的內容(變相地保留了最近經常訪問的內容)。
①記憶體快取LruCache
LruCache原理:LruCache底層是使用LinkedHashMap來實現的,所以LruCache也是一個泛型類。在圖片快取中,其鍵型別是字串,值型別為Bitmap。利用LinkedHashMap的accessOrder屬性可以實現LRU演算法。accessOrder屬性決定了LinkedHashMap的連結串列順序:accessOrder為true則以訪問順序維護連結串列,即被訪問過的元素會安排到連結串列的尾部;accessorder為false則以插入的順序維護連結串列。
而LruCache利用的正是accessOrder為true的LinkedHashMap來實現LRU演算法的。具體表現為:
1° put:通過LinkedHashMap的put方法來實現元素的插入,插入的過程還是要先尋找有沒有相同的key的資料,如果有則替換掉舊值,並且將該節點移到連結串列的尾部。這可以保證最近經常訪問的內容集中儲存在連結串列尾部,最近不常訪問的記憶體集中儲存在連結串列頭部位置。在插入後如果快取大小超過了設定的最大快取大小(閾值),則將LinkedHashMap頭部的節點(最近不常訪問的內容)刪除,直到size小於maxSize。
2° get:通過LinkedHashMap的get方法來實現元素的訪問,由於accessOrder為true,因此被訪問到的元素會被調整到連結串列的尾部,因此不常被訪問的元素就會留到連結串列的頭部,當觸發清理快取時不常被訪問的元素就會被刪除,這裡是實現LRU最關鍵的地方。
3° remove:通過LinkedHashMap的remove方法來實現元素的移除。
3° size:LruCache中很重要的兩個成員變數size和maxSize,因為清理快取的是在size>maxSize時觸發的,因此在初始化的時候要傳入maxSize定義快取的大小,然後重寫sizeOf方法,因為LruCache是通過sizeOf方法來計算每個元素的大小。這裡我們是使用LruCache來快取圖片,所以sizeOf方法需要計算Bitmap的大小並返回。
LruCache對其快取物件採用的是強引用關係,採用maxSize來控制快取空間大小以避免OOM錯誤。而且LruCache類在Android SDK中已經提供了,在實際使用中我們只需要完成以下幾步即可:
- 設計LruCache的最大快取大小:一般是通過計算當前可用的記憶體大小繼而來獲取到應該設定的快取大小
- 建立LruCache物件:傳入最大快取大小的引數,同時重寫sizeOf方法來設定存在LruCache裡的每個物件的大小
- 封裝對LruCache的資料訪問和新增操作並對外提供介面以供呼叫
具體程式碼參考如下:
//初始化LruCache物件 public void initLruCache() { //獲取當前程式的可用記憶體,轉換成KB單位 int maxMemory = (int) (Runtime.getRuntime().maxMemory() / 1024); //分配快取的大小 int maxSize = maxMemory / 8; //建立LruCache物件並重寫sizeOf方法 lruCache = new LruCache<String, Bitmap>(maxSize) { @Override protected int sizeOf(String key, Bitmap value) { // TODO Auto-generated method stub return value.getWidth() * value.getHeight() / 1024; } }; } /** * 封裝將圖片存入快取的方法 * @param key 圖片的url轉化成的key * @param bitmap物件 */ private void addBitmapToMemoryCache(String key, Bitmap bitmap) { if(getBitmapFromMemoryCache(key) == null) { mLruCache.put(key, bitmap); } } //封裝從LruCache中訪問資料的方法 private Bitmap getBitmapFromMemoryCache(String key) { return mLruCache.get(key); } /** * 因為外界一般獲取到的是url而不是key,因此為了方便再做一層封裝 * @param url http url * @return bitmap */ private Bitmap loadBitmapFromMemoryCache(String url) { final String key = hashKeyFromUrl(url); return getBitmapFromMemoryCache(key); }
②磁碟快取DiskLruCache
由於DiskLruCache並不屬於Android SDK的一部分,需要自行設計。與LruCache實現LRU演算法的思路基本上是一致的,但是有很多不一樣的地方:LruCache是記憶體快取,其鍵對應的值型別直接為Bitmap;而DiskLruCache是磁碟快取,所以其鍵對應的值型別應該是一個代表圖片檔案的類。其次,前者訪問或新增元素時,查詢成功可以直接使用該Bitmap物件;後者訪問或新增元素時,查詢到指定圖片檔案後還需要通過檔案的讀取和Bitmap的載入過程才能使用。另外,前者是在記憶體中的資料讀寫操作所以不需要非同步;後者涉及到檔案操作必須開啟子執行緒實現非同步處理。
具體DiskLruCache的設計方案和使用方式可以參考這篇部落格:https://www.jianshu.com/p/765640fe474a
有了LruCache類和DiskLruCache類,可以實現完整的Android圖片二級快取策略:在具體的圖片載入時:先嚐試在LruCache中查詢Bitmap物件,如果有直接拿來使用。如果沒有再嘗試在DiskLruCache中查詢圖片檔案,如果有將其載入為Bitmap物件再使用,並將其新增至LruCache中;如果沒有查詢到指定的圖片檔案,則傳送網路請求獲取圖片資源並載入為Bitmap物件再使用,並將其新增DiskLruCache中。
5、Bitmap記憶體管理
Android裝置的記憶體包括本機Native記憶體和Dalvik(類似於JVM虛擬機器)堆記憶體兩部分。在Android 2.3.3(API級別10)及更低版本中,點陣圖的支援畫素資料儲存在Native記憶體中。它與點陣圖本身是分開的,Bitmap物件本身儲存在Dalvik堆中。Native記憶體中的畫素資料不會以可預測的方式釋放,可能導致應用程式短暫超出其記憶體限制並崩潰。從Android 3.0(API級別11)到Android 7.1(API級別25),畫素資料與相關Bitmap物件一起儲存在Dalvik堆上,一起交由Dalvik虛擬機器的垃圾收集器來進行回收,因此比較安全。
①在Android2.3.3版本之前:
在Bitmap物件不再使用並希望將其銷燬時,Bitmap物件自身由於儲存在Dalvik堆中,所以其自身會由GC自動回收;但是由於Bitmap的畫素資料儲存在native記憶體中,所以必須由開發者手動呼叫Bitmap的recycle()方法來回收這些畫素資料佔用的記憶體空間。
②在Android2.3.3版本之後:
由於Bitmap物件和其畫素資料一起儲存在Dalvik堆上,所以在其需要回收時只要將Bitmap引用置為null 就行了,不需要如此麻煩的手動釋放記憶體操作。
當然,一般我們在實際開發中往往向下相容到Android4.0版本,所以你懂得。
③在Android3.0以後的版本,還提供了一個很好用的引數,叫options.inBitmap。如果你使用了這個屬性,那麼在呼叫decodeXXXX方法時會直接複用 inBitmap 所引用的那塊記憶體。大家都知道,很多時候ui卡頓是因為gc 操作過多而造成的。使用這個屬效能避免頻繁的記憶體的申請和釋放。帶來的好處就是gc操作的數量減少,這樣cpu會有更多的時間執行ui執行緒,介面會流暢很多,同時還能節省大量記憶體。簡單地說,就是記憶體空間被各個Bitmap物件複用以避免頻繁的記憶體申請和釋放操作。
需要注意的是,如果要使用這個屬性,必須將BitmapFactory.Options的isMutable屬性值設定為true,否則無法使用這個屬性。
具體使用方式參考如下程式碼:
final BitmapFactory.Options options = new BitmapFactory.Options(); //size必須為1 否則是使用inBitmap屬性會報異常 options.inSampleSize = 1; //這個屬性一定要在用在src Bitmap decode的時候 不然你再使用哪個inBitmap屬性去decode時候會在c++層面報異常 //BitmapFactory: Unable to reuse an immutable bitmap as an image decoder target. options.inMutable = true; inBitmap2 = BitmapFactory.decodeFile(path1,options); iv.setImageBitmap(inBitmap2); //將inBitmap屬性代表的引用指向inBitmap2物件所在的記憶體空間,即可複用這塊記憶體區域 options.inBitmap = inBitmap2; //由於啟用了inBitmap屬性,所以後續的Bitmap載入不會申請新的記憶體空間而是直接複用inBitmap屬性值指向的記憶體空間 iv2.setImageBitmap(BitmapFactory.decodeFile(path2,options)); iv3.setImageBitmap(BitmapFactory.decodeFile(path3,options)); iv4.setImageBitmap(BitmapFactory.decodeFile(path4,options));
補充:Android4.4以前,你要使用這個屬性,那麼要求複用記憶體空間的Bitmap物件大小必須一樣;但是Android4.4 以後只要求後續複用記憶體空間的Bitmap物件大小比inBitmap指向的記憶體空間要小就可以使用這個屬性了。另外,如果你不同的imageview 使用的scaletype 不同,但是你這些不同的imageview的bitmap在載入是如果都是引用的同一個inBitmap的話,
這些圖片會相互影響。綜上,使用inBitmap這個屬性的時候 一定要小心小心再小心。
六、開源框架
我們現在已經知道了,Android圖片載入的知識點和注意事項實在太多了:單個的點陣圖載入我們要考慮Bitmap載入的OOM問題、非同步處理問題和記憶體洩露問題;列表載入點陣圖要考慮顯示錯亂問題;頻繁大量的點陣圖載入時我們要考慮二級快取策略;我們還有考慮不同版本下的Bitmap記憶體管理問題,在這部分最後我們介紹了Bitmap記憶體複用方式,我們需要小心使用這種方式。
那麼,能不能有一種方式讓我們省去這麼多繁瑣的細節,方便我們對圖片進行載入呢?答案就是:利用已有的成熟的圖片載入和快取開源框架!比如square公司的Picasso框架、Google公司的Glide框架和Facebook公司的Fresco框架等。特別是Fresco框架,提供了三級快取策略,非常的專業。根據APP對圖片顯示和快取的需求從低到高排序,我們可以採用的方案依次為:Bitmapfun、Picasso、Android-Universal-Image-Loader、Glide、Fresco。
這些框架可以方便我們實現對網路圖片的載入和快取操作。具體不再贅述。