Android Volley缓存失效与清理的底层逻辑与源码实现(20)

9 阅读12分钟

深度剖析!Android Volley缓存失效与清理的底层逻辑与源码实现

一、引言

在Android应用开发中,网络请求是获取数据的重要途径,而Volley作为经典的网络请求框架,其缓存机制极大提升了数据加载效率。但缓存并非“一劳永逸”,随着数据更新或缓存空间不足,缓存失效与清理机制显得尤为关键。合理的缓存失效与清理策略,能避免应用使用过期数据,同时释放磁盘空间,保障应用性能。本文将从源码层面深入剖析Volley缓存失效与清理的工作原理,助力开发者更好地理解与优化应用缓存管理。

二、Volley缓存机制基础概览

2.1 缓存核心接口与类

Volley通过Cache接口定义了缓存操作规范,其默认实现DiskBasedCache基于文件系统完成缓存数据的存储与读取。

// Cache接口定义了缓存操作的基本方法
public interface Cache {
    // 从缓存中获取指定键的数据条目
    public Entry get(String key);
    // 将数据存入缓存
    public void put(String key, Entry entry);
    // 使缓存条目失效
    public void invalidate(String key, boolean fullExpire);
    // 从缓存中移除指定键的数据
    public void remove(String key);
    // 清空所有缓存数据
    public void clear();

    // 缓存条目类,包含数据及元信息
    public static class Entry {
        // 缓存数据字节数组
        public byte[] data;
        // ETag,用于服务器验证缓存有效性
        public String etag;
        // 服务器响应时间(毫秒)
        public long serverDate;
        // 软过期时间(毫秒)
        public long softTtl;
        // 硬过期时间(毫秒)
        public long ttl;
        // 响应头信息
        public Map<String, String> responseHeaders;

        // 判断缓存是否硬过期
        public boolean isExpired() {
            return this.ttl < System.currentTimeMillis();
        }

        // 判断缓存是否需要刷新(软过期)
        public boolean refreshNeeded() {
            return this.softTtl < System.currentTimeMillis();
        }
    }
}

DiskBasedCache类实现了Cache接口,是Volley缓存的核心实现类:

public class DiskBasedCache implements Cache {
    // 缓存根目录,用于存储缓存文件
    private final File mRootDirectory;
    // 缓存最大容量(字节)
    private final int mMaxCacheSizeInBytes;
    // 当前已使用的缓存大小(字节)
    private long mTotalSize = 0;
    // 缓存条目映射表,通过键快速查找缓存条目
    private final Map<String, CacheHeader> mEntries = new LinkedHashMap<>(16, 0.75f, true);

    // 构造函数,初始化缓存目录和最大容量
    public DiskBasedCache(File rootDirectory, int maxCacheSizeInBytes) {
        mRootDirectory = rootDirectory;
        mMaxCacheSizeInBytes = maxCacheSizeInBytes;
    }

    // 从缓存中获取数据条目
    @Override
    public Entry get(String key) {
        // 根据键生成缓存文件
        File file = getFileForKey(key);
        // 文件不存在则缓存未命中
        if (!file.exists()) {
            return null;
        }
        BufferedInputStream fis = null;
        try {
            // 打开文件输入流
            fis = new BufferedInputStream(new FileInputStream(file));
            // 读取缓存头部信息
            CacheHeader header = CacheHeader.readHeader(fis);
            // 头部信息读取失败,删除文件并返回null
            if (header == null) {
                VolleyLog.d("Cache header corruption for %s", file.getAbsolutePath());
                file.delete();
                return null;
            }
            // 计算数据部分长度并读取
            int length = (int) (file.length() - fis.available());
            byte[] data = streamToBytes(fis, length);
            // 创建缓存条目对象
            Entry entry = new Entry();
            entry.data = data;
            entry.etag = header.etag;
            entry.softTtl = header.softTtl;
            entry.ttl = header.ttl;
            entry.serverDate = header.serverDate;
            entry.responseHeaders = header.responseHeaders;
            return entry;
        } catch (IOException e) {
            // 读取异常,删除文件并返回null
            VolleyLog.d("%s: %s", file.getAbsolutePath(), e.toString());
            remove(key);
            return null;
        } finally {
            // 关闭输入流
            if (fis != null) {
                try {
                    fis.close();
                } catch (IOException ignored) {
                }
            }
        }
    }

