第12章 Bitmap的加載和Cache(筆記)


title: 第12章 Bitmap的加載和Cache
tags: []
notebook: Android開發藝術探索


第12章 Bitmap的加載和Cache

[TOC]

本章主要介紹了三個方面的知識:

  1. 圖片加載:如何有效的加載一個Bitmap
  2. 緩存策略:LruCache和DiskLruCache
  3. 列表的滑動流暢性:如何優化列表的卡頓現象

12.1 Bitmap的高效加載

首先有4種Bitmap的加載方法,都是有BitmapFactory提供的

加載方法 加載來源
decodeFile 文件
decodeResource 資源
decodeStream 輸入流
decodeByteArray 字節數組

*其中decodeFile和decodeResource是間接調用了decodeStream方法

1.核心思想

加載所需尺寸的圖片。即原本圖片很大,但是實際上需要的尺寸很小,比如頭像只需要縮略圖,此時可以計算出采樣率,然后根據采樣率來加載圖片

2.壓縮方法

通過設置BitmapFactory.Options對象的inSampleSize來進行圖片的采樣計算和縮放,這里需要了解的是整個圖片縮放比例是1/(inSampleSize的2次方),而寬高為原來的1/inSampleSize,即inSampleSize為1時不縮放,為2時縮放為原來的1/4(寬高都為原來的1/2),為4時縮放為原來的1/16(寬高都為原來的1/4)

采樣原則:縮放比例一定要大于等于需求寬高。比如ImageView的大小為100*100像素,原始圖片為200*300,那么inSampleSize應該為2,此時壓縮后的圖片為100*150 >= 100*100

3.采樣縮放流程

總體流程應該分為:獲取圖片的寬高->獲取所需的寬高->計算采樣率->壓縮圖片
具體流程如下:

  1. 將BitmapFactory.Options的inJustDecodeBounds參數設為true,并通過BitmapFactory和Options加載圖片(此時加載的只是原始圖片的寬高)
  2. 從BitmapFactory.Options中獲取到原始圖片的寬高,對應outWidth和outHeight
  3. 通過實際所需的寬高reqWidth和reqHeight以及outWidth和outHeight計算出采樣率,并設置到Options的inSampleSize中
  4. 將BitmapFactory.Options的inJustDecodeBounds參數設為false,然后重新通過BitmapFactory和Options加載圖片,此時圖片就是根據實際需要壓縮過的圖片

*其中inJustDecodeBounds參數的作用是使BitmapFactory只加載圖片的寬高,因為采樣率的計算并不需要加載整個圖片,提高了效率
上面流程的代碼實現如下

public class ImageResizer {
    public ImageResizer(){}

    /**
     * 從資源文件中獲取相應的圖片
     * @param res
     * @param resId
     * @param reqWidth
     * @param reqHeight
     * @return
     */
    public Bitmap decodeSampleBitmapFromResource(Resources res, int resId, int reqWidth, int reqHeight)
    {
        Options options = new Options();
        //設置只加載寬高標志位
        options.inJustDecodeBounds = true;
        //加載原始圖片寬高到Options中
        BitmapFactory.decodeResource(res, resId, options);
        //計算采樣率,通過所需寬高和原始圖片寬高
        options.inSampleSize = calculateSampleSize(reqWidth, reqHeight, options);
        //還原并再次加載圖片
        options.inJustDecodeBounds = false;
        return BitmapFactory.decodeResource(res, resId, options);
    }
    /** 
     * 從文件描述符中獲取相應的圖片
     * @param reqWidth
     * @param reqHeight
     * @param options
     * @return
     */
    public Bitmap decodeSampleBitmapFromFileDescriptor(FileDescriptor fd, int reqWidth, int reqHeight)
    {
        
        Options options = new Options();
        options.inJustDecodeBounds = true;
        BitmapFactory.decodeFileDescriptor(fd, null, options);
        options.inSampleSize = calculateSampleSize(reqWidth, reqHeight, options);
        options.inJustDecodeBounds = false;
        return  BitmapFactory.decodeFileDescriptor(fd, null, options);
    }

    //計算采樣率
    public static int calculateSampleSize(int reqWidth, int reqHeight, Options options)
    {
        //如果傳入0參數,則將采樣率設成1,即不壓縮
        if (reqWidth == 0 || reqHeight == 0) {
            return 1;
        }

        int inSampleSize = 1;
        int width = options.outWidth;
        int height = options.outHeight;
        
        //當所需寬高比實際寬高小時才進行壓縮
        if(reqWidth < width && reqHeight < height)
        {
            int halfWidth = width >>= 1;
            int halfHeight = height >>= 1;
            //保證壓縮后的寬高不能小于所需寬高
            while(reqWidth <= halfWidth && reqHeight <= halfHeight)
            {
                inSampleSize <<= 1;
                halfWidth /= inSampleSize;
                halfHeight /= inSampleSize;
            }
        }
        return inSampleSize;
    }
}

實際使用的時候根據需要壓縮圖片,比如ImageView所期望的圖片大小為100*100,則可以這樣實現

iv.setImageBitmap(mImageResizer.decodeSampleBitmapFromResource(getResources(), R.drawable.lizhuo, 100, 100));

12.2 Android中的緩存策略

