Volley 之 缓存篇

与Volley缓存相关的文件主要为:

  • Cache:定义缓存中的方法的接口类
  • NoCache:实现了Cache接口,当并没有实现其方法,相当于一个空壳
  • DiskBasedCache:实现了缓存中的所有方法,为核心类

1. Cache的实现

public interface Cache {

    Entry get(String key);

    void put(String key, Entry entry);

    void initialize();

    //True to fully expire the entry, false to soft expire
    void invalidate(String key, boolean fullExpire);

    void remove(String key);

    void clear();

    //用于表示缓存的内容
    class Entry {
        //缓存数据
        public byte[] data;
        //保证缓存一致性
        public String eTag;
        //从服务端获取到数据的时间
        public long serverDate;
        //最后被修改的时间
        public long lastModified;
        //保存该数据的最长时间
        public long ttl;
        //刷新数据的时间
        public long softTtl;
        //保存响应头的内容
        public Map<String,String> responseHeaders = Collections.emptyMap();
        //判断数据是否过期
        boolean isExpired(){
            return this.ttl < System.currentTimeMillis();
        }
        //判断数据是否需要刷新
        boolean refreshNeeded(){
            return this.softTtl < System.currentTimeMillis();
        }
    }

}

这里要注意的是除了提供常见的缓存方法,还提供了一个用于表示缓存内容的内部类。

2. DiskBasedCache的实现

  • 相关的定义和构造方法:
//定义一个linkedHashMap作为内存缓存,缓存的是信息头
    private final Map<String, CacheHeader> mEntries =
            new LinkedHashMap<>(16, .75f, true);
    //当前缓存的最大值
    private long mTotalSize = 0;
    //缓存目录
    private final File mRootDirectory;
    //定义最大的缓存空间
    private final int mMaxCacheSizeInBytes;
    //默认的缓存空间
    private static final int DEFAULT_DISK_USAGE_BYTES = 5 * 1024 * 1024;
    //当超过最大缓存时,对缓存的删除的负载因子
    private static final float HYSTERESIS_FACTORY = 0.9f;
    //缓存的标志-魔数
    private static final int CACHE_MAGIC = 0x20150306;

    public DiskBasedCache(File rootDirectory, int maxCacheSizeInBytes){
        mRootDirectory = rootDirectory;
        mMaxCacheSizeInBytes = maxCacheSizeInBytes;
    }

    public DiskBasedCache(File rootDirectory) {
        this(rootDirectory, DEFAULT_DISK_USAGE_BYTES);
    }

值得注意的是其内部采用了LinkedHashMap,而我们知道LinkedHashMap内部实现是LRU算法。

  • 对信息头CacheHeader(个人说法)的定义
//定义缓存的信息头
public static class CacheHeader {
    //缓存数据的大小
    public long size;
    public String key;
    public String eTags;
    public long serverDate;
    public long lastModified;
    public long ttl;
    public long softTtl;
    public Map<String,String> responseHeaders;

    //定义一个空的CacheHeader,便于后面的操作
    private CacheHeader(){
    }

    public CacheHeader(String key, Entry entry){
        this.key = key;
        this.size = entry.data.length;
        this.eTags = entry.eTag;
        this.serverDate = entry.serverDate;
        this.lastModified = entry.lastModified;
        this.ttl = entry.ttl;
        this.softTtl = entry.softTtl;
        this.responseHeaders = entry.responseHeaders;
    }

    //将CacheHeader对象转换为Entry对象
    public Entry toCacheEntry(byte[] data) {
        Entry e = new Entry();
        e.data = data;
        e.eTag = eTags;
        e.serverDate = serverDate;
        e.lastModified = lastModified;
        e.ttl = ttl;
        e.softTtl = softTtl;
        e.responseHeaders = responseHeaders;
        return e;
    }
}

这里可能会产生一个疑问:
为什么还要重新创建一个CacheHeader对象呢?我们不是已经用Entry对象表示缓存内容吗?——实际上,我们对比下两者,发现Entry中是包含真正缓存数据data的,而CacheHeader中只是包含了数据的大小。

