OkHttp 是 Square
组织出品的一套支持 http1.x/http 2/WebSocket
的网络框架。因为其易用性被广大的公司所采用。 OkHttp
跟以往的网络不一样还在于: OkHttp
抛弃掉以往基于 Android
平台或者 Java
平台上原生的 HTTP
协议支持的Api
,而本身实现了一套 HTTP
协议。而且这套协议不只支持 1.x
的 HTTP
协议,还支持 2.0
的 HTTP
协议android
(本文将先拆解
OkHttp
中的各类组件,而后整合到一块儿分析OkHttp
框架)算法
今天咱们要分析的类是 DiskLruCache
,Disk 表明这个 cache
针对的是外置存储器,多是磁盘或者 sdcard
。Lru 表明这个 cache
所使用的淘汰算法。其实,将 DiskLruCache
归类于 OkHttp
并不许确,这个类本来属于 android 4.1
的系统类,位于 libcore.io
包下。抽取出来之后就能够应用于任何版本的 Android
系统。实际上,咱们依旧能在 OkHttp 的 DiskLruCache
代码中找到 libcore.io
的影子:缓存
// OkHttp3
public final class DiskLruCache implements Closeable, Flushable {
...
static final String MAGIC = "libcore.io.DiskLruCache";//文件魔数依旧保留libcore包名
....
/*
* This cache uses a journal file named "journal". A typical journal file
* looks like this:
* libcore.io.DiskLruCache
....
*/
}
复制代码
如上面源码所述,DiskLruCache
会将一些元数据文件信息记录到本身的一个数据文件中,而文件魔数 MAGIC
依然保留着 libcore
的包名,甚至连注释,也直接 copy 的当时 libcore
时候的注释。固然,OkHttp
跟 libcore 里的 DiskLruCache
也有差异,主要体如今 IO
处理上。OkHttp
是基于 Okio
框架开发的,所以 OkHttp
在处理 IO 的时候使用了更为方便的 Okio
接口。安全
(若是你对 Okio 并不熟悉,能够参考个人 Okio 系列文章:Okio源码解析 )bash
DiskLruCache
的构造须要调用它的静态工厂方法 create
:网络
public static DiskLruCache create(FileSystem fileSystem, File directory, int appVersion,
int valueCount, long maxSize) {
if (maxSize <= 0) {
throw new IllegalArgumentException("maxSize <= 0");
}
if (valueCount <= 0) {
throw new IllegalArgumentException("valueCount <= 0");
}
// Use a single background thread to evict entries.
Executor executor = new ThreadPoolExecutor(0, 1, 60L, TimeUnit.SECONDS,
new LinkedBlockingQueue<Runnable>(), Util.threadFactory("OkHttp DiskLruCache", true));//构建单线程线程池
return new DiskLruCache(fileSystem, directory, appVersion, valueCount, maxSize, executor);
}
复制代码
静态工厂方法 create
所须要的形参,基本就是 DiskLruCache
构造器的形参。所须要的参数分别是:数据结构
参数名 | 参数类型 | 参数含义 |
fileSystem | FileSystem | 对文件操做相关接口的抽象 |
directory | File | 目录文件,表示缓存文件所在目录 |
appVersion | int | 应用版本号 |
valueCount | int | 表示最后所存储的数据记录包含多少数据 好比在 Okhttp 中 须要包含文件信息和文件内容两个数据,那么 valueCount 就为2 |
maxSize | long | 文件夹最大能够保存的文件大小 |
executor | ThreadPoolExecutor | 淘汰处理线程池,通常是单线程池 |
通常状况下,咱们不须要指定这么多的参数,OkHttp
给我提供了一个很好的门面 okhttp3.Cache
。Cache
类将持有一个 DiskLruCache
对象,最后的实际操做将交给 DiskLruCache
对象去执行,比较相似 ContextWarpper
和 ContextImpl
的关系。app
public Cache(File directory, long maxSize) {//构造器
this(directory, maxSize, FileSystem.SYSTEM);
}
Cache(File directory, long maxSize, FileSystem fileSystem) {
this.cache = DiskLruCache.create(fileSystem, directory, VERSION, ENTRY_COUNT, maxSize);//构建一个DiskLruCache 存到成员变量 cache中去
}
复制代码
经过 okhttp3.Cache
,调用者只须要经过传递一个目录和最大存储值便可构建一个 DiskLruCache
。OkHttp 之因此要在 DiskLruCache
这个类以外再包装一个 Cache
主要是由于 DiskLruCache
并不关注具体的业务种类。而 okhttp3.Cache
主要功能,是将 DiskLruCache
包装成为能够方便处理 OkHttp
的网络相关业务的类。框架
咱们先简单使用一下 DiskLruCache
:ide
private static void test(DiskLruCache cache ,String tag)throws Exception {
Editor editor = cache.edit(tag);//开启名字为 tag 的事务
File file = new File("/Users/david/temp2.txt");//temp2.txt 占用1303个字节
Buffer buffer = new Buffer();
Source source = Okio.source(file);
source.read(buffer, file.length());
source.close();
editor.newSink(0).write(buffer,
buffer.size());
editor.commit();
}
// test code
DiskLruCache cache = DiskLruCache.create(FileSystem.SYSTEM,
new File("/Users/david/cache"), 1, 1, 3000);
cache.initialize();
test(cache,"hello1");
test(cache,"hello2");
test(cache,"hello3");
test(cache,"hello4");
复制代码
代码执行以后,将会在咱们的 cache
目录下生成下列文件:
journal
文件相似一个日志文件,用来保存你对该文件夹的操做记录hello*.0
文件须要分红两部分 "." 好前部分 "hello*" 就是咱们传入的 key
。后面的阿拉伯数字表明咱们所对应的数据段索引。journal 是一个日志文件,它其实是一个文本文件,它的文件结构以下图:
MAGIC |
DiskLruCache 版本号 |
APP_VERSION |
VALUE_COUNT |
BLANK |
... RECORDS ... |
上面的例子中咱们将获得文件内容:
libcore.io.DiskLruCache //MAGIC
1 //DiskLruCache 版本号
1 //APP_VERSION
1 //VALUE_COUNT
//BLANK
DIRTY hello1 //RECORDS
CLEAN hello1 1303
DIRTY hello2
CLEAN hello2 1303
DIRTY hello3
CLEAN hello3 1303
DIRTY hello4
REMOVE hello1
CLEAN hello4 1303
复制代码
当外部须要访问 DiskLruCache
中的数据时候, DiskLruCache
将调用 initialize()
函数,这个函数将读取 journal 文件进行初始化操做。好比你在使用 DiskLruCache.get
获取缓存的时候:
public synchronized Snapshot get(String key) throws IOException {
initialize();
...具体get操做
}
复制代码
initialize()
函数将会在全部数据访问操做以前执行,相似 AOP 。DiskLruCache
在记录的管理上,保持了比较高的安全策略。为了保证数据的准确性,须要维护多个 journal
文件,避免管理出错
public synchronized void initialize() throws IOException {
。。。
if (initialized) {
return; // Already initialized.
}
// If a bkp file exists, use it instead.
if (fileSystem.exists(journalFileBackup)) {
...
}
}//若是原始文件消失,能够采用备份文件
// Prefer to pick up where we left off.
if (fileSystem.exists(journalFile)) {
try {
readJournal();//读取journal文件
processJournal();//处理从journal文件读取出来的数据,删除没必要要的数据
initialized = true;
return;
} catch (IOException journalIsCorrupt) {
...
delete();//若是文件解析出现异常,删除掉journal文件
closed = false;
}
}
rebuildJournal();//从新构建一个journal文件
initialized = true;
}
复制代码
Journal 文件的读取依赖于文件的数据结构,日志记录数据将经过调用 readJournalLine
方法实现:
private void readJournal() throws IOException {
BufferedSource source = Okio.buffer(fileSystem.source(journalFile));
try {
String magic = source.readUtf8LineStrict();//MAGIC
String version = source.readUtf8LineStrict();//VERSION
String appVersionString = source.readUtf8LineStrict();//APPVERSION
String valueCountString = source.readUtf8LineStrict();//VALUE_COUNT
String blank = source.readUtf8LineStrict();//BLANK
...
int lineCount = 0;
while (true) {
try {
readJournalLine(source.readUtf8LineStrict());//解析记录数据
lineCount++;
} catch (EOFException endOfJournal) {
break;
}
}
...
}
复制代码
每个记录都将分红几部分:指令 + key + [文件大小+],每个数据元都使用空格分隔。而若是指令能够携带文件大小参数的话,那么这个文件大小参数能够是多个,个数根据参数 valueCount
指定。好比上面的例子中
指令 | 空格占位 | Key | 空格占位 | 文件长度 |
CLEAN | hello1 | 1303 |
Journal 有四个指令:
private static final String CLEAN = "CLEAN"; //须要携带文件大小
private static final String DIRTY = "DIRTY";
private static final String REMOVE = "REMOVE";
private static final String READ = "READ";
复制代码
其中,只有 "CLEAN" 指令须要携带文件大小。这四个指令的含义分别是:
指令 | 指令含义 |
CLEAN | 该记录的修改或者添加已经正确持久化 |
DIRTY | 该记录被修改,但还未持久化 |
REMOVE | 这条记录已经被删除 |
READ | 表示这条文件记录被访问过 |
private void readJournalLine(String line) throws IOException {
int firstSpace = line.indexOf(' ');
...
int keyBegin = firstSpace + 1;
int secondSpace = line.indexOf(' ', keyBegin);//判断 CLEAN 指令
final String key;
if (secondSpace == -1) {
key = line.substring(keyBegin);
//code step1
if (firstSpace == REMOVE.length() && line.startsWith(REMOVE)) {
lruEntries.remove(key);
return;
}
} else {
key = line.substring(keyBegin, secondSpace);
}
Entry entry = lruEntries.get(key);
if (entry == null) {
entry = new Entry(key);
lruEntries.put(key, entry);//构建 lruEntries
}
if (secondSpace != -1 && firstSpace == CLEAN.length() && line.startsWith(CLEAN)) {
//code step2
String[] parts = line.substring(secondSpace + 1).split(" ");
entry.readable = true;
entry.currentEditor = null;
entry.setLengths(parts);
} else if (secondSpace == -1 && firstSpace == DIRTY.length() && line.startsWith(DIRTY)) {
entry.currentEditor = new Editor(entry);
} else if (secondSpace == -1 && firstSpace == READ.length() && line.startsWith(READ)) {
//nothing
} else {
throw new IOException("unexpected journal line: " + line);
}
}
复制代码
反复调用 readJournalLine
函数的目的,是为了构建 lruEntries
对象,而在将字符串指令转换成为具体对象的时候,根据指令的特性,DiskLruCache
运用了一些简单的算法:
step2
位置),获得文件大小参数字符串 line.substring(secondSpace + 1)
经过调用 entry.setLengths
方法设置到 entry
文件内存记录中去。initialize
函数经过调用 readJournal
完成配置以后,将会调用 processJournal
。这个函数一方面是用于计算 key
对应的数据的总大小,一方面是对一些脏数据处理,最后状态 DIRTY
的数据是不安全的。多是你在准备写入的时候,程序中断,致使这个事务并没被执行。为了保证数据的完整性和安全性,DiskLruCache
会将这个 key
对应的相关数据删除。
private void processJournal() throws IOException {
fileSystem.delete(journalFileTmp);
for (Iterator<Entry> i = lruEntries.values().iterator(); i.hasNext(); ) {
Entry entry = i.next();
if (entry.currentEditor == null) {
//计算文件大小
for (int t = 0; t < valueCount; t++) {
size += entry.lengths[t];
}
} else {
...
//脏数据进行处理
for (int t = 0; t < valueCount; t++) {
fileSystem.delete(entry.cleanFiles[t]);
fileSystem.delete(entry.dirtyFiles[t]);
}
i.remove();
}
}
}
复制代码
经过咱们上面的 demo 和后面的代码分析。咱们知道,对 DiskLruCache
对象的处理是基于事务对象 Editor
。这种作法像极了咱们的 SharePerference
对象。DiskLruCache
的 Editor
事务是经过调用 DiskLruCache.edit
函数得到:
synchronized Editor edit(String key, long expectedSequenceNumber) throws IOException {
initialize();//初始化
...
validateKey(key);//检测key名字是否合法
Entry entry = lruEntries.get(key);
...
if (entry != null && entry.currentEditor != null) {//保证每次只有一个东西在操做文件
return null; // Another edit is in progress.
}
if (mostRecentTrimFailed || mostRecentRebuildFailed) {
executor.execute(cleanupRunnable);//执行淘汰机制
return null;
}
journalWriter.writeUtf8(DIRTY).writeByte(' ').writeUtf8(key).writeByte('\n');//保存DIRTY 记录
journalWriter.flush();
...
if (entry == null) {
entry = new Entry(key);
lruEntries.put(key, entry);
}
Editor editor = new Editor(entry);
entry.currentEditor = editor;//将该事务分配在这条记录之上
return editor;
}
复制代码
lruEntries
是 LinkedHashMap
类型,Linked
前缀的 Map
类型表示,当采用迭代器方式获取 Map
中的数据时候,将以 LinkedList
的有序序列返回,利用这个中有序性,就能够实现 LRU
的简单算法。当你对 Edit
事务都处理完了之后,就须要调用 Edit.commit()
函数提交最后的修改,实际上就是在 Journal
文件的最后将你操做记录的文件状态设置为 CLEAN
public void commit() throws IOException {
synchronized (DiskLruCache.this) {
if (done) {
throw new IllegalStateException();
}
if (entry.currentEditor == this) {
completeEdit(this, true);//第二个参数表明这个事务是否执行正常
}
done = true;
}
}
synchronized void completeEdit(Editor editor, boolean success) throws IOException {
...
if (success && !entry.readable) {
for (int i = 0; i < valueCount; i++) {
if (!editor.written[i]) {//step1 保证每个数据都被写入
editor.abort();
throw new IllegalStateException("Newly created entry didn't create value for index " + i);
}
if (!fileSystem.exists(entry.dirtyFiles[i])) {
editor.abort();//dirty文件不存在,那么须要将该事务弃置
return;
}
}
}
for (int i = 0; i < valueCount; i++) {
File dirty = entry.dirtyFiles[i];
if (success) {
if (fileSystem.exists(dirty)) {
File clean = entry.cleanFiles[i];
fileSystem.rename(dirty, clean);//dirtyFile->cleanFile
...
}
}
}
redundantOpCount++;
entry.currentEditor = null;
if (entry.readable | success) {
entry.readable = true;
journalWriter.writeUtf8(CLEAN).writeByte(' ');
journalWriter.writeUtf8(entry.key);
entry.writeLengths(journalWriter);
journalWriter.writeByte('\n');
if (success) {
entry.sequenceNumber = nextSequenceNumber++;
}
} else {
lruEntries.remove(entry.key);
journalWriter.writeUtf8(REMOVE).writeByte(' ');
journalWriter.writeUtf8(entry.key);
journalWriter.writeByte('\n');
}
journalWriter.flush();
if (size > maxSize || journalRebuildRequired()) {
executor.execute(cleanupRunnable);
}
}
复制代码
这里,size
变量表明的是目前的目录下全部文件的大小。在 Edit.commit
里调用了内部函数 completeEdit(Editor editor, boolean success)
。代码 step1
用于检查是否全部的数据都被覆盖。若是你要修改一个数据,须要将这条记录的全部文件都要修改。好比咱们的 OkHttp
框架,每一次 OkHttp
修改记录的时候都会对全部的索引文件进行修改:
//code com.squareup.okhttp.Cache
public final class Cache {
private static final int ENTRY_METADATA = 0;
private static final int ENTRY_BODY = 1;
private static final int ENTRY_COUNT = 2;
}
复制代码
每个 OkHttp
记录包含两个数据段,对应的索引分别是:
当经过 Cache.put(Response response)
往缓冲池里存入数据的时候,会调用到咱们上面所阐述的 DiskLruCache
存放记录的流程:
private CacheRequest put(Response response) throws IOException {
...
Entry entry = new Entry(response);
DiskLruCache.Editor editor = null;
try {
editor = cache.edit(urlToKey(response.request()));
if (editor == null) {
return null;
}
entry.writeTo(editor);
//这个函数用于写入索引为ENTRY_METADATA的数据
return new CacheRequestImpl(editor);
//CacheRequestImpl 这个对象包装了写入ENTRY_BODY的操做和commit 操做
} catch (IOException e) {
abortQuietly(editor);
return null;
}
}
复制代码
当调用Cache.put(Response response)
函数的时候,OkHttp
会生成一个 Entry
对象,这个Entry
用于表示 OkHttp
的每个请求的记录,而 DiskLruCache
中的 Entry
是用来表示key
数据的记录。OkHttp
会经过调用 entry.writeTo(editor)
的方式将 ENTRY_METADATA
数据写入 editor
事务中去,而且构建了一个 CacheRequest
对象给上层,用于后面 ENTRY_BODY
数据的输入。
//code Cache.Entry
public void writeTo(DiskLruCache.Editor editor) throws IOException {
BufferedSink sink = Okio.buffer(editor.newSink(ENTRY_METADATA));
....
sink.close();
//注意,此处并无对 editor 作commit,是由于 ENTRY_BODY 还没写
}
复制代码
而CacheRequest
对象其实是作了一层简单的装饰,保证当你数据书写完毕之后 editor
对象被 commit
:
//code CacheRequestImpl
public CacheRequestImpl(final DiskLruCache.Editor editor) throws IOException {
this.editor = editor;
this.cacheOut = editor.newSink(ENTRY_BODY);
this.body = new ForwardingSink(cacheOut) {
@Override public void close() throws IOException {
synchronized (Cache.this) {
if (done) {
return;
}
done = true;
writeSuccessCount++;
}
super.close();
editor.commit();
//保证close函数调用之后事务 editor 被commit
}
};
}
复制代码
上面咱们说明了如何经过 DiskLruCache
对象添加一条记录,以及在 OkHttp
里是如何添加记录的,可是咱们还遗留了一个问题,就是在 DiskLruCache
中是如何实现 LRU
的?咱们彷佛并无在代码中找到对应的代码。上面咱们说到,文件记录的存储是放在一个 Map
对象 lruEntries
中去,而这个 lruEntries
是一个 LinkedHashMap
类型。在 DiskLruCache
生成 lruEntries
对象的时候,调用的是LinkedHashMap
三参构造器:
public final class DiskLruCache {
...
final LinkedHashMap<String, Entry> lruEntries = new LinkedHashMap<>(0, 0.75f, true);
...
}
复制代码
咱们说的Lru算法实现就依赖于 LinkedHashMap
构造器的最后一个参数。参数注释翻译过来就是是否支持访问后迭代排序。
/**
* The iteration ordering method for this linked hash map: <tt>true</tt>
* for access-order, <tt>false</tt> for insertion-order.
*
* @serial
*/
private final boolean accessOrder;
public LinkedHashMap(int initialCapacity,
float loadFactor,
boolean accessOrder) {
super(initialCapacity, loadFactor);
this.accessOrder = accessOrder;
}
复制代码
咱们来看下这个参数是如何影响最后的迭代序列的,咱们从访问函数 get
入手:
//code LinkedHashMap
public V get(Object key) {
LinkedHashMapEntry<K,V> e = (LinkedHashMapEntry<K,V>)getEntry(key);
if (e == null)
return null;
e.recordAccess(this);
return e.value;
}
//code LinkedHashMapEntry
void recordAccess(HashMap<K,V> m) {
LinkedHashMap<K,V> lm = (LinkedHashMap<K,V>)m;
if (lm.accessOrder) {
lm.modCount++;
remove();//将本身从迭代队列中删除
addBefore(lm.header);// 将本身放入迭代队列队头
}
}
复制代码
每一次访问操做都会触发 LinkedHashMapEntry.recordAccess
接口。 LinkedHashMapEntry
是迭代器线性列表中的一个节点,在调用 recordAccess
的时候会判断 lm.accessOrder
属性是否为 true
。而后将经过调用 remove
和 addBefore(lm.header)
。 lm.header
LinkedHashMap
的散列队列的队列头部。经过这两步操做,这个LinkedHashMapEntry
对象就将本身变成队列头部,从而实现了 LRU
算法。
DiskLruCache
认为数据被访问依赖于两个函数:
Editor edit(String)
: 用于开启编辑事务Snapshot get(String key)
: 用于获取记录快照当须要重建 journal
文件或者触发清理操做的时候,会往线程池中抛出一个 cleanupRunnable
消息:
private final Runnable cleanupRunnable = new Runnable() {
public void run() {
synchronized (DiskLruCache.this) {
...
trimToSize();
...
}
}
};
void trimToSize() throws IOException {
while (size > maxSize) {
Entry toEvict = lruEntries.values().iterator().next();
removeEntry(toEvict);
}
mostRecentTrimFailed = false;
}
复制代码
cleanupRunnable
命令中会调用 trimToSize
函数,用于删除和计算当前 cache
文件夹中所包含的文件大小总和。清理操做由 removeEntry(Entry)
完成:
boolean removeEntry(Entry entry) throws IOException {
...
for (int i = 0; i < valueCount; i++) {
fileSystem.delete(entry.cleanFiles[i]);
size -= entry.lengths[i];
entry.lengths[i] = 0;
}
...
return true;
}
复制代码
所谓清理,也就是删除掉掉这条记录下全部文件。
DiskLruCache
这个类,或者这种模式有很好的通用性,目前也非只有 OkHttp
一个框架在用。做者但愿读者们将这篇做为一篇工具文档,而不是做为一篇知识储备文档,结合这篇工具文档去结合代码去看或者去实际操做,这样能更加深入的理解DiskLruCache
这类工具。