Android中三級緩存策略:內存-磁盤-網絡。即在獲取資源時比如圖片,先從內存緩存中讀取,如果沒有則從磁盤緩存中讀取,最后還沒有再從網絡中拉取圖片。
Android中通過LruCache實現內存緩存,通過DiskLruCache實現磁盤緩存,它們采用的都是LRU(Least Recently Used)最近最少使用算法來移除緩存

12.2.1 LruCache

使用LruCache類時建議使用v4包中的以兼容Android2.2版本,它是在Android3.1開始默認所提供的一個緩存類

1.LruCache實現原理

LruCache底層是使用LinkedHashMap來實現的,所以LruCache也是一個泛型類,利用LinkedHashMap的accessOrder屬性可以實現LRU算法。
因為LinkedHashMap利用一個雙重鏈接鏈表來維護所有條目,accessOrder屬性決定了LinkedHashMap的鏈表順序
* 為true則以訪問順序維護鏈表,即被訪問過的元素會安排到鏈表的尾部;
* 為false則以插入的順序維護鏈表。
而LruCache利用的正是accessOrder為true的LinkedHashMap來實現LRU算法的。

  1. put:通過LinkedHashMap的put來實現元素的插入,在插入后調用trimToSize來調整緩存的大小,如果大于設定的最大緩存大小,則將LinkedHashMap頭部的節點刪除,直到size小于maxSize。注:插入的過程還是要先尋找有沒有相同的key的數據,如果有則替換掉舊值,并且將該節點移到鏈表的尾部
  2. get:通過LinkedHashMap的get來實現,由于accessOrder為true,因此被訪問到的元素會被調整到鏈表的尾部,因此不常被訪問的元素就會留到鏈表的頭部,當觸發清理緩存時不常被訪問的元素就會被刪除,這里是實現LRU最關鍵的地方
  3. remove:通過LinkedHashMap的remove來實現
  4. size:LruCache中很重要的兩個成員size和maxSize,因為清理緩存的是在size>maxSize時觸發的,因此在初始化的時候要傳入maxSize定義緩存的大小,然后重寫sizeOf方法,因為LruCache是通過sizeOf方法來計算每次添加一個元素或者刪除一個元素而改變的size的大小

引用的分類

  • 強引用:直接的對象引用,不會被gc回收
  • 軟引用:系統內存不足時,對象會被gc回收
  • 弱引用:對象隨時被gc回收
    LruCache里的LinkedHashMap以強引用的方式存儲外界的緩存對象

2.LruCache的使用

  1. 設計LruCache的最大緩存大小:一般是通過計算當前可用的內存大小繼而來獲取到應該設置的緩存大小
  2. 創建LruCache對象:傳入最大緩存大小的參數,同時重寫sizeOf方法來設置存在LruCache里的每個對象的大小
  3. 通過put、get和remove方法來實現數據的添加、獲取和刪除
//初始化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;
            }
        };
}
//LruCache對數據的操作
public void fun()
{
    //添加數據
    lruCache.put("lizhuo", bm1);
    lruCache.put("sushe", bm2);
    lruCache.put("jiqian", bm3);
    //獲取數據
    Bitmap b1 = (lruCache.get("lizhuo"));
    Bitmap b2 = (lruCache.get("sushe"));
    Bitmap b3 = (lruCache.get("jiqian"));
    //刪除數據
    lruCache.remove("sushe");
}

一般會對將數據添加進內存和獲取數據做一個封裝

/**
 * 將圖片存入緩存
 * @param key 圖片的url轉化成的key
 * @param bitmap
 */
private void addBitmapToMemoryCache(String key, Bitmap bitmap)
{
    if(getBitmapFromMemoryCache(key) == null)
    {
        mLruCache.put(key, bitmap);
    }
}

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

12.2.2 DiskLruCache

DiskLruCache用于實現磁盤緩存,通過將緩存對象寫入文件系統從而實現緩存的效果。DiskLruCache并不屬于Android SDK的一部分,需要另行添加
注:由于DiskLruCache是采用文件存儲的,存儲和讀取都是通過IOStream來處理的,所以并不存在什么類型,所以DiskLruCache并不是泛型類,不能像LurCache一樣添加泛型

1.DiskLruCache實現原理

DiskLruCache是通過文件的形式將數據存在磁盤上的,和LruCache一樣,是通過LinkedHashMap來實現LRU算法的,不同的是,LruCache是直接將數據存在LinkedHashMap當中,即key對應的value;而DiskLruCache在LinkedHashMap中存儲的值是Entry對象,可以認為它指向磁盤中的文件,因此DiskLruCache對文件數據的操作(寫入、讀取)是通過Entry對象間接進行的。以下是各數據操作的原理

  1. 添加數據:Editor->OutputStream->Entry->File
    先是獲取到對應Key的Editor,然后Editor里有LinkekHashMap中對應的Entry,Entry對應的就是緩存文件,因此通過Editor的OutputStream可以將數據直接寫到對應的緩存文件當中。當然中間的過程還是要先查找,如果查找到了直接換舊值,并且把節點移到鏈表的尾部,這跟LruCache一樣,只不過是通過Editor的OutputStream來添加數據
    其實仔細想想也不難理解啊,因為緩存的是文件,而文件的寫入肯定是OutputStream來達到的,而Edittor就是將OutputStream和Entry連接起來的橋梁,這么想就理解了為什么DiskLruCache的添加數據比LruCache麻煩
  2. 獲取數據:Snapshot->InputStream->Entry->File
    上面添加數據過程理解之后獲取數據就簡單多了,同樣要讀取文件是需要InputStream的,而Snapshot就是將LinkedHashMap中對應key的Entry和InputStream連接的對象,然后從Snapshot對象中可以獲取到文件的輸入流從而達到讀取緩存文件的效果
  3. 刪除數據:通過LinkedHashMap直接刪除對應key的Entry
  4. 日志文件:DiskLruCache中的特別的地方還有一個日志文件journal
    它用于記錄DiskLruCache進行過的數據操作,DiskLruCache能夠正常工作的前提就是依賴journal文件中的內容,它一共有DIRTY,CLEAN,REMOVE和READ四種前綴,分別記錄了正在修改的數據,干凈的,被移除的和被讀取的數據。

