第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";
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,117评论 4 360
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 66,963评论 1 290
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 107,897评论 0 240
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,805评论 0 203
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,208评论 3 286
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,535评论 1 216
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,797评论 2 311
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,493评论 0 197
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,215评论 1 241
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,477评论 2 244
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 31,988评论 1 258
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,325评论 2 252
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 32,971评论 3 235
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,055评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,807评论 0 194
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,544评论 2 271
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,455评论 2 266

推荐阅读更多精彩内容