为什么要这样设计呢?
我们可以这么想,如果LinkedHashMap中的value对应的Entry对象,那么试想这对内存的需求要多大,因为这相当于将数据完全保存在内存中!!!
因此,为了解决这个问题,我们可以将数据的相关信息保存在内存中,然后真正的数据放在文件中,当需要获取的时候,找到对应的文件,再将文件中的数据加载到内存即可。

  • 定义一个可统计已读取长度的输入流
//定义一个可统计已读取长度的输入流
private static class CountingInputStream extends FilterInputStream {
    //记录已读取的长度
    private int bytesRead = 0;

    protected CountingInputStream(InputStream in) {
        super(in);
    }

    @Override
    public int read() throws IOException {
        int result = super.read();
        if (result != -1){
            bytesRead ++;
        }
        return result;
    }

    @Override
    public int read(byte[] b, int off, int len) throws IOException {
        int result = super.read(b, off, len);
        if (result != -1){
            //针对缓存池的读取
            bytesRead += result;
        }
        return result;
    }
}

为什么需要这么一个统计已读取的输入流设计呢?这是为后续的文件读取时,对信息头和数据的获取的便利。(后面会细说)

  • 实现读写各种类型数据的操作
//一个个字节读取输入流
private static int read(InputStream is) throws IOException {
    int b = is.read();
    if (b == -1){
        throw new EOFException();
    }
    return b;
}

//写入整型数据:write是按一个个byte地写
static void writeInt(OutputStream os, int n) throws IOException {
    os.write((n >> 0) & 0xff);
    os.write((n >> 8) & 0xff);
    os.write((n >> 16) & 0xff);
    os.write((n >> 24) & 0xff);
}

//读取整型数据
static int readInt(InputStream is) throws IOException {
    int n = 0;
    n |= (read(is) << 0);
    n |= (read(is) << 8);
    n |= (read(is) << 16);
    n |= (read(is) << 24);
    return n;
}

//写入Long数据
static void writeLong(OutputStream os, long n) throws IOException {
    os.write((byte)(n >>> 0));
    os.write((byte)(n >>> 8));
    os.write((byte)(n >>> 16));
    os.write((byte)(n >>> 24));
    os.write((byte)(n >>> 32));
    os.write((byte)(n >>> 40));
    os.write((byte)(n >>> 48));
    os.write((byte)(n >>> 56));
}

//读取Long数据
static long readLong(InputStream is) throws IOException {
    long n = 0;
    n |= ((read(is) & 0xFFL) << 0);
    n |= ((read(is) & 0xFFL) << 8);
    n |= ((read(is) & 0xFFL) << 16);
    n |= ((read(is) & 0xFFL) << 24);
    n |= ((read(is) & 0xFFL) << 32);
    n |= ((read(is) & 0xFFL) << 40);
    n |= ((read(is) & 0xFFL) << 48);
    n |= ((read(is) & 0xFFL) << 56);
    return n;
}

//写入String数据
static void writeString(OutputStream os, String s) throws IOException {
    //将String类型转换为byte类型
    byte[] b = s.getBytes("UTF-8");
    //分别写入长度和数据
    writeLong(os, b.length);
    os.write(b, 0, b.length);
}

//读取String数据
static String readString(InputStream is) throws IOException {
    int n = (int) readLong(is);
    //将输入流转换为byte数组
    byte[] b = streamToBytes(is, n);
    //将byte数组转换为String数据
    return new String(b, "UTF-8");
}

//写入信息头
static void writeStringMap(Map<String,String> map, OutputStream os) throws IOException {
    if (map != null){
        writeInt(os, map.size());
        for (Map.Entry<String, String> entry : map.entrySet()){
            writeString(os, entry.getKey());
            writeString(os, entry.getValue());
        }
    } else {
        writeInt(os, 0);
    }
}

//读取信息头
static Map<String, String> readStringMap(InputStream is) throws IOException {
    int size = readInt(is);
    Map<String, String> result = (size == 0) ? Collections.<String, String>emptyMap()
            : new HashMap<String, String>(size);
    for (int i = 0; i < size; i ++){
        String key = readString(is).intern();
        String value = readString(is).intern();
        result.put(key,value);
    }
    return result; 
}