這里強調一下Editor和Snapshot的使用,以免對它們的使用不夠清楚了解。它們可以理解成在DiskLruCache中,每個key對應一個Editor和一個Snapshot,每個Editor和Snapshot又對應著一個Entry繼而對應著一個文件FIle,因此在獲取Editor或者Snapshot對象時需要相應的key,如下:

Editor editor = mDiskLruCache.edit(key);
OutputStream = editor.newOutputStream(0);

Snapshot snapshot = mDiskLruCache.get(key);
FileInputStream fileInputStream = snapshot.getInputStream(0);

2.DiskLruCache的使用

使用過程和LruCache大同小異,還是分配空間,創建對象和數據操作,由于DiskLruCache是將數據存在磁盤上的,因此比LruCache多了一步設置緩存目錄

  1. 計算分配DiskLruCache分配空間大小:設成50MB的寫法為1024*1024*50
  2. 設置緩存目錄:通常都會存放在 /sdcard/Android/data/ "application package"/cache 這個路徑下面,但同時我們又需要考慮如果這個手機沒有SD卡,或者SD正好被移除了的情況,因此比較優秀的程序都會專門寫一個方法來獲取緩存地址(后面說明)
  3. 創建DiskLruCache對象:通過靜態方法open(File directory, int appVersion, int valueCount, long maxSize) 來創建對象,因為創建對象時還要對journal文件進行操作,因此不能直接通過構造方法來創建。其中第一個參數指定的是數據的緩存地址,第二個參數指定當前應用程序的版本號,第三個參數指定同一個key可以對應多少個緩存文件,基本都是傳1,這里的1導致在Editor獲取OutputStream和Snapshot獲取InputStream時需要傳入的參數為0,表示數組的第一個,第四個參數指定最多可以緩存多少字節的數據。
  4. 數據操作:通過Editor進行數據的添加,通過Snapshot進行數據的讀取,通過remove進行數據的刪除,注意在添加數據的時候最后要調用editor.commit()保證被其它的Reader看到,并且commit方法可以保證緩存大小不超過閾值
  5. flush():最后記得調用flush將數據寫入磁盤

注:由于磁盤緩存和內存緩存一般是同時工作的,因此這里在使用DiskLruCache的時候會順便使用LruCache以更好的觀察它們之間如何相互作用。主要的一個關系就是,在從磁盤讀出數據的同時,通常會將該數據加載到內存當中

DiskLruCache的初始化

以下是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的寫入數據的方法,即從網上拉取圖片到磁盤中,考慮到實用性,這里直接給出從網絡獲取圖片的方法,里面其實包含了幾個過程:從網上獲取圖片的輸入流InputStream,通過InputStream和OutputStream將該資源寫入到磁盤緩存中,最后從磁盤中獲取所需大小的圖片。
其中第二個過程正是DiskLruCache的寫入方法,配合其他過程能夠更好的理解DiskLruCache的工作

/**
 * 從網絡中下載圖片到磁盤中并獲取到按需壓縮后的圖片
 * 1.由于涉及到網絡通信,因此該方法應該運行在子線程當中
 * 2.圖片是完整下載下來的,reqWidth和reqHeight只是在從磁盤讀取的時候進行
 *      壓縮用的
 * 3.在存到磁盤的時候url是轉換過的編碼的
 *  4.要通過editor的commit保證被其它的Reader看到,而且在commit中會保證緩存大小不超過閾值
 * 5.最后記得調用flush()將數據確實寫入文件系統
 * 
 * @param url 圖片網址
 * @param reqWidth 所需寬度
 * @param reqHeight 所需高度
 * @return 按需壓縮后的圖片
 * @throws IOException
 */
private Bitmap loadBitmapFromHttp(String url, int reqWidth, int reqHeight) throws IOException
{
    if(Looper.myLooper() == Looper.getMainLooper())
        throw new RuntimeException("can not visit network from UI Thread.");
    if(mDiskLruCache == null)
        return null;
    
    String key = hashKeyForDisk(url);
    DiskLruCache.Editor editor = mDiskLruCache.edit(key);
    if(editor != null)
    {
        OutputStream outputStream = editor.newOutputStream(DISK_CACHE_INDEX);
        //寫入完成后絕不能忘了commit和flush
        if(downloadUrlToStream(url, outputStream))
        {
            editor.commit();
        }else
        {
            editor.abort();
        }
        //flush確保數據寫入文件系統
        mDiskLruCache.flush();
    }
    
    return loadBitmapFromDiskCache(url, reqWidth, reqHeight);
}   

