Android中的缓存策略

441 阅读11分钟

说到缓存策略,其实并没有统一的标准。一般来说,缓存策略主要包含缓存的添加、获取和删除这三类操作。如何添加和获取缓存这个比较好理解,那么为什么还要删除缓存呢?这是因为不管是内存缓存还是存储设备缓存,它们的缓存大小都是有限制的,因为内存和诸如SD卡之类的存储设备都是有容量限制的,因此在使用缓存时总是要为缓存指定一个最大的容量。如果当缓存容量满了,但是程序还需要向其添加缓存,这个时候该怎么办呢?这就需要删除一些旧的缓存并添加新的缓存,如何定义缓存的新旧这就是一种策略,不同的策略就对应着不同的缓存算法,比如可以简单地根据文件的最后修改时间来定义缓存的新旧,当缓存满时就将最后修改时间较早的缓存移除,这就是一种缓存算法,但是这种算法并不算很完美。目前常用的一种缓存算法是LRU(Least Recently Used), LRU是近期最少使用算法,它的核心思想是当缓存满时,会优先淘汰那些近期最少使用的缓存对象。采用LRU算法的缓存有两种:LruCache和DiskLruCache, LruCache用于实现内存缓存,而DiskLruCache则充当了存储设备缓存,通过这二者的完美结合,就可以很方便地实现一个具有很高实用价值的ImageLoader。

LruCache

LruCache是一个泛型类,它内部采用一个LinkedHashMap以强引用的方式存储外界的缓存对象,其提供了get和put方法来完成缓存的获取和添加操作,当缓存满时,LruCache会移除较早使用的缓存对象,然后再添加新的缓存对象。这里读者要明白强引用、软引用和弱引用的区别,如下所示。

  • 强引用:直接的对象引用;
  • 软引用:当一个对象只有软引用存在时,系统内存不足时此对象会被gc回收;
  • 弱引用:当一个对象只有弱引用存在时,此对象会随时被gc回收。另外LruCache是线程安全的,下面是LruCache的定义:
public class LruCache<K, V> {
    private final LinkedHashMap<K, V> map;
    ...
    }

LruCache的实现比较简单,读者可以参考它的源码,这里仅介绍如何使用LruCache来实现内存缓存。仍然拿图片缓存来举例子,下面的代码展示了LruCache的典型的初始化过程:

        //可用的最大内存 单位 KB
        int maxMemory = (int) (Runtime.getRuntime().maxMemory() / 1024);
        int cacheSize = maxMemory / 8;
        LruCache<String, Bitmap> lruCache = new LruCache<String, Bitmap>(cacheSize) {
            @Override
            protected int sizeOf(String key, Bitmap value) {
                //Bitmap所占用的内存空间数等于Bitmap的每一行所占用的空间数乘以Bitmap的行数
                return value.getRowBytes() * value.getHeight() / 1024;
            }
        };
        lruCache.get(key);
        lruCache.put(key,bitmap);
        lruCache.remove(key);

在上面的代码中,只需要提供缓存的总容量大小并重写sizeOf方法即可。sizeOf方法的作用是计算缓存对象的大小,这里大小的单位需要和总容量的单位一致。对于上面的示例代码来说,总容量的大小为当前进程的可用内存的1/8,单位为KB,而sizeOf方法则完成了Bitmap对象的大小计算。 在这里补充一点获取Bitmap大小的方法:

/** 
  * 得到bitmap的大小 
  */  
 public static int getBitmapSize(Bitmap bitmap) {  
     if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {    //API 19  
         return bitmap.getAllocationByteCount();  
     }  
     if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.HONEYCOMB_MR1) {//API 12  
         return bitmap.getByteCount();  
     }  
     // 在低版本中用一行的字节x高度  
     return bitmap.getRowBytes() * bitmap.getHeight();                //earlier version  
 } 

DiskLruCache

DiskLruCache用于实现存储设备缓存,即磁盘缓存,它通过将缓存对象写入文件系统从而实现缓存的效果。DiskLruCache得到了Android官方文档的推荐,但它不属于Android SDK的一部分,它的源码可以从如下网址得到:

https://github.com/JakeWharton/DiskLruCache
  1. DiskLruCache的创建 open方法有四个参数,其中第一个参数表示磁盘缓存在文件系统中的存储路径。缓存路径可以选择SD卡上的缓存目录,具体是指/sdcard/Android/data/package_name/cache目录,其中package_name表示当前应用的包名,当应用被卸载后,此目录会一并被删除。当然也可以选择SD卡上的其他指定目录,还可以选择data下的当前应用的目录,具体可根据需要灵活设定。这里给出一个建议:如果应用卸载后就希望删除缓存文件,那么就选择SD卡上的缓存目录,如果希望保留缓存数据那就应该选择SD卡上的其他特定目录。第二个参数表示应用的版本号,一般设为1即可。当版本号发生改变时DiskLruCache会清空之前所有的缓存文件,而这个特性在实际开发中作用并不大,很多情况下即使应用的版本号发生了改变缓存文件却仍然是有效的,因此这个参数设为1比较好。第三个参数表示单个节点所对应的数据的个数,一般设为1即可。第四个参数表示缓存的总大小,比如50MB,当缓存大小超出这个设定值后,DiskLruCache会清除一些缓存从而保证总大小不大于这个设定值。下面是一个典型的DiskLruCache的创建过程:
        File diskCacheDir = getDiskCacheDir(this,"bitmap");
        if (!diskCacheDir.exists()){
            diskCacheDir.mkdirs();
        }
        try {
            DiskLruCache diskLruCache = DiskLruCache.open(diskCacheDir, 1, 1, DISK_CACHE_SIZE);
        } catch (IOException e) {
            e.printStackTrace();
        }
  1. DiskLruCache的缓存添加 DiskLruCache的缓存添加的操作是通过Editor完成的,Editor表示一个缓存对象的编辑对象。这里仍然以图片缓存举例,首先需要获取图片url所对应的key,然后根据key就可以通过edit()来获取Editor对象,如果这个缓存正在被编辑,那么edit()会返回null,即DiskLruCache不允许同时编辑一个缓存对象。之所以要把url转换成key,是因为图片的url中很可能有特殊字符,这将影响url在Android中直接使用,一般采用url的md5值作为key,如下所示。
    private String hasKeyFromUrl(String url) {
        String cacheKey;
        try {
            MessageDigest digest = MessageDigest.getInstance("MD5");
            digest.update(url.getBytes());
            cacheKey = bytesToHexString(digest.digest());
        } catch (NoSuchAlgorithmException e) {
            cacheKey = String.valueOf(url.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();
    }

将图片的url转成key以后,就可以获取Editor对象了。对于这个key来说,如果当前不存在其他Editor对象,那么edit()就会返回一个新的Editor对象,通过它就可以得到一个文件输出流。有了文件输出流,接下来要怎么做呢?其实是这样的,当从网络下载图片时,图片就可以通过这个文件输出流写入到文件系统上。还必须通过Editor的commit()来提交写入操作,如果图片下载过程发生了异常,那么还可以通过Editor的abort()来回退整个操作,这个过程如下所示。

        String key = hasKeyFromUrl(url);
        try {
            editor = diskLruCache.edit(key);
            if (editor != null) {
                outputStream = editor.newOutputStream(0);
                if (downloadUrlToStrean(url, outputStream)) {
                    editor.commit();
                } else {
                    editor.abort();
                }
                diskLruCache.flush();
            }
        } catch (IOException e) {
            e.printStackTrace();
        }

  1. DiskLruCache的缓存查找 和缓存的添加过程类似,缓存查找过程也需要将url转换为key,然后通过DiskLruCache的get方法得到一个Snapshot对象,接着再通过Snapshot对象即可得到缓存的文件输入流,有了文件输出流,自然就可以得到Bitmap对象了。
       String key = hasKeyFromUrl(url);
        DiskLruCache.Snapshot snapshot = diskLruCache.get(key);
        if (snapshot != null) {
            FileInputStream fileInputStream = (FileInputStream) snapshot.getInputStream(DISK_CACHE_INDEX);
            FileDescriptor fd = fileInputStream.getFD();
            bitmap = imageCompressor.decodeSampleBitmapFromFileDescriptor(fd, reqWidth, reqHeight);
            if (bitmap != null) {
                Log.i(TAG, "从磁盘加载图片,并写入内存缓存");
                addBitmapToMemCache(key, bitmap);
            }
        }
        

Android 文件目录存储介绍

说到缓存不得不说一下Android的存储目录,参考这篇文章 摘要如下:

Android 为我们提供了一系列API来获取我们需要的存储目录,如下:

  • Environment.getExternalStorageDirectory().getPath();

    /storage/emulated/0

  • getExternalCacheDir().getPath();

    /storage/emulated/0/Android/data/com.azhon.androiddir/cache

  • getCacheDir().getPath();

    /data/user/0/com.azhon.androiddir/cache

  • getFilesDir().getPath();

    /data/user/0/com.azhon.androiddir/files

  • Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MUSIC);

    /storage/emulated/0/Music type 还可以取不同的值…

我们都知道往手机上写入一个文件需要 [存储空间] 权限,在Android M之后 还需要动态申请权限。那么重点来了,当我们往App的缓存目录中写入一个文件 也就是/storage/emulated/0/Android/data/com.azhon.androiddir/cache目录它是不需要声明权限的 对的 你没看错是不需要权限的。往/data/data/packageName/目录下写入一个文件,也是不需要权限的。

ImageLoader的实现

在本章的前面先后介绍了Bitmap的高效加载方式、LruCache以及DiskLruCache,现在我们来着手实现一个优秀的ImageLoader。一般来说,一个优秀的ImageLoader应该具备如下功能:

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

图片的同步加载是指能够以同步的方式向调用者提供所加载的图片,这个图片可能是从内存缓存中读取的,也可能是从磁盘缓存中读取的,还可能是从网络拉取的。图片的异步加载是一个很有用的功能,很多时候调用者不想在单独的线程中以同步的方式来获取图片,这个时候ImageLoader内部需要自己在线程中加载图片并将图片设置给所需的ImageView。图片压缩的作用更毋庸置疑了,这是降低OOM概率的有效手段,ImageLoader必须合适地处理图片的压缩问题。内存缓存和磁盘缓存是ImageLoader的核心,也是ImageLoader的意义之所在,通过这两级缓存极大地提高了程序的效率并且有效地降低了对用户所造成的流量消耗,只有当这两级缓存都不可用时才需要从网络中拉取图片。

除此之外,ImageLoader还需要处理一些特殊的情况,比如在ListView或者GridView中,View复用既是它们的优点也是它们的缺点,优点想必读者都很清楚了,那缺点可能还不太清楚。考虑一种情况,在ListView或者GridView中,假设一个item A正在从网络加载图片,它对应的ImageView为A,这个时候用户快速向下滑动列表,很可能item B复用了ImageView A,然后等了一会之前的图片下载完毕了。如果直接给ImageView A设置图片,由于这个时候ImageView A被item B所复用,但是item B要显示的图片显然不是item A刚刚下载好的图片,这个时候就会出现item B中显示了item A的图片,这就是常见的列表的错位问题,ImageLoader需要正确地处理这些特殊情况。

具体的实现细节可用参考这个项目,包含了内存缓存,磁盘缓存,同步加载异步加载,图片压缩,线程池,Handler的使用等各方面内容。

其中运用了RecyclerView来展示图片,既有网格的实现,也有瀑布流的实现。这两者都需要处理item间的间隔,可以用系统自带的DividerItemDecoration,也可以自己实现。自定义时要继承RecyclerView.ItemDecoration这个类,处理间隔的时候重点是getItemOffsets方法。如网格布局且有两列时,要处理好左边,右边以及中间的间隔,目的是为了左边中间右边各处的间隔大小一致。具体的可以看下面的代码:

       @Override
        public void getItemOffsets(@NonNull Rect outRect, @NonNull View view, @NonNull RecyclerView parent, @NonNull RecyclerView.State state) {
            int space = DensityUtil.dip2px(parent.getContext(), 10);
            int position = parent.getChildAdapterPosition(view);
            //spanCount为2时,余数是0或者1
            int column = position % spanCount;

            //余数为0,表示所有位于左边那一列的itemView,它们的left是right的2倍,如left=10,right=5
            //余数为1,表示所有位于右边那一列的itemView,他们的right是left的2倍,如right=10,left=5
            //如此一来两列中间的间隙大小就和左边以及右边的一致了
//            outRect.left = space - column * space / spanCount;
//            outRect.right = (column + 1) * space / spanCount;
            //和上面的写法等同 更直白
//            if (column == 0) {
//                outRect.left = space;
//                outRect.right = space / 2;
//            } else {
//                outRect.left = space / 2;
//                outRect.right = space;
//            }

            //通用的,不管spanCount是多少(大于等于2)
            if (column == 0) {
                //最小的余数 最左边的一列
                outRect.left = space;
                outRect.right = space / 2;
            } else if (column == spanCount - 1) {
                //最大的余数 最右边的一列
                outRect.left = space / 2;
                outRect.right = space;
            } else {
                //中间的列
                outRect.left = space / 2;
                outRect.right = space / 2;
            }

            if (position < spanCount) {
                outRect.top = space;
            }
            outRect.bottom = space;
        }
    }

插一下基础的数学知识:

  //被除数 除数 商   余数
    27     / 6 = 4 ...3
    余数的取值范围:0-除数之间,不包括除数
    
    7  / 17 = 0 ...7
    当除以另一个数,比另一个数小时,余数就这自己,如7除以17,余数是7