    // 将数据存入缓存
    @Override
    public void put(String key, Entry entry) {
        // 检查缓存空间,不足则清理
        pruneIfNeeded(entry.data.length);
        // 根据键生成缓存文件
        File file = getFileForKey(key);
        FileOutputStream fos = null;
        BufferedOutputStream bos = null;
        try {
            // 创建临时文件
            File tmpFile = getTempFileForKey(key);
            // 打开临时文件输出流
            fos = new FileOutputStream(tmpFile);
            bos = new BufferedOutputStream(fos);
            // 写入缓存头部信息
            CacheHeader header = new CacheHeader(key, entry);
            header.writeHeader(bos);
            // 写入数据
            bos.write(entry.data);
            // 刷新输出流
            bos.flush();
            // 将临时文件重命名为正式缓存文件
            if (!tmpFile.renameTo(file)) {
                VolleyLog.e("ERROR: rename failed, tmpFile=%s, file=%s", tmpFile.getAbsolutePath(), file.getAbsolutePath());
                throw new IOException("Rename failed!");
            }
            // 更新缓存条目映射表和总大小
            putEntry(key, header);
        } catch (IOException e) {
            // 写入异常,删除文件
            if (file.exists()) {
                if (!file.delete()) {
                    VolleyLog.e("Could not clean up file %s", file.getAbsolutePath());
                }
            }
            VolleyLog.e("Failed to write cache entry for key=%s, filename=%s: %s", key, file.getAbsolutePath(), e.getMessage());
        } finally {
            // 关闭输出流
            if (bos != null) {
                try {
                    bos.close();
                } catch (IOException ignored) {
                }
            } else if (fos != null) {
                try {
                    fos.close();
                } catch (IOException ignored) {
                }
            }
        }
    }

    // 辅助方法:将输入流转换为字节数组
    private static byte[] streamToBytes(InputStream in, int length) throws IOException {
        byte[] bytes = new byte[length];
        int count;
        int pos = 0;
        // 循环读取数据
        while (pos < length && ((count = in.read(bytes, pos, length - pos)) != -1)) {
            pos += count;
        }
        // 读取长度不符则抛出异常
        if (pos != length) {
            throw new IOException("Expected " + length + " bytes, read " + pos + " bytes");
        }
        return bytes;
    }

    // 其他辅助方法...
}

2.2 缓存与网络请求的协同关系

Volley的缓存与网络请求协同工作,缓存调度器CacheDispatcher和网络调度器NetworkDispatcher在其中扮演重要角色。缓存调度器优先检查缓存,网络调度器负责获取最新数据并更新缓存,这为缓存失效与清理机制的运行提供了基础框架 。

三、缓存失效机制详解

3.1 自动失效:基于时间的过期策略

Volley通过Entry类中的softTtl(软过期时间)和ttl(硬过期时间)实现自动失效策略。

public static class Entry {
    // 省略其他字段...
    // 判断缓存是否硬过期
    public boolean isExpired() {
        return this.ttl < System.currentTimeMillis();
    }

    // 判断缓存是否需要刷新(软过期)
    public boolean refreshNeeded() {
        return this.softTtl < System.currentTimeMillis();
    }
}

在缓存调度器CacheDispatcher中,会检查缓存数据的过期状态:

public class CacheDispatcher extends Thread {
    // 省略其他代码...
    @Override
    public void run() {
        while (true) {
            try {
                Request<?> request = mCacheQueue.take();
                // 省略其他检查...
                Cache.Entry entry = mCache.get(request.getCacheKey());
                // 缓存未命中,转发到网络队列
                if (entry == null) {
                    request.addMarker("cache-miss");
                    mNetworkQueue.put(request);
                    continue;
                }
                // 缓存硬过期
                if (entry.isExpired()) {
                    request.addMarker("cache-hit-expired");
                    request.setCacheEntry(entry);
                    mNetworkQueue.put(request);
                    // 解析缓存数据生成响应
                    Response<?> response = request.parseNetworkResponse(new NetworkResponse(entry.data, entry.responseHeaders));
                    request.addMarker("cache-hit-parsed");
                    // 缓存软过期,先返回旧数据并发起网络请求更新
                    if (entry.refreshNeeded()) {
                        request.addMarker("cache-hit-refresh-needed");
                        response.intermediate = true;
                        mDelivery.postResponse(request, response, new Runnable() {
                            @Override
                            public void run() {
                                try {
                                    mNetworkQueue.put(request);
                                } catch (InterruptedException e) {
                                    Thread.currentThread().interrupt();
                                }
                            }
                        });
                    } else {
                        // 仅硬过期,直接返回旧数据
                        mDelivery.postResponse(request, response);
                    }
                    continue;
                }
                // 缓存未过期,直接返回数据
                request.addMarker("cache-hit");
                Response<?> response = request.parseNetworkResponse(new NetworkResponse(entry.data, entry.responseHeaders));
                mDelivery.postResponse(request, response);
            } catch (InterruptedException e) {
                // 线程中断处理
                if (mQuit) {
                    Thread.currentThread().interrupt();
                    return;
                }
                continue;
            }
        }
    }
}