上面是這個流程,其中使用了3個方法

  1. hashKeyForDisk是將url編碼成key方便存儲和查找
  2. downloadUrlToStream是將資源寫到outputStream中也就是寫到文件系統中
  3. loadBitmapFromDiskCache(url, reqWidth, reqHeight)是從磁盤中讀取剛剛寫入的資源
    注意這里傳入的參數是url,不傳key是因為在讀取這個方法中還會將url過同樣的處理變成key.
    至于為什么不拿key做參數,那是因為在其他地方從磁盤中獲取資源的時候只需要傳入url就行了,不用自行轉化成key

下面是其中兩個方法,最后一個讀取磁盤另外講

/**
 * 考慮到直接使用URL作為DiskLruCache中LinkedHashMap的Key不太適合,
 * 因為圖片URL中可能包含一些特殊字符,這些字符有可能在命名文件時是不合法的。
 * 其實最簡單的做法就是將圖片的URL進行MD5編碼,編碼后的字符串肯定是唯一的,
 * 并且只會包含0-F這樣的字符,完全符合文件的命名規則。
 *
 * 該方法在寫入磁盤和從磁盤讀取時都需要用到,因為它是計算出索引的方法
 * 
 * @param key 圖片的url
 * @return MD5編碼之后的key
 */
public String hashKeyForDisk(String key) {  
    String cacheKey;  
    try {  
        final MessageDigest mDigest = MessageDigest.getInstance("MD5");  
        mDigest.update(key.getBytes());  
        cacheKey = bytesToHexString(mDigest.digest());  
    } catch (NoSuchAlgorithmException e) {  
        cacheKey = String.valueOf(key.hashCode());  
    }  
    return cacheKey;  
}  
  
private String bytesToHexString(byte[] bytes) {  
    StringBuilder sb = new StringBuilder();  
    for (int i = 0; i < bytes.length; i**) {  
        String hex = Integer.toHexString(0xFF & bytes[i]);  
        if (hex.length() == 1) {  
            sb.append('0');  
        }  
        sb.append(hex);  
    }  
    return sb.toString();  
} 

/**
 * 將圖片資源寫到文件系統上
 * @param urlString 資源
 * @param outputStream Editor的對應Entry下的文件的OutputStream
 * @return
 */
private boolean downloadUrlToStream(String urlString, OutputStream outputStream) {  
    HttpURLConnection urlConnection = null;  
    BufferedOutputStream out = null;  
    BufferedInputStream in = null;  
    try {  
        final URL url = new URL(urlString);  
        urlConnection = (HttpURLConnection) url.openConnection();  
        in = new BufferedInputStream(urlConnection.getInputStream(),   
                IO_BUFFER_SIZE);  
        out = new BufferedOutputStream(outputStream, IO_BUFFER_SIZE);  
        int b;  
        while ((b = in.read()) != -1) {  
            out.write(b);  
        }  
        return true;  
    } catch (final IOException e) {  
        e.printStackTrace();  
    } finally {  
        if (urlConnection != null) {  
            urlConnection.disconnect();  
        }  
        try {  
            if (out != null) {  
                out.close();  
            }  
            if (in != null) {  
                in.close();  
            }  
        } catch (final IOException e) {  
            e.printStackTrace();  
        }  
    }  
    return false;  
} 
    

以上就是DiskLruCache的寫入數據的過程,其實核心只是3步,即
1.獲取mDiskLruCache中對應key的Editor
2.獲取Editor的OutputStream
3.通過OutputStream寫入數據
而上面寫那么多是為了能夠更好地理解DiskLruCache的工作流程和如何使用才是最好的,比如講url轉換成key這個方法可以我們意識到這樣才能使DiskLruCache工作得更好

Editor editor = mDiskLruCache.edit(key);
OutputStream outputStream = editor.newOutputStream(DISK_CACHE_INDEX);//DISK_CACHE_INDEX == 0
outputStream.wirte(data);
DiskLruCache讀取數據

下面介紹的是根據需要的寬高加載磁盤緩存中的圖片,先是計算url對應的key,然后從mDiskLruCache中獲取到相應的數據,最后在返回之前加載到LruCache當中