//将流转换为byte:注意这里是对固定长度的转换
private static byte[] streamToBytes(InputStream is, int length) throws IOException {
    byte[] bytes = new byte[length];
    int count;
    //统计读取的字节数
    int pos = 0;
    //进行读取
    while (pos < length && ((count = is.read(bytes, pos, length - pos)) != -1)){
        pos += count;
    }
    //若读取的字节数和传入的长度不同,说明存在问题
    if (pos != length){
        throw new IOException("Expected " + length + " bytes, read" + pos + "bytes");
    }
    return bytes;
}

以上主要提供了对int、long和String类型的读写。要注意的是对int和long不同位数的处理,以及String和byte数组之间的转换。

  • 对信息头的读写

在对进行读写操作分析前,我们先了解下文件格式,如下:

/*信息头*/(非文件数据)
魔数(用于标记缓存)
key
eTags
serverDate
lastModified
ttl
softTtl
responseHeaders
/*信息头*/
真实数据

对应的读写如下:

//从文件输入流中读取信息头,并返回CacheHeader对象
public static CacheHeader readHeader(InputStream is) throws IOException {
    CacheHeader entry = new CacheHeader();
    int magic = readInt(is);
    if (magic != CACHE_MAGIC){
        throw new IOException();
    }
    entry.key = readString(is);
    entry.eTags = readString(is);
    if (entry.eTags.equals("")){
        entry.eTags = null;
    }
    entry.serverDate = readLong(is);
    entry.lastModified = readLong(is);
    entry.ttl = readLong(is);
    entry.softTtl = readLong(is);
    entry.responseHeaders = readStringMap(is);
    return entry;
}

//将信息头内容写入到输出流中
public boolean writeHeader(OutputStream os){
    try {
        writeInt(os, CACHE_MAGIC);
        writeString(os, key);
        writeString(os, eTags == null ? "" : eTags);
        writeLong(os, serverDate);
        writeLong(os, lastModified);
        writeLong(os, ttl);
        writeLong(os, softTtl);
        writeStringMap(responseHeaders, os);
        os.flush();
        return true;
    } catch (IOException e) {
        VolleyLog.d("%s",e.toString());
        return false;
    }
}
  • 初始化缓存操作
//初始化缓存:即将缓存目录中所有文件的信息头加入到内存缓存中
@Override
public synchronized void initialize() {
//判断缓存文件是否存在,若不存在,则进行创建缓存文件
if (!mRootDirectory.exists()){
    if (!mRootDirectory.mkdirs()){
        VolleyLog.e("Unable to create cache dir %s", mRootDirectory.getAbsolutePath());
    }
    return;
}
//获取到缓存目录中的所有文件
File[]  files = mRootDirectory.listFiles();
if (files == null){
    return;
}
//遍历所有文件
for (File file : files){
    BufferedInputStream bis = null;
    try {
        bis = new BufferedInputStream(new FileInputStream(file));
        CacheHeader entry = CacheHeader.readHeader(bis);
        entry.size = file.length();
        //将文件中的信息头添加到内存缓存中
        putEntry(entry.key, entry);

    } catch (IOException e){
        if (file != null){
            file.delete();
        }
    } finally {
        try {
            if (bis != null){
                bis.close();
            }
        } catch (IOException ingored){}
    }
}

}

实际上,所谓的初始化缓存就是:检查缓存目录是否存在,若存在则需要将缓存文件中信息头添加到内存缓存中。

  • get操作获取缓存

检查内存缓存中是否存在——>若存在,则获取到key对应的文件——>读取文件中的数据——>包装为Entry对象返回