当缓存数据硬过期时,会将请求转发到网络队列获取新数据;若只是软过期,则先返回旧数据,同时发起网络请求更新缓存,保证用户能快速看到数据,又能获取最新内容。

3.2 手动失效:invalidate方法的实现

开发者可通过调用Cache接口的invalidate方法手动使缓存条目失效,DiskBasedCache中的实现如下:

@Override
public void invalidate(String key, boolean fullExpire) {
    // 获取缓存头部信息
    CacheHeader entry = mEntries.get(key);
    if (entry != null) {
        // 完全过期,设置软、硬过期时间为0
        if (fullExpire) {
            entry.softTtl = 0;
            entry.ttl = 0;
        } else {
            // 仅软过期,设置软过期时间为0
            entry.softTtl = 0;
        }
        // 更新缓存文件头部信息
        File file = getFileForKey(key);
        if (file.exists()) {
            File tmpFile = getTempFileForKey(key);
            try {
                FileOutputStream fos = new FileOutputStream(tmpFile);
                BufferedOutputStream bos = new BufferedOutputStream(fos);
                entry.writeHeader(bos);
                FileInputStream fis = new FileInputStream(file);
                BufferedInputStream bis = new BufferedInputStream(fis);
                // 跳过已写入的头部信息
                bis.skip(CacheHeader.HEADER_LENGTH);
                byte[] buffer = new byte[1024];
                int count;
                // 复制数据部分到临时文件
                while ((count = bis.read(buffer)) != -1) {
                    bos.write(buffer, 0, count);
                }
                bis.close();
                bos.close();
                // 将临时文件重命名为正式文件
                if (!tmpFile.renameTo(file)) {
                    throw new IOException("Rename failed!");
                }
            } catch (IOException e) {
                VolleyLog.e("Error writing header for %s", file.getAbsolutePath());
            }
        }
    }
}

invalidate方法通过修改缓存文件的头部信息,设置过期时间,当下次请求该数据时,Volley会判定缓存失效,从而发起网络请求获取新数据。

3.3 条件请求下的失效判断

在使用条件请求(如ETag、Last - Modified)时,服务器会返回304状态码表示缓存未失效。网络调度器NetworkDispatcher中对此进行了处理:

public class NetworkDispatcher extends Thread {
    // 省略其他代码...
    @Override
    public void run() {
        while (true) {
            try {
                Request<?> request = mQueue.take();
                // 省略其他检查...
                NetworkResponse networkResponse = mNetwork.performRequest(request);
                // 服务器返回304状态码,缓存未失效
                if (networkResponse.notModified) {
                    request.addMarker("network-304-not-modified");
                    Cache.Entry entry = mCache.get(request.getCacheKey());
                    Response<?> response = request.parseNetworkResponse(new NetworkResponse(entry.data, entry.responseHeaders));
                    mDelivery.postResponse(request, response);
                } else {
                    // 其他状态码,处理新数据
                    Response<?> response = request.parseNetworkResponse(networkResponse);
                    // 省略缓存更新等操作...
                    mDelivery.postResponse(request, response);
                }
            } catch (Exception e) {
                // 异常处理...
            }
        }
    }
}

只有当服务器返回非304状态码时,才认为缓存失效,需要更新缓存数据,这种机制有效减少了不必要的网络请求,同时保证了数据的有效性 。

四、缓存清理机制剖析

4.1 空间不足时的清理策略

当缓存占用空间超过设定的最大容量时,DiskBasedCache会触发清理策略,释放空间。