/**
     * 磁盤緩存的讀取
     * @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;      
}

跟Editor寫入數據只需要3步一樣,讀取數據的核心也只需要3步
1.根據key獲取mDiskLruCache中對應的Snapshot
2.通過Snapshot獲取到文件數據的InputStream(可以向下轉型為FileInputStream)
3.通過InputStream讀取數據

Snapshot snapshot = mDiskLruCache.get(key);
InputStream inputStream = snapshot.getInputStream(DISK_CACHE_INDEX);//DISK_CACHE_INDEX == 0
int data = inputStream.read();

12.2.3 ListView的列表錯位問題

1.問題

在Adapter中,通常會復用View來優化ListView或者GridView的加載,而復用View帶來的問題就是,圖片在ListView列表中顯示的位置錯亂,即本應該顯示B圖片的位置顯示了A圖片

2.原因

假設在ListView中,每個位置對應著一個ImageView控件用于顯示一張圖片,而圖片是從網絡中拉取(沒有緩存的情況下),對應如下表所示。

列表位置 對應控件 對應圖片
itemA ImageView 圖片A
itemX ImageView 圖片X
itemY ImageView 圖片Y
itemZ ImageView 圖片Z
itemB ImageView 圖片B

問題產生的原因在于:

  1. ListView在Adapter中復用每個item的布局包括里面的控件
    即在屏幕中顯示的每個ListView的item對應的布局只有在第一次的時候被加載,然后緩存在convertView里面,之后滑動改變ListView時調用的getView就會復用緩存在converView中的布局和控件,所以可以使得ListView變得流暢(因為不用重復加載布局)
  2. 異步加載圖片需要時間
    假設每個ListView中的item都有一個ImageView顯示圖片,比如itemA需要ImageView顯示圖片A,itemB需要顯示圖片B。而圖片都是需要異步從網絡加載,所以需要時間
  3. 加載過程中ListView發生滑動
    itemA在使用ImageView加載圖片A時,ListView發生了滑動,導致itemB滑動到了itemA的位置
  4. itemB復用itemA的布局和控件
    由于Adapter復用convertView的原因,itemB的布局直接復用原來該位置的ImageView,并同時利用該ImageView加載圖片B
  5. itemA異步加載圖片A的方法還持有被復用的ImgaView
    由于是異步加載圖片,所以itemA在加載圖片的過程中會持有剛才的ImageView,導致圖片A在下載完成之后會被加載到該ImageView控件當中,因此造成了itemB對應顯示的是圖片A,這就是列表錯亂問題

3.解決方法

從上面可以看出,問題的根源在于圖片A在被加載ImageView之前,ListView發生滑動導致ImageView被itemB復用,此時該ImageView就不能顯示圖片A了。
那么就從根源入手,在圖片A被加載到ImageView之前做一個判斷,判斷該ImageView是否還是對應的是itemA,如果是則將圖片加載到ImageView當中,如果不是則放棄加載(因為itemB已經啟動了圖片B的加載,所以不用擔心控件出現空白的情況)
所以問題就變成了,如何判斷ImageView對應的item已經改變了

方法:

  1. 在每次getView的復用布局控件時,對會被復用的控件設置一個標簽(在這里就是對ImageView設置標簽),這里使用圖片的url作為標簽內容,然后再異步加載圖片
  2. 在圖片下載完成后要加載到ImageView之前做判斷,判斷該ImageView的標簽內容是否和圖片的url一樣
    如果一樣說明ImageView沒有被復用,可以將圖片加載到ImageView當中;
    如果不一樣,說明ListView發生了滑動,導致其他item調用了getView從而將該ImageView的標簽改變,此時放棄圖片的加載(盡管圖片已經被下載成功了)
    注:如果是直接從內存里讀取的數據,則不需要對比tag,因為這幾乎不需要時間

4.具體實現

1)在getView中給ImageView設置標簽內容

imageView.setTag(uri);//對應imageView.getTag()
//or
imageView.setTag(TAG_KEY_URI, uri);//對應imageView.getTag(TAG_KEY_URI) TAG_KEY_URL是常量      

2)在給ImageView設置圖片前判斷圖片的uri是否和ImageView的標簽內容

if(uri.equals(imageView.getTag(TAG_KEY_URI)))
{
    //如果相等才設置圖片
    imageView.setImageBitmap(bitmap);
}else
{
    Log.w(TAG,"set image bitmap, but url has changed, ignored!");
}      

12.2.4 ImageLoader的實現

ImageLoader封裝了Bitmap的高效加載、LruCache和DiskLruCache,學習ImageLoader的實現有利于對Android的Bitmap的加載和緩存機制有更深刻的理解
ImageLoader應該具備以下功能

  • 圖片的同步加載
  • 圖片的異步加載
  • 圖片壓縮
  • 內存緩存
  • 磁盤緩存
  • 網絡拉取

圖片的同步加載

同步:先復習一下同步的意思,就是整個流程會按順序執行,如果有阻塞那就一直等待阻塞返回。

ImageLoader采取的圖片的同步加載的方法是loadBitmap,其中的流程是
1.loadBitmapFromMemoryCache:從內存緩存提取圖片
2.loadBitmapFromDisk:從磁盤緩存提取圖片
3.loadBitmapFromHttp:從網絡拉取圖片(要經過磁盤的存儲)
*4.downloadBitmapFromUrl:直接從網絡提取圖片(不經過磁盤存儲)

注:
* 只有前一步提取不成功時才會執行下一步;
* 第4步是為了防止因為磁盤不夠而第3步執行不成功的情況,本質都是通過網絡獲取圖片

從ImageLoader這個名稱上來看就知道圖片加載時ImageLoader的最主要的工作,因此要深刻理解ImageLoader中圖片加載的步驟。ImageLoader封裝了整個加載流程,只向外提供了統一的接口loadBitmap,這方便了調用者不用再去關心加載的流程,以下是loadBitmap的程序:

/**
 * load bitmap from memory cache or disk or network
 * NOTE that should run in a new Thread
 * @param url http url
 * @param reqWidth  the width that imageView desire
 * @param reqHeight the height that imageView desire
 * @return bitmap maybe null
 */