public Entry get(String key) {
    //获取缓存头信息
    CacheHeader entry = mEntries.get(key);
    if (entry == null){
        return null;
    }
    //获取到文件
    File file = getFileForKey(key);
    CountingInputStream cis = null;
    try {
        //使用CountingInputStream来获取文件内容
        cis = new CountingInputStream(new BufferedInputStream(new FileInputStream(file)));
        //读取缓存内容中信息头
        CacheHeader.readHeader(cis);
        //读取缓存中的真正数据
        byte[] data = streamToBytes(cis, (int) (file.length() - cis.bytesRead));
        //包装为Entry对象返回
        return entry.toCacheEntry(data);
    } catch (IOException e) {
        VolleyLog.d("%s: %s", file.getAbsolutePath(), e.toString());
        remove(key);
        return null;
    } catch (NegativeArraySizeException e){
        VolleyLog.d("%s: %s", file.getAbsolutePath(), e.toString());
        remove(key);
        return null;
    } finally {
        if (cis != null){
            try {
                cis.close();
            } catch (IOException e) {
                return null;
            }
        }
    }
}

我们可以看到这里使用了我们自定义的CountingInputStream来获取文件内容,文件中存在信息头和数据,我们需要对它们分别读取,问题是读取完信息头后,如何确定数据流中已读取的位置?实际上,这就是CountingInputStream的作用。我们看到这一句byte[] data = streamToBytes(cis, (int) (file.length() - cis.bytesRead));,里面通过cis.bytesRead获取到已读取的内容,那么文件剩下的内容自然就是数据了。

  • put操作实现添加缓存

检查缓存空间是否足够——>若空间足够,则将信息头和数据写入到文件中——>最后将信息头添加到内存缓存中

public synchronized void put(String key, Entry entry) {
    //检查缓存中的剩余空间是否足够容纳新添加的缓存对象
    pruneIfNeed(entry.data.length);
    //获取到文件
    File file = getFileForKey(key);
    try {
        BufferedOutputStream fos = new BufferedOutputStream(new FileOutputStream(file));
        CacheHeader e = new CacheHeader(key, entry);
        //将信息头写入到文件中
        boolean success = e.writeHeader(fos);
        if (!success){
            fos.close();
            VolleyLog.d("Failed to write header for %s", file.getAbsolutePath());
            throw new IOException();
        }
        //将真正数据写入到文件中
        fos.write(entry.data);
        fos.close();
        //将信息头信息添加到内存缓存中
        putEntry(key, e);
        return;
    } catch (IOException e){

    }
    //添加失败,则对文件进行清除
    boolean deleted = file.delete();
    if (!deleted){
        VolleyLog.d("Couldn't clean up file %s", file.getAbsolutePath());
    }
}

我们先看到对缓存空间的检查,若空间不足,应该如何处理?

//检查缓存中的剩余空间是否足够容纳新添加的缓存对象
private void pruneIfNeed(int neededSpace) {
    //剩余空间满足新添加的缓存对象所需大小
    if ((mTotalSize + neededSpace) < mMaxCacheSizeInBytes){
        return;
    }

    if (VolleyLog.DEBUG){
        VolleyLog.v("Pruning old cache entries");
    }

    long before = mTotalSize;
    int prunedFiles = 0;
    //获取当前时间,为日志记录准备
    long startTime = SystemClock.elapsedRealtime();

    //获取到内存缓存的迭代器
    Iterator<Map.Entry<String, CacheHeader>> iterator = mEntries.entrySet().iterator();
    while (iterator.hasNext()){
        Map.Entry<String, CacheHeader> entry = iterator.next();
        CacheHeader e = entry.getValue();
        //删除当前文件
        boolean deleted = getFileForKey(e.key).delete();
        //若删除成功,则将减少当前空间的大小
        if (deleted){
            mTotalSize -= e.size;
        } else {
            VolleyLog.d("Could not delete cache entry for key=%s, filename=%s",
                    e.key, getFileNameForKey(e.key));
        }
        //当然,内存缓存中也要删除对应的信息
        iterator.remove();
        prunedFiles ++;

        //删除文件,直到满足达到负载因子的需求
        if ((mTotalSize + neededSpace) < mMaxCacheSizeInBytes * HYSTERESIS_FACTORY){
            break;
        }
    }

    //打印日志信息
    if (VolleyLog.DEBUG){
        VolleyLog.v("pruned %d files, %d bytes, %d ms",
                prunedFiles, (mTotalSize - before), SystemClock.elapsedRealtime() - startTime);
    }
}