private void pruneIfNeeded(int neededSpace) {
    // 当前缓存大小加上新增数据大小超过最大容量
    if ((mTotalSize + neededSpace) > mMaxCacheSizeInBytes) {
        // 计算目标缓存大小(最大容量的90%)
        int targetSize = (int) (mMaxCacheSizeInBytes * 0.9f);
        // 按访问顺序遍历缓存条目映射表
        Iterator<Map.Entry<String, CacheHeader>> iterator = mEntries.entrySet().iterator();
        while (iterator.hasNext() && mTotalSize + neededSpace > mMaxCacheSizeInBytes) {
            Map.Entry<String, CacheHeader> entry = iterator.next();
            CacheHeader e = entry.getValue();
            // 删除对应的缓存文件
            boolean deleted = e.file.delete();
            if (deleted) {
                // 更新已使用的缓存大小
                mTotalSize -= e.size;
            }
            // 从映射表中移除该条目
            iterator.remove();
        }
        // 清理后仍空间不足,记录错误
        if (mTotalSize + neededSpace > mMaxCacheSizeInBytes) {
            VolleyLog.e("Failed to clear space in cache");
        }
    }
}

该策略采用“最近最少使用(LRU)”原则,优先删除最早访问的缓存条目,确保缓存空间始终维持在合理范围内 。

4.2 手动清理:remove与clear方法

Cache接口提供了removeclear方法用于手动清理缓存。

// 移除指定键的缓存条目
@Override
public void remove(String key) {
    // 根据键获取缓存文件
    File file = getFileForKey(key);
    boolean deleted = file.delete();
    if (deleted) {
        // 获取缓存头部信息
        CacheHeader entry = mEntries.get(key);
        if (entry != null) {
            // 更新已使用的缓存大小
            mTotalSize -= entry.size;
            // 从映射表中移除该条目
            mEntries.remove(key);
        }
    }
}

// 清空所有缓存数据
@Override
public void clear() {
    // 获取缓存目录下的所有文件
    File[] files = mRootDirectory.listFiles();
    if (files != null) {
        for (File file : files) {
            // 删除每个文件
            if (!file.delete()) {
                VolleyLog.e("Could not delete cache file %s", file.getAbsolutePath());
            }
        }
    }
    // 清空缓存条目映射表和总大小
    mEntries.clear();
    mTotalSize = 0;
}

remove方法用于删除单个缓存条目,clear方法则会删除所有缓存数据,开发者可根据实际需求灵活使用,如在用户退出登录或应用数据更新时清理缓存 。

4.3 缓存清理的触发时机

缓存清理不仅在空间不足时触发,在以下场景也会执行:

  1. 网络请求成功后:当网络请求获取到新数据且请求设置为可缓存时,会将新数据存入缓存。若此时缓存空间不足,会先触发清理策略 。
public class NetworkDispatcher extends Thread {
    // 省略其他代码...
    @Override
    public void run() {
        while (true) {
            try {
                Request<?> request = mQueue.take();
                // 省略请求预处理...
                NetworkResponse networkResponse = mNetwork.performRequest(request);
                // 省略304状态码处理...
                Response<?> response = request.parseNetworkResponse(networkResponse);
                // 如果请求需要缓存且响应有可缓存条目
                if (request.shouldCache() && response.cacheEntry != null) {
                    // 尝试将数据存入缓存,此时可能触发清理
                    mCache.put(request.getCacheKey(), response.cacheEntry);
                    request.addMarker("network-cache-written");
                }
                mDelivery.postResponse(request, response);
            } catch (Exception e) {
                // 异常处理...
            }
        }
    }
}

DiskBasedCacheput方法中,会调用pruneIfNeeded方法检查并清理缓存空间:

@Override
public void put(String key, Entry entry) {
    // 检查并清理缓存空间,确保有足够容量
    pruneIfNeeded(entry.data.length);
    // 省略文件写入操作...
}
  1. 手动调用清理方法:开发者主动调用Cache接口的removeclear方法时,会立即执行相应的清理操作。例如,在应用的设置页面添加“清除缓存”功能,点击时调用clear方法:
public class CacheClearActivity extends AppCompatActivity {
    private Cache mCache;
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_cache_clear);
        // 获取Volley的缓存实例
        RequestQueue requestQueue = Volley.newRequestQueue(this);
        mCache = ((DiskBasedCache) requestQueue.getCache());
        
        Button clearButton = findViewById(R.id.clear_cache_button);
        clearButton.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                // 调用clear方法清空缓存
                mCache.clear();
                Toast.makeText(CacheClearActivity.this, "缓存已清空", Toast.LENGTH_SHORT).show();
            }
        });
    }
}
  1. 应用退出或后台运行时:在某些场景下,为了释放资源,应用在退出或进入后台时可能会清理缓存。可以通过监听应用的生命周期,在合适的时机调用缓存清理方法。例如,在ActivityonStop方法中清理部分缓存:
public class MainActivity extends AppCompatActivity {
    private Cache mCache;
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        RequestQueue requestQueue = Volley.newRequestQueue(this);
        mCache = ((DiskBasedCache) requestQueue.getCache());
    }

    @Override
    protected void onStop() {
        super.onStop();
        // 假设清理以特定前缀开头的缓存条目
        String cachePrefix = "temp_";
        File cacheDir = mCache.getCacheDirectory();
        File[] files = cacheDir.listFiles();
        if (files != null) {
            for (File file : files) {
                if (file.getName().startsWith(cachePrefix)) {
                    mCache.remove(file.getName());
                }
            }
        }
    }
}

五、缓存失效与清理的关联与交互

5.1 失效与清理的协同作用

缓存失效和清理机制并非独立运作,而是相互配合。当缓存数据失效时,若新数据需要存入缓存且空间不足,清理机制会启动,为新数据腾出空间。例如,一个新闻应用的缓存数据过期后,应用请求新的新闻数据,此时如果缓存已满,DiskBasedCache会先清理部分旧的缓存条目,再将新新闻数据存入。

// 假设新闻请求类继承自StringRequest
public class NewsRequest extends StringRequest {
    public NewsRequest(String url, Response.Listener<String> listener, Response.ErrorListener errorListener) {
        super(Method.GET, url, listener, errorListener);
    }

    @Override
    public boolean shouldCache() {
        return true;
    }
}

// 在Activity中发起新闻请求
public class NewsActivity extends AppCompatActivity {
    private RequestQueue requestQueue;
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_news);
        requestQueue = Volley.newRequestQueue(this);
        
        NewsRequest request = new NewsRequest("https://api.example.com/news",
            new Response.Listener<String>() {
                @Override
                public void onResponse(String response) {
                    // 处理新闻数据
                }
            },
            new Response.ErrorListener() {
                @Override
                public void onErrorResponse(VolleyError error) {
                    // 处理错误
                }
            });
        requestQueue.add(request);
    }
}

在这个过程中,DiskBasedCacheput方法会触发清理,确保新的新闻数据能够成功缓存。

5.2 避免重复清理与失效冲突

在设计缓存机制时,需要避免重复清理和失效冲突的问题。例如,当多个请求同时触发缓存清理和失效操作时,可能会导致数据不一致或性能下降。Volley通过加锁机制来保证操作的原子性。在DiskBasedCache的多个关键方法中,使用synchronized关键字进行同步:

private void pruneIfNeeded(int neededSpace) {
    // 使用同步块保证清理操作的原子性
    synchronized (this) {
        if ((mTotalSize + neededSpace) > mMaxCacheSizeInBytes) {
            // 清理逻辑...
        }
    }
}

@Override
public void invalidate(String key, boolean fullExpire) {
    synchronized (this) {
        // 获取缓存头部信息并更新
        CacheHeader entry = mEntries.get(key);
        if (entry != null) {
            // 更新过期时间逻辑...
            // 更新文件头部信息逻辑...
        }
    }
}

这种同步机制确保了在同一时刻只有一个线程可以执行清理或失效操作,避免了数据冲突和不一致的问题。

5.3 动态调整清理与失效策略

根据应用的使用场景和数据特点,可以动态调整缓存的清理与失效策略。例如,对于实时性要求高的数据,可以缩短其软过期和硬过期时间,使其更快失效并触发更新;对于占用空间大但不常使用的数据,可以在缓存空间紧张时优先清理。

// 自定义请求类,设置特殊的过期时间
public class RealtimeDataRequest extends StringRequest {
    private long customSoftTtl;
    private long customTtl;
    public RealtimeDataRequest(String url, Response.Listener<String> listener, Response.ErrorListener errorListener,
                               long softTtl, long ttl) {
        super(Method.GET, url, listener, errorListener);
        customSoftTtl = softTtl;
        customTtl = ttl;
    }

    @Override
    protected Response<String> parseNetworkResponse(NetworkResponse response) {
        Response<String> parsedResponse = super.parseNetworkResponse(response);
        if (parsedResponse.cacheEntry != null) {
            // 设置自定义的过期时间
            parsedResponse.cacheEntry.softTtl = System.currentTimeMillis() + customSoftTtl;
            parsedResponse.cacheEntry.ttl = System.currentTimeMillis() + customTtl;
        }
        return parsedResponse;
    }

    @Override
    public boolean shouldCache() {
        return true;
    }
}

通过这种方式,开发者可以根据实际需求灵活调整缓存的失效和清理策略,优化应用的缓存管理和性能表现。