public Bitmap loadBitmap(String url, int reqWidth, int reqHeight)
{
    Bitmap bitmap = loadBitmapFromMemoryCache(url);
    
    if(bitmap != null)
    {
        Log.d(TAG, "loadBitmapFromMemoryCache, url:"+url);
        return bitmap;
    }
    
    try
    {
        bitmap = loadBitmapFromDiskCache(url, reqWidth, reqHeight);
        if(bitmap != null)
        {
            Log.d(TAG, "loadBitmapFromDiskCache, url:"+url);
            return bitmap;
        }
        bitmap = loadBitmapFromHttp(url, reqWidth, reqHeight);
        Log.d(TAG, "loadBitmapFromHttp, url:"+url);
        
    }catch(Exception e)
    {
        e.printStackTrace();
    }
    
    if(bitmap == null && !mIsDiskLruCacheCreated)
    {
        Log.w(TAG, "encounter error, DiskLruCache is not created.");
        bitmap = downloadBitmapFromUrl(url);
    }
    
    return bitmap;
}
  1. loadBitmapFromMemoryCache:從內存緩存提取圖片

private Bitmap loadBitmapFromMemoryCache(String url)
{
    final String key = hashKeyForDisk(url);
    return getBitmapFromMemoryCache(key);
}

private Bitmap getBitmapFromMemoryCache(String key)
{
    return mLruCache.get(key);
}

private void addBitmapToMemoryCache(String key, Bitmap bitmap)
{
    if(getBitmapFromMemoryCache(key) == null)
    {
        mLruCache.put(key, bitmap);
    }
}

2.loadBitmapFromDisk:從磁盤緩存提取圖片