我们可以看到,当空间不足时,会采用删除文件的方法来保证足够的空间来添加新的缓存。删除的方式很简单,就是遍历LinkedHashMap的节点,逐个删除(我们之前提到过其内部实现了LRU算法,因此,实际上删除的是最近最少使用的节点)。
保证的方法也稍微有些特殊,并不是说刚好满足可以添加新的缓存就停止删除,而是保证小于最大缓存的0.9。为啥?因为如果只是刚好满足的话,下一次添加新的缓存可能又再次触发删除文件操作,这样势必会影响一些效率。

putEntry操作也需要注意:判断是否内存缓存中已含有该key对应的缓存,若存在,则更新为新的缓存

//将信息头添加到内存缓存中
private void putEntry(String key, CacheHeader entry) {
    //若内存缓存中,不包含,则直接添加
    if (!mEntries.containsKey(key)){
        mTotalSize += entry.size;
    }
    //否则,将替换到之前的缓存,即更新缓存
    else {
        CacheHeader oldEntry = mEntries.get(key);
        mTotalSize += (entry.size - oldEntry.size);
    }
    //最后添加信息头到内存缓存中
    mEntries.put(key, entry);
}
  • remove操作删除缓存

对应删除文件缓存和内存缓存即可。

//删除缓存
@Override
public synchronized void remove(String key) {
    //删除对应的文件
    boolean deleted = getFileForKey(key).delete();
    //删除对应的信息头
    removeEntry(key);
    if (!deleted){
        VolleyLog.d("Could not delete cache entry for key=%s, filename=%s",
                key, getFileNameForKey(key));
    }
}

//删除内存缓存中的key对应的信息头
private void removeEntry(String key) {
    CacheHeader entry = mEntries.get(key);
    if (entry != null){
        mTotalSize -= entry.size;
        mEntries.remove(key);
    }
}
  • clear操作清除缓存

删除缓存目录下的所有缓存文件和清空内存缓存即可

public synchronized void clear() {
    //删除缓存目录下的所有文件
    File[] files = mRootDirectory.listFiles();
    if (files != null){
        for (File file : files){
            file.delete();
        }
    }
    //清空内存缓存
    mEntries.clear();
    mTotalSize = 0;
    VolleyLog.d("Cache cleared");
}
  • invalidate操作使得缓存失效

注意刷新缓存和使缓存过期两种情况

public synchronized void invalidate(String key, boolean fullExpire) {
    Entry entry = get(key);
    if (entry != null){
        //表示需要刷新缓存
        entry.softTtl = 0;
        //表示缓存过期
        if (fullExpire){
            entry.softTtl = 0;
        }
        //注意:这里还是需要将其添加到内存缓存中
        put(key, entry);
    }
}

DiskBasedCache完整代码

3.从中获取到的技能

  • CacheHeader和Entry的定义,一个只是保留数据相关信息保存在内存缓存中,另一个是保存数据,保留在文件中。这样的定义,很好地将内存缓存和磁盘缓存联系在一起。
  • 数据保存在文件的格式也是蛮值得学习的,读和写与文件格式密切相关。
  • 对基本类型数据的读写操作,比如int和long的位操作,String和byte数组的转换。

DiskBasedCache完整代码

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

推荐阅读更多精彩内容

  • 从三月份找实习到现在,面了一些公司,挂了不少,但最终还是拿到小米、百度、阿里、京东、新浪、CVTE、乐视家的研发岗...
    时芥蓝阅读 42,366评论 11 349
  • 1.OkHttp源码解析(一):OKHttp初阶 2 OkHttp源码解析(二):OkHttp连接的"前戏"——H...
    隔壁老李头阅读 7,089评论 2 28
  • 理论总结 它要解决什么样的问题? 数据的访问、存取、计算太慢、太不稳定、太消耗资源,同时,这样的操作存在重复性。因...
    jiangmo阅读 2,924评论 0 11
  • 在经过一次没有准备的面试后,发现自己虽然写了两年的android代码,基础知识却忘的差不多了。这是程序员的大忌,没...
    猿来如痴阅读 2,872评论 3 10
  • 醉步踏雪
    唐漫音阅读 143评论 0 0