Android开发艺术探索——第十二章:Bitmap的加载和Cache

这章讲述的是Bitmap的加载和Cache,主要包含三个方面的内容,首先讲述如何有效的加载一个Bitmap,这是一个很有意义的话题,由于Bitmap的特殊性以及Android对单个应用所施加的内存限制,比如16MB,这就导致加载Bitmao的时候很容易的出现内存溢出:

java.lang.OutofMemoryError:bitmap size exceeds VM budget

因此如何高效的加载Bitmap是一个很重要的问题也很容易被开发者忽视

接着介绍Android中常见的缓存策略,缓存策略是通用的意思,可以用在很多场景中,但是实际开发当中经常需要用Bitmap缓存,通过缓存策略,我们不需要每次都从网络上请求图片或者从存储设备中加载图片,这样就极大的提高了图片的加载肖玲以及产品的用户体验,目前比较好的缓存策略是LrcCache和DiskLruCache,其中前者常备用作内存缓存,后者是存储缓存

最后还会介绍一下如何优化卡顿现象,以及listview加载大量子视图,当用户快速滑动卡顿的解决办法

一.Bitmap的高效加载

在介绍Bitmap的高效加载之前,先说一下如何高效的加载一个Bitmap,Bitmap在Android中指的是一张图片,想要加载的话BitmapFactory的decodeFile,decodeResource,decodeStream,decodeByteArray
分别对应的从文件系统,资源,输入流,以及字节数组加载一个Bitmap对象,其中文件和资源,其实是间接调用了输入流的方法,这四类方法最终实在android的底层实现的,对应的几个native方法。

如何高效的加载Bitmap呢?其实核心思想也很简单,那就是采用BitmapFactory.Options来加载所需尺寸的图片,这里假设通过ImageView来显示图片,很多时候ImageView并没有图片的原始尺寸那么大,这个时候把整个图片加载进来后再设给ImageView,这显然是没有必要的,BitmapFactory.Options就可以按照一定的采用率来压缩图片,然后显示,这样降低内存占用可以一定程度避免OOM

通过采样率即有效的加载图片,那么到底如何回去采样率呢?

  • 1.将BitmapFactory.Options的inJustDecodeBounds参数设置为true并且加载图片
  • 2.从BitmapFactory.Options中取出图片的原始宽高
  • 3.根据采用率的规则结合目标的View所需计算采用率
  • 4.将BitmapFactory.Options的inJustDecodeBounds参数设置为false然后重新加载图片

经过上面的4个步骤,加载出的图片就是最终缩放的图片,当然也有可能不需要缩放,这么说一下inJustDecodeBounds这个参数,为true的时候,
BitmapFactory只会解析图片的原始宽高,并不会去真正的加载图片,所以这是轻量级的操作,另外需要注意的是,这个时候BitmapFactory获取到的图片宽高和图片位置以及程序运行的设备有关,比如同一张图放在不同的drawable目录下或者程序运行在不同屏幕密度的设备上,这都可能导致BitmapFactory获取到不同的结果,之所以会出现这个现象,这和Activity的资源家在机制有关

我们来看一下代码

    public static Bitmap decodeSampleBitmapFromResource(Resources res, int resId, int reqWidth, int reqHeight) {
        BitmapFactory.Options options = new BitmapFactory.Options();
        options.inJustDecodeBounds = true;
        BitmapFactory.decodeResource(res, resId, options);
        options.inSampleSize = calculateInSampleSize(options, reqWidth, reqHeight);
        options.inJustDecodeBounds = false;
        return BitmapFactory.decodeResource(res, resId, options);
    }

    public static int calculateInSampleSize(BitmapFactory.Options options, int reqWidth, int reqHeight) {
        int width = options.outWidth;
        int height = options.outHeight;
        int inSampleSize = 1;

        if (height > reqHeight || width > reqWidth) {
            int halfHeight = height / 2;
            int halfWidth = width / 2;
            while (halfHeight / inSampleSize >= reqHeight && (halfWidth / inSampleSize) >= reqWidth) {
                inSampleSize *= 2;
            }
        }
        return inSampleSize;
    }

有了上面的两个方法,实际使用的时候就很简单,比如ImageView所期望的大小是100*100

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

除了加载资源的这个方法,其他的也都支持,就是输入流的有些特殊,会在后面介绍

二.Android中的缓存策略

缓存策略在Android中有着广泛的使用场景,尤其在图片加载这个场景下, 缓存策略变得更加重要,考虑一种场景:有一批网络图片,需要下载后再用户界面上予以显示,这个场景在PC环境下很简单,直接把所有的图片下载到本地显示即可,但是放在移动设备上就不一样了,流量是个很可怕的东西,所以考虑缓存的问题

