導語
主要介紹如何高效地加載一個Bitmap,Android中常用的緩存策略,如何優化列表的卡頓。
主要內容
- Bitmap的高效加載
- Android中的緩存策略
- ImageLoader的使用
具體內容
Bitmap的高效加載
先來簡單介紹一下如何加載一個Bitmap, Bitmap在android中指的是一張圖片, 可以是png格式也可以是jpg等其他常見的圖片格式.
那么如何加載一個圖片?首先BitmapFactory類提供了四種方法: decodeFile(), decodeResource(), decodeStream(), decodeByteArray(). 分別用于從文件系統, 資源文件, 輸入流以及字節數組加載出一個Bitmap對象. 其中decodeFile和decodeResource又間接調用了decodeStream()方法, 這四類方法最終是在Android的底層實現的, 對應著BitmapFactory類的幾個native方法.
高效加載的Bitmap的核心思想:采用BitmapFactory.Options來加載所需尺寸的圖片. 比如說一個ImageView控件的大小為300300. 而圖片的大小為800800. 這個時候如果直接加載那么就比較浪費資源, 需要更多的內存空間來加載圖片, 這不是很必要的. 這里我們就可以先把圖片按一定的采樣率來縮小圖片在進行加載. 不僅降低了內存占用,還在一定程度上避免了OOM異常. 也提高了加載bitmap時的性能.
而通過Options參數來縮放圖片: 主要是用到了inSampleSize參數, 即采樣率。
- 如果是inSampleSize=1那么和原圖大小一樣,
- 如果是inSampleSize=2那么寬高都為原圖1/2, 而像素為原圖的1/4, 占用的內存大小也為原圖的1/4
- 如果是inSampleSize=3那么寬高都為原圖1/3, 而像素為原圖的1/9, 占用的內存大小也為原圖的1/9
- 以此類推…..
要知道Android中加載圖片具體在內存中的占有的大小是根據圖片的像素決定的, 而與圖片的實際占用空間大小沒有關系.而且如果要加載mipmap下的圖片, 還會根據不同的分辨率下的文件夾進行不同的放大縮小.
列舉現在有一張圖片像素為:10241024, 如果采用ARGB8888(四個顏色通道每個占有一個字節,相當于1點像素占用4個字節的空間)的格式來存儲.(這里不考慮不同的資源文件下情況分析) 那么圖片的占有大小就是102410244那現在這張圖片在內存中占用4MB.
如果針對剛才的圖片進行inSampleSize=2, 那么最后占用內存大小為512512*4, 也就是1MB
采樣率的數值必須是大于1的整數是才會有縮放效果, 并且采樣率同時作用于寬/高, 這將導致縮放后的圖片以這個采樣率的2次方遞減, 即內存占用縮放大小為1/(inSampleSize的二次方). 如果小于1那么相當于=1的時候. 在官方文檔中指出, inSampleSize的取值應該總是為2的指數, 比如1,2,4,8,16,32…如果外界傳遞inSampleSize不為2的指數, 那么系統會向下取整并選擇一個最接近的2的指數來代替. 比如如果inSampleSize=3,那么系統會選擇2來代替. 但是這條規則并不作用于所有的android版本, 所以可以當成一個開發建議
整理一下開發中代碼流程:
- 將BitmapFactory.Options的inJustDecodeBounds參數設置為true并加載圖片。
- 從BitmapFactory.Options取出圖片的原始寬高信息, 他們對應于outWidth和outHeight參數。
- 根據采樣率的規則并結合目標View的所需大小計算出采樣率inSampleSize。
- 將BitmapFactory.Options的inJustDecodeBounds參數設為false, 然后重新加載。
inJustDecodeBounds這個參數的作用就是在加載圖片的時候是否只是加載圖片寬高信息而不把圖片全部加載到內存. 所以這個操作是個輕量級的.
通過這些步驟就可以整理出以下的工具加載圖片類調用decodeFixedSizeForResource()即可.
public class MyBitmapLoadUtil {
/**
* 對一個Resources的資源文件進行指定長寬來加載進內存, 并把這個bitmap對象返回
*
* @param res 資源文件對象
* @param resId 要操作的圖片id
* @param reqWidth 最終想要得到bitmap的寬度
* @param reqHeight 最終想要得到bitmap的高度
* @return 返回采樣之后的bitmap對象
*/
public static Bitmap decodeFixedSizeForResource(Resources res, int resId, int reqWidth, int reqHeight){
// 首先先指定加載的模式 為只是獲取資源文件的大小
BitmapFactory.Options options = new BitmapFactory.Options();
options.inJustDecodeBounds = true;
BitmapFactory.decodeResource(res, resId, options);
//Calculate Size 計算要設置的采樣率 并把值設置到option上
options.inSampleSize = calculateInSampleSize(options, reqWidth, reqHeight);
// 關閉只加載屬性模式, 并重新加載的時候傳入自定義的options對象
options.inJustDecodeBounds = false;
return BitmapFactory.decodeResource(res, resId, options);
}
/**
* 一個計算工具類的方法, 傳入圖片的屬性對象和 想要實現的目標大小. 通過計算得到采樣值
*/
private static int calculateInSampleSize(BitmapFactory.Options options, int reqWidth, int reqHeight) {
//Raw height and width of image
//原始圖片的寬高屬性
final int height = options.outHeight;
final int width = options.outWidth;
int inSampleSize = 1;
// 如果想要實現的寬高比原始圖片的寬高小那么就可以計算出采樣率, 否則不需要改變采樣率
if (reqWidth < height || reqHeight < width){
int halfWidth = width/2;
int halfHeight = height/2;
// 判斷原始長寬的一半是否比目標大小小, 如果小那么增大采樣率2倍, 直到出現修改后原始值會比目標值大的時候
while((halfHeight/inSampleSize) >= reqHeight && (halfWidth/inSampleSize) >= reqWidth){
inSampleSize *= 2;
}
}
return inSampleSize;
}
}
Android中的緩存策略
當程序第一次從網絡上加載圖片后,將其緩存在存儲設備中,下次使用這張圖片的時候就不用再從網絡從獲取了。很多時候為了提高應用的用戶體驗,往往還會把圖片在內存中再緩存一份,因為從內存中加載圖片比存儲設備中快。一般情況會把圖片存一份到內存中,一份到存儲設備中,如果內存中沒找到就去存儲設備中找,還沒有找到就從網絡上下載。
緩存策略包含緩存的添加、獲取和刪除操作。不管是內存還是存儲設備,緩存大小都是有限制的。如何刪除舊的緩存并添加新的緩存,就對應緩存算法。
目前常用的一種緩存算法是LRU(Least Recently Used), 最近最少使用算法. 核心思想: 當緩存存滿時, 會優先淘汰那些近期最少使用的緩存對象. 采用LRU算法的緩存有兩種: LruCache和DiskLruCache,LruCahe用于實現內存緩存, DiskLruCache則充當了存儲設備緩存, 當組合使用后就可以實現一個類似ImageLoader這樣的類庫.
LruCache
LruCache是Android 3.1所提供的一個緩存類, 通過support-v4兼容包可以兼容到早期的Android版本
LruCache是一個泛型類, 它內部采用了一個LinkedHashMap以強引用的方式存儲外界的緩存對象, 其提供了get和put方法來完成緩存的獲取和添加的操作. 當緩存滿了時, LruCache會移除較早使用的緩存對象, 然后在添加新的緩存對象. 普及一下各種引用的區別:
- 強引用: 直接的對象引用
- 軟引用: 當一個對象只有軟引用存在時, 系統內存不足時此對象會被gc回收
- 弱引用: 當一個對象只有弱引用存在時, 對象會隨下一次gc時被回收
LruCache是線程安全的。
LruCache 典型初始化過程:
int maxMemory = (int) (Runtime.getRuntime().maxMemory() / 1024);
int cacheSize = maxMemory / 8;
mMemoryCache = new LruCache<String, Bitmap>(cacheSize) {
@Override
protected int sizeOf(String key, Bitmap value) {
return value.getRowBytes() * value.getHeight() / 1024;
}
};
這里只需要提供緩存的總容量大小(一般為進程可用內存的1/8)并重寫 sizeOf 方法即可.sizeOf方法作用是計算緩存對象的大小。這里大小的單位需要和總容量的單位(這里是kb)一致,因此除以1024。一些特殊情況下,需要重寫LruCache的entryRemoved方法,LruCache移除舊緩存時會調用entryRemoved方法,因此可以在entryRemoved中完成一些資源回收工作(如果需要的話)。
還有獲取和添加方法,都比較簡單:
mMemoryCache.get(key)
mMemoryCache.put(key,bitmap)
通過remove方法可以刪除一個指定的對象。
從Android 3.1開始,LruCache稱為Android源碼的一部分。
DiskLruCache
DiskLruCache用于實現磁盤緩存,DiskLruCache得到了Android官方文檔推薦,但它不屬于Android SDK的一部分,源碼在這里。
DiskLruCache的創建:
DiskLruCache并不能通過構造方法來創建, 他提供了open()方法用于創建自身, 如下所示
public static DiskLruCache open(File directory, int appVersion, int valueCount, long maxSize)
- File directory: 表示磁盤緩存在文件系統中的存儲路徑. 可以選擇SD卡上的緩存目錄, 具體是指/sdcard/Andriod/data/package_name/cache目錄, package_name表示當前應用的包名, 當應用被卸載后, 此目錄會一并刪除掉. 也可以選擇data目錄下. 或者其他地方. 這里給出的建議:如果應用卸載后就希望刪除緩存文件的話 , 那么就選擇SD卡上的緩存目錄, 如果希望保留緩存數據那就應該選擇SD卡上的其他目錄.
- int appVersion: 表示應用的版本號, 一般設為1即可. 當版本號發生改變的時候DiskLruCache會清空之前所有的緩存文件, 在實際開發中這個實用性不大.
- int valueCount: 表示單個節點所對應的數據的個數, 一般設為1.
- long maxSize: 表示緩存的總大小, 比如50MB, 當緩存大小超出這個設定值后, DiskLruCache會清除一些緩存而保證總大小不大于這個設定值.
//初始化DiskLruCache,包括一些參數的設置
public void initDiskLruCache
{
//配置固定參數
// 緩存空間大小
private static final long DISK_CACHE_SIZE = 1024 * 1024 * 50;
//下載圖片時的緩存大小
private static final long IO_BUFFER_SIZE = 1024 * 8;
// 緩存空間索引,用于Editor和Snapshot,設置成0表示Entry下面的第一個文件
private static final int DISK_CACHE_INDEX = 0;
//設置緩存目錄
File diskLruCache = getDiskCacheDir(mContext, "bitmap");
if(!diskLruCache.exists())
diskLruCache.mkdirs();
//創建DiskLruCache對象,當然是在空間足夠的情況下
if(getUsableSpace(diskLruCache) > DISK_CACHE_SIZE)
{
try
{
mDiskLruCache = DiskLruCache.open(diskLruCache,
getAppVersion(mContext), 1, DISK_CACHE_SIZE);
mIsDiskLruCache = true;
}catch(IOException e)
{
e.printStackTrace();
}
}
}
//上面的初始化過程總共用了3個方法
//設置緩存目錄
public File getDiskCacheDir(Context context, String uniqueName) {
String cachePath;
if (Environment.MEDIA_MOUNTED.equals(Environment
.getExternalStorageState())
|| !Environment.isExternalStorageRemovable()) {
cachePath = context.getExternalCacheDir().getPath();
} else {
cachePath = context.getCacheDir().getPath();
}
return new File(cachePath + File.separator + uniqueName);
}
// 獲取可用的存儲大小
@TargetApi(VERSION_CODES.GINGERBREAD)
private long getUsableSpace(File path) {
if (Build.VERSION.SDK_INT >= VERSION_CODES.GINGERBREAD)
return path.getUsableSpace();
final StatFs stats = new StatFs(path.getPath());
return (long) stats.getBlockSize() * (long) stats.getAvailableBlocks();
}
//獲取應用版本號,注意不同的版本號會清空緩存
public int getAppVersion(Context context) {
try {
PackageInfo info = context.getPackageManager().getPackageInfo(
context.getPackageName(), 0);
return info.versionCode;
} catch (NameNotFoundException e) {
e.printStackTrace();
}
return 1;
}
DiskLruCache的緩存添加:
DiskLruCache的緩存添加的操作是通過Editor完成的, Editor表示一個緩存對象的編輯對象.
如果還是緩存圖片為例子, 每一張圖片都通過圖片的url為key, 這里由于url可能會有特殊字符所以采用url的md5值作為key. 根據這個key就可以通過edit()來獲取Editor對象, 如果這個緩存對象正在被編輯, 那么edit()就會返回null. 即DiskLruCache不允許同時編輯一個緩存對象.
當用.edit(key)獲得了Editor對象之后. 通過editor.newOutputStream(0)就可以得到一個文件輸出流. 由于之前open()方法設置了一個節點只能有一個數據. 所以在獲得輸出流的時候傳入常量0即可.
有了文件輸出流, 可以當網絡下載圖片時, 圖片就可以通過這個文件輸出流寫入到文件系統上.最后,要通過Editor中commit()來提交寫操作, 如果下載中發生異常, 那么使用Editor中abort()來回退整個操作.
DiskLruCache的緩存查找:
和緩存的添加過程類似, 緩存查找過程也需要將url轉換成key, 然后通過DiskLruCache#get()方法可以得到一個Snapshot對象, 接著在通過Snapshot對象即可得到緩存的文件輸入流, 有了文件輸入流, 自然就可以得到Bitmap對象. 為了避免加載圖片出現OOM所以采用壓縮的方式. 在前面對BitmapFactory.Options的使用說明了. 但是這中方法對FileInputStream的縮放存在問題. 原因是FileInputStream是一種有序的文件流, 而兩次decodeStream調用會影響文件的位置屬性, 這樣在第二次decodeStream的時候得到的會是null. 針對這一個問題, 可以通過文件流來得到它所對應的文件描述符, 然后通過BitmapFactory.decodeFileDescription()來加載一張縮放后的圖片.
/**
* 磁盤緩存的讀取
* @param url
* @param reqWidth
* @param reqHeight
* @return
*/
private Bitmap loadBitmapFromDiskCache(String url, int reqWidth, int reqHeight) throws IOException
{
if(Looper.myLooper() == Looper.getMainLooper())
Log.w(TAG, "it's not recommented load bitmap from UI Thread");
if(mDiskLruCache == null)
return null;
Bitmap bitmap = null;
String key = hashKeyForDisk(url);
Snapshot snapshot = mDiskLruCache.get(key);
if(snapshot != null)
{
FileInputStream fileInputStream = (FileInputStream) snapshot.getInputStream(DISK_CACHE_INDEX);
FileDescriptor fd = fileInputStream.getFD();
bitmap = mImageResizer.decodeSampleBitmapFromFileDescriptor(fd, reqWidth, reqHeight);
if(bitmap != null)
addBitmapToMemoryCache(key, bitmap);
}
return bitmap;
}
ImageLoader的實現
一個好的ImageLoader應該具備以下幾點:
- 圖片的壓縮
- 網絡拉取
- 內存緩存
- 磁盤緩存
- 圖片的同步加載
- 圖片的異步加載
圖片壓縮功能
ImageResizer
內存緩存和磁盤緩存
ImageLoader
同步加載和異步加載的接口設計
ImageLoader 173行
異步加載過程:
- bindBitmap先嘗試從內存緩存讀取圖片,如果沒有會在線程池中調用loadBitmap方法。獲取成功將圖片封裝為LoadResult對象通過mMainHandler向UI線程發送消息。選擇線程池和Handler來提供并發能力和異步能力。
- 為了解決View復用導致的列表錯位問題,在給ImageView設置圖片之前都會檢查它的url有沒有發生改變,如果改變就不再給它設置圖片。(76行)
ImageLoader的使用
照片墻效果
實現照片墻效果,如果圖片都需要是正方形;這樣做很快,自定義一個ImageView,重寫onMeasure方法。
@Override
protected void onMeasure(int widthMeasureSpec,int heightMeasureSpec){
super.onMeasure(widthMeasureSpec,widthMeasureSpec);//將原來的參數heightMeasureSpec換成widthMeasureSpec
}
優化列表的卡頓現象
- 不要在getView中執行耗時操作,不要在getView中直接加載圖片。
- 控制異步任務的執行頻率:如果用戶刻意頻繁上下滑動,getView方法會不停調用,從而產生大量的異步任務??梢钥紤]在列表滑動停止加載圖片;給ListView或者GridView設置 setOnScrollListener 并在 OnScrollListener 的 onScrollStateChanged 方法中判斷列表是否處于滑動狀態,如果是的話就停止加載圖片。
- 大部分情況下,可以使用硬件加速解決莫名卡頓問題,通過設置 android:hardwareAccelerated=”true” 即可為Activity開啟硬件加速。