private Bitmap loadBitmapFromDiskCache(String url, int reqWidth, int reqHeight)
{
    if(Looper.myLooper() == Looper.getMainLooper())
    {
        Log.w(TAG, "load bitmap from UI Thread is not recomment.");
    }
    if(mDiskLruCache == null)
    {
        return null;
    }
    
    Bitmap bitmap = null;
    final String key = hashKeyForDisk(url);
    Snapshot snapshot = mDiskLruCache.get(key);
    if(snapshot == null)
    {
        return 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;
}

3.loadBitmapFromHttp:從網絡拉取圖片(要經過磁盤的存儲) ,其實是先存到磁盤到從磁盤中讀取


private Bitmap loadBitmapFromHttp (String url, int reqWidth, int reqHeight)
    throws IOException
{
    if(Looper.myLooper() == Looper.getMainLooper())
    {
        throw new RuntimeException("can not visit network in from UI Thread");
    }
    if(mDiskLruCache == null)
    {
        return null;
    }
    
    final String key = hashKeyForDisk(url);
    Editor editor = mDiskLruCache.edit(key);
    if(editor != null)
    {
        OutputStream outputStream = editor.newOutputStream(DISK_CACHE_INDEX);
        if(downloadUrlToStream(url, outputStream))
        {
            editor.commit();
        }else
        {
            editor.abort();
        }
    }
    mDiskLruCache.flush();
    
    return loadBitmapFromDiskCache(url, reqWidth, reqHeight);
}

/**
 * 將圖片資源寫到文件系統上
 * @param urlString 資源
 * @param outputStream Editor的對應Entry下的文件的OutputStream
 * @return
 */
private boolean downloadUrlToStream(String urlString, OutputStream outputStream) {  
    HttpURLConnection urlConnection = null;  
    BufferedOutputStream out = null;  
    BufferedInputStream in = null;  
    try {  
        final URL url = new URL(urlString);  
        urlConnection = (HttpURLConnection) url.openConnection();  
        in = new BufferedInputStream(urlConnection.getInputStream(), 
        IO_BUFFER_SIZE);  
        out = new BufferedOutputStream(outputStream, IO_BUFFER_SIZE);  
        int b;  
        while ((b = in.read()) != -1) {  
            out.write(b);  
        }  
        return true;  
    } catch (final IOException e) {  
        e.printStackTrace();  
    } finally {  
        if (urlConnection != null) {  
            urlConnection.disconnect();  
        }  
        try {  
            if (out != null) {  
                out.close();  
            }  
            if (in != null) {  
                in.close();  
            }  
        } catch (final IOException e) {  
            e.printStackTrace();  
        }  
    }  
    return false;  
} 

*4.downloadBitmapFromUrl:直接從網絡提取圖片(不經過磁盤存儲)


private Bitmap downloadBitmapFromUrl(String urlString)
{
    Bitmap bitmap = null;
    HttpURLConnection connection = null;
    BufferedInputStream bis = null;
    
    try
    {
        URL url = new URL(urlString);
        connection = (HttpURLConnection) url.openConnection();
        bis = new BufferedInputStream(connection.getInputStream(), 
        IO_BUFFER_SIZE);
        bitmap = BitmapFactory.decodeStream(bis);
    }catch(IOException e)
    {
        Log.e(TAG,"Error in downloadBitmap: " + e);
    }finally
    {
        if(connection != null)
        {
            connection.disconnect();
        }
        if(bis != null)
        {
            try {
                bis.close();
            } catch (IOException e) {
                // TODO Auto-generated catch block
                e.printStackTrace();
            }
        }
    }
    return bitmap;
}

圖片的異步加載

異步:指在執行整個流程的時候,如果發生阻塞,那么此時會去做其他跟阻塞返回無關事情,如果阻塞返回了再去執行跟阻塞相關的事情。
簡單來說異步就是在主線程里開另一個線程里做阻塞的事情,而主線程完成其他的工作,然后在子線程阻塞返回后通過回調(在Android中一般是通過Handler)將結果返回給主線程,此時主線程會按照預定好的規則(handleMessage)處理相關的結果

在ImageLoader當中需要圖片的異步加載的原因:因為圖片的下載是需要時間的,也就是需要在子線程中下載,而圖片下載完成之后需要加載到外界向ImageLoader提供的ImageView當中,也就是需要在主線程中加載圖片,還需要線程間通信。
所以ImageLoader不僅要同步加載圖片,還應該向外提供異步加載的接口,使得外界提供url和ImageView之后就不用再管圖片在子線程中的下載和在主線程中的加載了

實現異步加載的條件
1.Excutor:線程池,用于開啟新線程下載圖片
2.Handler:主線程的Handler,用于將下載完成后的圖片在主線程中加載到ImageView當中
3.bindBitmap:異步加載圖片,包括了在子線程中同步加載圖片和在主線程中設置圖片
*4.列表錯亂預防:利用ImageView的標簽防止異步加載圖片時發生列表錯亂
注:第4步是跟ListView或者GridView列表錯亂有關,實際意義上的實現異步只需要前面三個條件

1.線程池的創建
這里使用線程池而不使用AsyncTask的原因,AsyncTask在3.0之后無法實現并發的效果(雖然AsyncTask里也使用了線程池,但是自帶的SerialExecutor導致任務只能一個個被執行)。而ImageLoader需要并發的效果,所以使用了線程池

//線程池的配置
private static final int CPU_COUNT = Runtime.getRuntime().availableProcessors();
private static final int CORE_POOL_SIZE = CPU_COUNT + 1;
private static final int MAXIMUM_POOL_SIZE = CPU_COUNT * 2 + 1;
private static final long KEEP_ALIVE = 10L;
private static final ThreadFactory sThreadFactory = new ThreadFactory() {
    
    private final AtomicInteger mCount = new AtomicInteger(1);
    
    @Override
    public Thread newThread(Runnable r) {
        // TODO Auto-generated method stub
        return new Thread(r,"ImageLoader#" + mCount.getAndIncrement());
    }
};
//作為靜態成員的線程池
//線程池的創建
public static final Executor THREAD_POOL_EXEUTOR = 
        new ThreadPoolExecutor(
                CORE_POOL_SIZE, MAXIMUM_POOL_SIZE,
                KEEP_ALIVE, TimeUnit.SECONDS, 
                new LinkedBlockingQueue<Runnable>(), 
                sThreadFactory);


2.Handler的創建
由于該Handler是向主線程發送消息的,因此Handler需要在主線程創建。由于ImageLoader很可能在子線程中創建而導致成員都在子線程中被創建,因此該Handler的創建需要借助主線程的Looper對象,即Looper.getMainLooper()
其中在給ImageView設置圖片前先判斷列表項是否已經改變

//主線程Handler創建的方法
private Handler mMainHandler = new Handler(Looper.getMainLooper())
{
    @Override
    public void handleMessage(android.os.Message msg) {
        
        LoaderResult result = (LoaderResult) msg.obj;
        ImageView imageView = result.imageView;
        String uri = result.uri;
        if(uri.equals(imageView.getTag(TAG_KEY_URI)))
        {
            imageView.setImageBitmap(result.bitmap);
        }else
        {
            Log.w(TAG, "set image bitmap, but url has changed, ignored!");
        }
        
    };
};

/**
 * 用于線程間通信的實體類,防止列表錯亂的關鍵
 *  因為它攜有原圖的url和要被加載的ImageView
 */
private static class LoaderResult
{
    public String uri;
    public ImageView imageView;
    public Bitmap bitmap;
    public LoaderResult(String uri, ImageView imageView, Bitmap bitmap)
    {
        this.uri = uri;
        this.imageView = imageView;
        this.bitmap = bitmap;
    }
}

3.異步加載圖片bindBitmap
包括了在子線程中同步加載圖片(從內存、磁盤和網絡加載)以及通過Handler將結果送給主線程
它是一個向外提供的接口,因此外界可以直接使用它

/**
 * load bitmap from memory cache or disk or http, 
 *  then bind bitmap and ImageView
 * @param uri
 * @param imageView
 */
public void bindBitmap(final String uri, final ImageView imageView)
{
    bindBitmap(uri, imageView, 0, 0);
}

/**
 * 封裝了從url獲取圖片到加載到特定的ImageView當中的方法
 * 其中先是從內存中獲取圖片,如果獲取不到在調用loadBitmap獲取圖片
 * 
 * 如果是從網絡中獲取圖片,則
 * 在將圖片加載到ImageView前,會將圖片的url和ImageView的tag進行對比
 *  如果相同則說明ImageView沒有被復用,因此可以加載圖片
 *  如果不同則說名ImageView被復用隔了,放棄加載下載完成的圖片
 * 
 * @param url
 * @param imageView
 * @param reqWidth
 * @param reqHeight
 */
public void bindBitmap(final String url, final ImageView imageView,
        final int reqWidth, final int reqHeight)
{
    imageView.setTag(TAG_KEY_URI);
    
    Bitmap bitmap = loadBitmapFromMemoryCache(url);
    if(bitmap != null)
    {
        imageView.setImageBitmap(bitmap);
        return;
    }
    
    Runnable loadBitmapTask = new Runnable()
    {
        @Override
        public void run() {
            Bitmap bitmap = loadBitmap(url, reqWidth, reqHeight);
            if(bitmap != null)
            {
                LoaderResult result = new LoaderResult(url, imageView, bitmap);
                mMainHandler.obtainMessage(MESSAGE_POST_RESULT, result)
                    .sendToTarget();
            }
        };
    };
    
    THREAD_POOL_EXEUTOR.execute(loadBitmapTask);
    
}

*4.防止列表錯亂
前面一節已經提到了如何防止列表錯亂的方法,這里強調一下ImageLoader中設置標簽和對比標簽的位置

1.給ImageView設置標簽
在bindBitmap中,在利用子線程同步加載圖片前將ImageView設置標簽,然后該imageView會在圖片下載完成后和圖片一起通過Handler傳送到主線程

imageView.setTag(TAG_KEY_URI);

2.取出ImageView的標簽與圖片url對比
在Handler的handleMessage中,在用imageView設置圖片之前進行對比

LoaderResult result = (LoaderResult) msg.obj;
ImageView imageView = result.imageView;
String uri = result.uri;
if(uri.equals(imageView.getTag(TAG_KEY_URI)))
{
    imageView.setImageBitmap(result.bitmap);
}else
{
    Log.w(TAG, "set image bitmap, but url has changed, ignored!");
}

圖片壓縮

參照12.1節的ImageResizer.java

內存緩存

參照12.2.1節的LruCache
loadBitmapFromMemoryCache(String url)

磁盤緩存

參照12.2.2節的DiskLruCache
loadBitmapFromDiskCache(String url, int reqWidth, int reqHeight)

網絡拉取

參照12.2.2節的DiskLruCache
loadBitmapFromHttp(String url, int reqWidth, int reqHeight)


12.3 ListView或GridView的優化

ListView是開發時經常用到的控件,如果使用的不夠好,會造成卡頓等體驗不好的現象,下面就介紹可用且好用的優化方法

核心思想

不要在主線程中做太耗時的操作

方法

1.不要再Adapter的getView中執行耗時操作
列表的滑動觸發最多的方法就是Adapter的getView方法,如果getView方法里面執行的操作太耗時,就會造成卡頓現象。如果有耗時的操作比如像上面的從網絡加載圖片,那么就應該開啟新線程異步加載圖片

2.控制異步任務的執行頻率
既然上面使用了異步任務,那么要考慮到用戶頻繁滑動的情景,這個是百分百發生的場景,所以一定要注意
方法是,ListView或GridView設置OnScollListener,在Adapter中設置一個布爾成員mIsListViewIdle用于記錄當前列表是否在滑動從而在getView里面限制列表的加載,程序如下

/*
    為列表控件設置監聽器,并在滑動的時候設置Adapter的mIsListViewIdle成員
 */
mImageGridView.setOnScrollListener(new OnScollListener()
{
    @Override
    public void onScrollStateChanged(AbsListView view, int scrollState) {
        // TODO Auto-generated method stub
        if(scrollState == OnScrollListener.SCROLL_STATE_IDLE)
        {
            mImageAdapter.setIsGridViewIdle(true);
            mImageAdapter.notifyDataSetChanged();
        }else
        {
            mImageAdapter.setIsGridViewIdle(false);
        }
    }

    @Override
    public void onScroll(AbsListView view, int firstVisibleItem,
            int visibleItemCount, int totalItemCount) {
        // TODO Auto-generated method stub
        
    }
    });

下面是ImageAdapter.java中的部分代碼

//ImageAdapter.java中的getView,只有在mIsGridViewIdle為true即列表停止滑動的時候才加載數據
private boolean mIsGridViewIdle = true;

public void setIsGridViewIdle(boolean mIsGridViewIdle) {
    this.mIsGridViewIdle = mIsGridViewIdle;
}

@Override
public View getView(int position, View convertView, ViewGroup parent) {
    // TODO Auto-generated method stub
    ViewHolder viewHolder = null;
    if(convertView == null)
    {
        convertView = LayoutInflater.from(mContext).inflate(R.layout.list_item_image, parent, false);
        viewHolder = new ViewHolder();
        viewHolder.imageView = (ImageView) convertView.findViewById(R.id.iv_list_item);
        convertView.setTag(viewHolder);
    }else
    {
        viewHolder = (ViewHolder)convertView.getTag();
    }
    
    ImageView imageView = viewHolder.imageView;
    String url = getItem(position);
    
    if(!url.equals(imageView.getTag()+""))
    {
        imageView.setImageDrawable(mDefaultBitmapDrawable);
    }
    
    if(mIsGridViewIdle)
    {
        imageView.setTag(url);
        mImageLoader.bindBitmap(url, imageView, mImageWidth,mImageWidth);
    }
    
    return convertView;
}    

3.為Activity開啟硬件加速

android:hardwareAccelerated="true";
最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容