日常使用的算法就是LRU了,LRU是近期最少使用的算法,他的核心思想是当缓存满时,会优先淘汰那些近期最少使用的缓存对象,采用LRU算法的缓存有两种,LruCache和DiskLruCache,二者可以完美结合,就可以很方便的实现一个有价值的ImageLoader

1.LruCache

LruCache是android3.1所提供的一个缓存类,通过v4兼容包,可以兼容到早期的Android版本

LruCache是一个泛型类,他内部采用了LinkedHashMap以强引用的方式存储外界的缓存对象,然后提供get和set的操作

  • 强引用:直接的对象引用
  • 软引用:当一个对象只有软引用存在时,系统内存不足时此对象会被gc回收
  • 弱引用:当一个对象只有弱引用存在时,此对象会随时被gc回收。

另外,LruCache是线程安全的,下面是他的定义

public class LruCache<K, V> {
    private final LinkedHashMap<K, V> map;

LruCache的实现比较简单,读者可以参考它的源码,这里介绍如何使用LruCache来实现内存缓存

                int maxMemory = (int)(Runtime.getRuntime().maxMemory() / 1024);
                int cacheSize = maxMemory / 8;
                mMemoryLruCache  = new LruCache<String,Bitmap>(cacheSize){
                    @Override
                    protected int sizeOf(String key, Bitmap value) {
                        return value.getRowBytes() * value.getHeight() / 1024;
                    }
                };

在上面的代码中,只需要提供缓存的总容量代销并且重写sizeOf即可,sizeOf的作用是计算缓存对象的大小,这里大小的单位需要和总容量的单位一直,对于上面的实例代码,总容量的代销是当前进程可用的八分之一,单位是kb,而sizeOf则完成了bitmap对象的大小计算,很显然,之所以除以1024也是为了将其 单位转换成KB,一些特殊情况下,还需要重写entryRemoved来处理资源回收的工作

除了LruCache的创建外,我们还可以get和put

                mMemoryLruCache.get(key);
                mMemoryLruCache.put(key,bitmap);

当然,也可以删除之类的各种操作

2.DiskLruCache

DiskLruCache用于实现存储设备缓存,即磁盘缓存,他通过缓存对象写入文件系统从而实现缓存的效果

-1.DiskLruCache的创建

DiskLruCache并不能通过构造方法来创建,他提供了open方法才可以,DiskLruCache的源码会在demo中

public static DiskLruCache open(File directory, int appVersion, int valueCount, long maxSize)

这四个参数,其中第一个参数表示磁盘缓存在文件系统中的存储路径,缓存路径可以选择SD卡上的缓存目录,具体是指sdcard/Android/data/package_name/cache目录,第二个参数表示应用的版本号,一般设为1即可,当版本号发生改变时DiskLruCache会清空之前所有的缓存文件,这个特性作用不大,很多情况下即使版本号更改了还是有效,第三个参数表示单个节点所对应的数据个数,一般设为1即可,第四个参数表示缓存的总大小,比如50MB,当换粗大小超过一个设定值的时候,他会清楚一些缓存来保证总大小不小于这个值:

                long DISK_CACHE_SIZE = 1024 * 1024 *50;
                File diskCacheDir = getDiskCacheDir(this,"bitmap");
                if(!diskCacheDir.exists()){
                    diskCacheDir.mkdirs();
                }
                try {
                    mDiskLruCache = DiskLruCache.open(diskCacheDir,1,1,DISK_CACHE_SIZE);
                } catch (IOException e) {
                    e.printStackTrace();
                }

-2.DiskLruCache的缓存添加

DiskLruCache的缓存添加的操作是通过Editor完成的,Editor表示一个缓存对象的编辑对象,这里任然以图片缓存为例,首先需要获取图片url所对应的key,然后根据key就可以通过edit()来获取Editor对象,如果一个缓存对象正在被编辑,那么edit()会返回null,,之所以把url转换成key,是因为图片的url中很可能有特殊字符,这将影响url在Android中的直接使用,一般采用url的md5值作为key

    private String hashKeyFormUrl(String url){
        String cacheKey;
        try {
            MessageDigest mDigest = MessageDigest.getInstance("MD5");
            mDigest.update(url.getBytes());
            cacheKey = bytesToHexString(mDigest.digest());
        } catch (NoSuchAlgorithmException e) {
            cacheKey = String.valueOf(url.hashCode());
        }
        return cacheKey;
    }

    private String bytesToHexString(byte[] digest) {
        StringBuffer sb = new StringBuffer();
        for (int i = 0; i < digest.length; i++) {
            String hex = Integer.toHexString(0xFF&digest[i]);
            if(hex.length() == 1){
                sb.append('0');
            }
            sb.append(hex);
        }
        return sb.toString();
    }

将图片的url转为key之后,就可以获取Editor对象了,对于这个key来说,如果当前不存在其他Editor对象,那么edit()就会返回一个新的Editor对象,通过他就可以得到一个文件输入流,需要注意的是,由于前面的open设置了一个节点只能有一个数据,因此DISK_CACHE_SIZE = 0即可

                int DISK_CACHE_INDEX = 0;
                String key = hashKeyFormUrl(url);
                try {
                    DiskLruCache.Editor editor = mDiskLruCache.edit(key);
                    if(editor != null){
                        OutputStream outputStream = editor.newOutputStream(DISK_CACHE_INDEX);
                    }
                } catch (IOException e) {
                    e.printStackTrace();
                }

有了文件输入输出流,接下来要怎么做,其实是这样的,当从网络下载图片的时候,图片就可以写入文件系统了:

    private boolean downloadUrlToStream(String urlString,OutputStream outputStream){
        int IO_BUFFER_SIZE = 0;
        HttpURLConnection urlConnection = null;
        BufferedOutputStream out = null;
        BufferedInputStream in = null;
        try {
            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 (MalformedURLException e) {
            e.printStackTrace();
        } catch (IOException e) {
            e.printStackTrace();
        }finally {
            if(urlConnection != null){
                urlConnection.disconnect();
            }
        }
        return false;
    }

经过上面的步骤,其实并没有真正的将图片写入文件系统,还必须通过Editor的commit来提交操作,这个图片下载过程发生了异常,那么还可以通过Editor的abort来回退操作

                    DiskLruCache.Editor editor = mDiskLruCache.edit(key);
                    if(editor != null){
                        OutputStream outputStream = editor.newOutputStream(DISK_CACHE_INDEX);
                        if(downloadUrlToStream(url,outputStream)){
                            editor.commit();
                        }else {
                            editor.abort();
                        }
                    }

这样以后就不需要网络了

-3.DiskLruCache的缓存查找

和缓存的添加过程类似,缓存查找过程中也需要将url转换为key。然后通过DiskLruCache的get方法得到一个Snapshot对象,接着再通过Snapshot对象即可得到缓存的文件输入流,自然就可以得到Bitmap对象了,为了避免图片过程中导致的OOM问题,一般不建议直接加载原始图片,上面介绍的缩放,实际上对FileInputStream的缩放存在问题,原因是FileInputStaeam是一种有序的文件流,而两次decodeStream调用影响了文件流的位置属性,导致了第二次decodeStream时得道的是null。为了解决这个问题,可以通过文件流得到他所对应的文件描述符然后再通过BitmapFactory.decodeFileDescriptor方法来加载一张缩放后的图片,这个过程的实现如下:

                Bitmap bitmap = null;
                String keys = hashKeyFormUrl(url);
                try {
                    DiskLruCache.Snapshot snapshot = mDiskLruCache.get(keys);
                    if(snapshot != null){
                        FileInputStream fileInputStream = (FileInputStream) snapshot.getInputStream(DISK_CACHE_INDEX);
                        FileDescriptor fileDescriptor = fileInputStream.getFD();
                        bitmap = mImageResizer.decodeSampledBitmapFromFileDescriptor(fileDescriptor,reqWidth,reqHeight);
                        if(bitmap != null){
                            addBitmapToMemoryCache(keys,bitmap);
                        }
                    }

这上面是DiskLruCache的创建吗,缓存,和添加查找过程,读者应该对他的使用有一个大概的理解,当然他还有一些其他的方法remoce,delete之类的用于磁盘缓存的删除操作。

3.ImageLoader的实现

我们理解了缓存之后可以开始对他们进行封装,来打造一个完美的ImageLoader,一般来说,一个优秀的ImageLoader应该具备:

  • 图片的同步加载
  • 图片的异步加载
  • 图片压缩
  • 内存缓存
  • 磁盘缓存
  • 网络拉取

图片的同步加载是指能够同步的方式向调用者所提供加载的图片,这个图片可能是从内存缓存中读取,也可以是磁盘的,还可以是网络拉取的,图片的异步加载是一个很有用的功能,很多时候调用者不想再单独的线程里加载图片并将图片设置给需要的ImageView,图片压缩的作用毋庸置疑了,避免OOM的有效手段

内存和磁盘缓存是ImageLoader的核心,也是ImageLoader的意义所在,通过两级缓存极大的提高了程序的效率降低了用户所造成的流量消耗,只是当着二级缓存都不可用才需要从网络拉取

除此之外,ImageLoader还需要一个特殊的情况,比如ListView中,View复用即是他的优点也是他的缺点,缺点就是在ListView中,假设一个item A正在从网站加载图片,他对应的ImageView为A,这个时候用户快速向下滑动列表,很有可能itemB复用了ImageView A,然后等一会之前的图片加载完之后,如果直接给ImageView A设置图片,由于这个时候ImageViewA被itemB所复用,但是item B要显示的图片显然不是item A刚刚下载好的图片,这个时候就会出现item B中显示了item A的图片,这就是比较常见的错误。

-1.图片压缩功能的实现

来看下代码

public class ImageResizer {

    private static final String TAG = "ImageResizer";

    public ImageResizer() {

    }

    public static Bitmap decodeSampleBitmapFromResource(Resources res, int resId, int reqWidth, int reqHeight) {
        BitmapFactory.Options options = new BitmapFactory.Options();
        options.inJustDecodeBounds = true;
        BitmapFactory.decodeResource(res, resId, options);
        options.inSampleSize = calculateInSampleSize(options, reqWidth, reqHeight);
        options.inJustDecodeBounds = false;
        return BitmapFactory.decodeResource(res, resId, options);
    }

    public static int calculateInSampleSize(BitmapFactory.Options options, int reqWidth, int reqHeight) {
        int width = options.outWidth;
        int height = options.outHeight;
        int inSampleSize = 1;

        if (height > reqHeight || width > reqWidth) {
            int halfHeight = height / 2;
            int halfWidth = width / 2;
            while (halfHeight / inSampleSize >= reqHeight && (halfWidth / inSampleSize) >= reqWidth) {
                inSampleSize *= 2;
            }
        }
        return inSampleSize;
    }
}

-2.内存缓存和磁盘缓存的实现

这里选择二级缓存来做这个工作,在ImageLoader初始化时,会创建

    public ImageLoader(Context context) {
        mContext = context.getApplicationContext();
        int maxMemory = (int) (Runtime.getRuntime().maxMemory() / 1024);
        int cacheSize = maxMemory / 8;
        mMemoryLruCache = new LruCache<String, Bitmap>(cacheSize) {
            @Override
            protected int sizeOf(String key, Bitmap value) {
                return value.getRowBytes() * value.getHeight() / 1024;
            }
        };
        File diskCacheDir = getDiskCacheDir(mContext,"bitmap");
        if(!diskCacheDir.exists()){
            diskCacheDir.mkdirs();
        }
        try {
            mDiskLruCache = DiskLruCache.open(diskCacheDir,1,1,DISK_CACHE_SIZE);
            mIsDiskLruCacheCreate = true;
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

在创建磁盘缓存时,这里做了一个判断,即有可能磁盘剩余空间大于磁盘缓存所需要大小,一般是指用户的手机空间已经不足了,因为没有办法创建磁盘缓存,这个时候磁盘缓存就会失效,在上面的代码中,ImageLoader的内存缓存的容量为当前进程可用内存的1/8,磁盘缓存的容量为50MB

二级缓存创建完毕之后,我们还需要提供一个添加和获取的功能

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

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

而磁盘缓存比较复杂,在之前也讲过了,这里简单再说明一下,磁盘缓存的添加需要通过Editor来完成,Editor提供了commit和abort方法来提交和撤销对文件系统的写操作,具体实现请参考下面的代码:

   private Bitmap loadBitmapFromHttp(String url,int reqWidth,int reqHeight) throws IOException {
        if(Looper.myLooper() == Looper.getMainLooper()){
            throw new RuntimeException("cat not visit network from UI thread");
        }
        if(mDiskLruCache == null){
            return null;
        }
        String key = hashKeyFormUrl(url);
        DiskLruCache.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);
    }

    private Bitmap loadBitmapFromDiskCache(String url, int reqWidth, int reqHeight) throws IOException {
        if(Looper.myLooper() == Looper.getMainLooper()){
            throw new RuntimeException("cat not visit network from UI thread");
        }
        if(mDiskLruCache == null){
            return null;
        }
        Bitmap bitmap = null;
        String key = hashKeyFormUrl(url);
        DiskLruCache.Snapshot snapshot = mDiskLruCache.get(key);
        if(snapshot != null){
            FileInputStream fileInputStream = (FileInputStream) snapshot.getInputStream(DISK_CACHE_INDEX);
            FileDescriptor fileDescriptor = fileInputStream.getFD();
            bitmap =ImageResizer.decodeSampleBitmapFromResource(fileDescriptor,reqWidth,reqHeight);
            if(bitmap != null){
                addBitmapToMemoryCache(key,bitmap);
            }
        }
        return bitmap;
    }

-3.同步加载和异步加载接口的设计

首先看同步加载,同步加载接口需要外部和线程调用,这是因为同步加载很可能比较耗时,他的实现如下:

   private Bitmap loadBitmap(String url,int reqWidth,int reqHeight){
        Bitmap bitmap = loadBitmapFromMemCache(url);
        if(bitmap != null){
            return bitmap;
        }
        try {
            bitmap = loadBitmapFromDiskCache(url,reqWidth,reqHeight);
            if(bitmap != null){
                return bitmap;
            }
            bitmap = loadBitmapFromHttp(url,reqWidth,reqHeight);
        } catch (IOException e) {
            e.printStackTrace();
        }
        if(bitmap == null && !mIsDiskLruCacheCreate){
            bitmap = downloadBitmapFromUrl(url);
        }
        return bitmap;
    }

从loadBitmap的实现可以看出,其工作过程遵循如下几个步骤,手续爱你尝试从内存缓存中读取,然后会从磁盘缓存中读取,最后才会从网络拉取,另外,这个方法不能再主线程调用

接着来看下异步加载接口的设计

    private void bindBitmap(final String uri, final ImageView imageView, final int reqWidth, final int reqHeight){
        imageView.setTag(TGA_KEY_URI,uri);
        try {
            Bitmap bitmap = loadBitmapFromDiskCache(uri,reqWidth,reqHeight);
            if(bitmap != null){
                imageView.setImageBitmap(bitmap);
                return;
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
        Runnable loadBitmapTask = new Runnable() {
            @Override
            public void run() {
               Bitmap bitmap =  loadBitmap(uri,reqWidth,reqHeight);
               if(bitmap != null){
                    LoaderResult result = new LoaderResult(imageView,uri,bitmap);
                    mMainHandler.obtainMessage(MESSAGE_POST_RESULT,reqHeight);
                    sendToTarget();
               }
            }
        };
        THREAD_POOL_EXECUTOR.execute(loadBitmapTask);
    }

从bindBitmap的实现可以看出,bindBitmap方法会尝试从内存缓存中读取图片,如果读取成功直接返回结果,否则hi在线程池中调用loadBitmap方法,当图片成功加载成功后再讲图片,图片的资质以及需要绑定的imageview封装成一个loaderresult对象,再通过mMainHandler向主线程发送一个消息,这样就可以在主线程给imageview设置图片了,之所以通过Handler来中转是因为子线程无法访问UI

bindBitmap中用到了线程池和handler,这里看一下他们的先,首先看线程池THREAD_POOL_EXECUTOR的实现:

    private static final int CODE_POOL_SIZE = CPU_COUNT + 1;
    private static final int MAXIMUM_POOL_SZIE = 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 runnable) {
            return new Thread(runnable,"ImageLoader#"+ mCount.getAndIncrement());
        }
    };
    public static final Executors THREAD_POOL_EXECUTOR = new ThreadPoolExecutor(CODE_POOL_SIZE
    ,MAXIMUM_POOL_SZIE,KEEP_ALIVE, TimeUnit.SECONDS,new LinkedBlockingDeque<Runnable>(),sThreadFactory);

之所以采用线程池是有原因的,首先肯定不能采用普通的线程去做这个事情,线程池的好处在之前都已经说了,如果我们直接采永普通的线程去加载图片,随这列表的滑动会产生大量的线程,这个效率不好,还有就是AsyncTask,他的兼容性也有问题

分析完线程池的选择,下面看下Handler的实现,ImageLoader直接采永主线程的Loopr来构造Handler对象,这使得imageloader可以在非主线程中构造,另外为了解决由于view复用所导致的列表错误这个问题,我们在设置图片的时候需要坚持url

    private Handler mMainHandler = new Handler(Looper.myLooper()){

        @Override
        public void handleMessage(Message msg) {
            LoaderResult result = msg.obj;
            ImageView imageView = result.imageview;
            imageView.setImageBitmap(result.bitmap);
            String uri = imageView.getTag(TAG_KEY_URI);
            if(uri.equals(result.uri)){
                imageView.setImageBitmap(result.bitmap);
            }
        }
    };

到此为止,ImageLoader的细节已经做了全部的分析了,至于ImageLoader的源码,书上也有,这里就不贴出来了。

书中也提供了一个照片墙的使用效果的例子,这里就不多讲了

猜你喜欢

转载自blog.csdn.net/qq_26787115/article/details/80999276