Coil源码解析(四)之磁盘缓存

868 阅读12分钟

“我报名参加金石计划1期挑战——瓜分10万奖池,这是我的第1篇文章,点击查看活动详情

前言

前文再续,书接上一回。上回说到Coil的内存缓存(MemoryCache),今天就来把磁盘缓存(DIskCache)也讲完!

用法

回顾一下上文说过的关于Coil的缓存用法,这里也再贴出来一遍。

imageView.load(
    url,
    //Coil给Context提供了一个扩展属性获取全局单例的ImageLoader。
    context.imageLoader
    //newBuilder用于获取ImageLoader的构造器
    .newBuilder()
    //指定内存缓存策略
    .memoryCache {
        MemoryCache.Builder(context)
        //指定最大Size为当前可用内存的25%
        .maxSizePercent(0.25)
        //启用或停用对缓存资源的弱引用
        .weakReferencesEnabled(true)
        //启用或停用对缓存资源的强引用
        .strongReferencesEnabled(true)
        //指定最大Size为1MB
        .maxSizeBytes(1024 * 1024)
        //构建内存缓存策略
        .build()
    }
    //指定磁盘缓存策略
    .diskCache {
        DiskCache.Builder()
        //指定磁盘缓存的路径,没有默认值,必填。
        .directory(context.cacheDir.resolve("coil_cache"))
        //指定最大Size为当前可用磁盘空间的2%
        .maxSizePercent(0.02)
        //指定最大Size为10MB
        .maxSizeBytes(1024 * 1024 * 10)
        //指定清理逻辑执行的协程Dispatcher
        .cleanupDispatcher(Dispatchers.IO)
        //指定磁盘缓存的最大大小。如果设置了maxSizeBytes则忽略该设置。
        .maximumMaxSizeBytes(...)
        //指定磁盘缓存的最小大小。如果设置了maxSizeBytes则忽略该设置。
        .minimumMaxSizeBytes(...)
        //指定OKio的FileSystem
        .fileSystem(...)
        //构建磁盘缓存策略
        .build()
    }.build() //构建ImageLoader
)

DiskCache

根据Coil整体的设计风格,不难猜出这个DiskCache同样也是个接口类。话不多说,直接上类图!

classDiagram
class DiskCache{
	<<interface>>
	+Long size
	+Long maxSize
	+Path directory
	+FileSystem fileSystem
	+get(key:String)
	+edit(key:String)
	+remove(key:String)
	+clear()
}

class RealDiskCache

DiskCache <|.. RealDiskCache

其中比较重要的是以下方法:

  • get方法。根据Key获取Snapshot。
  • edit方法。根据Key获取对应数据的Editor。

Snapshot以及Editor都是DiskCache内定义的接口,之后会讲到。

RealDiskCache

磁盘缓存没有复杂的结构,只有唯一一个RealDiskCache实现了DiskCache接口。而RealDiskCache实际上也只是另一个真正工作的类的代理类。这个类就是整个磁盘缓存中的核心:DiskLruCache。

DiskLruCache

这个DiskLruCache其实也是现成的,虽然没有直接归入到Android的SDK里,但据说也是受到了谷歌的官方认可的推荐做法。

整体思路和普通的DiskLruCache别无二致,只不过将IO操作改造成OKIO这个库来实现。如果对DiskLruCache本身有了解的读者可以关闭这篇文章了,基本没有什么新知识。毕竟看技术文章主要还是为了获取新知识,我不会怪你的。

分析这个类,本文将从三个方面进行。

首先是数据的载体。

private val lruEntries = LinkedHashMap<String, Entry>(0, 0.75f, true)

从代码可以看到,内存里实际上的数据载体是LinkedHashMap,String是数据对应的Key,Entry自然就是指向数据的。Entry类为DiskLruCache的内部类,其作用是持有缓存文件的脏数据以及干净数据的路径(用的是OKIO的Path)。

但既然是磁盘缓存,那在磁盘层面上的数据载体是什么呢?能存在磁盘里的东西,那必然是文件啦!Coil是图片加载库,那必然是图片文件啦!自然是如此,但这个DiskLruCache的核心之处就在于除了实际上缓存下来的图片文件之外,还维护了一个日志文件(journal file)。日志文件记录下了每一条的操作记录,有一点类似数据库的事务管理。对缓存文件的操作有以下选项:

  • DIRTY。表示脏数据,该文件正在被写入(新增或修改)。
  • CLEAN。表示干净数据,文件已经写入完毕(缓存下来了)。
  • REMOVE。表示移除操作,该文件被移除(移除一条干净数据或者一条脏数据写入失败)。
  • READ。表示读取操作。

其中值得注意的是,由于DIRTY操作要么成功要么失败,所以它的后一条操作只会是CLEAN(成功)或者REMOVE(失败),如果DIRTY后面没有其他操作,则证明这条数据在写入过程中被强行打断了(比如崩溃了),也是一条无效的数据。

既然清楚了内存上的数据载体以及磁盘上的载体,那么接下来就应该看看如何将两者联系起来,也就是磁盘缓存的初始化。以下是DiskLruCache的初始化代码:

fun initialize() {
    if (initialized) return
​
    //省略...
​
    if (fileSystem.exists(journalFile)) {
        try {
            readJournal()
            processJournal()
            initialized = true
            return
        } catch (_: IOException) {
            
        }
​
        try {
            delete()
        } finally {
            closed = false
        }
    }
​
    writeJournal()
    initialized = true
}

首先判断一下是否已经初始化过了,由于这里的DiskLruCache是懒汉式的初始化,只有在需要进行缓存操作时才会去进行初始化,所以每个缓存操作前都会调用一次这个initialize方法。

接着如果日志文件存在则进行日志文件的读取(readJournal)和处理(processJournal)。

先说一下readJournal,关键就是循环读取日志文件的每一行(readJournalLine):

private fun readJournalLine(line: String) {
    val firstSpace = line.indexOf(' ')
    if (firstSpace == -1) throw IOException("unexpected journal line: $line")
​
    val keyBegin = firstSpace + 1
    val secondSpace = line.indexOf(' ', keyBegin)
    val key: String
    if (secondSpace == -1) {
        key = line.substring(keyBegin)
        if (firstSpace == REMOVE.length && line.startsWith(REMOVE)) {
            //REMOVE操作
            lruEntries.remove(key)
            return
        }
    } else {
        key = line.substring(keyBegin, secondSpace)
    }
​
    val entry = lruEntries.getOrPut(key) { Entry(key) }
    when {
        secondSpace != -1 && firstSpace == CLEAN.length && line.startsWith(CLEAN) -> {
            //CLEAN操作
            val parts = line.substring(secondSpace + 1).split(' ')
            entry.readable = true
            entry.currentEditor = null
            entry.setLengths(parts)
        }
        secondSpace == -1 && firstSpace == DIRTY.length && line.startsWith(DIRTY) -> {
            //DIRTY操作
            entry.currentEditor = Editor(entry)
        }
        secondSpace == -1 && firstSpace == READ.length && line.startsWith(READ) -> {
            //READ操作
        }
        else -> throw IOException("unexpected journal line: $line")
    }
}

从上面的代码的注释位置可以看到,对于读取到的不同操作都有着不同的处理:

  • REMOVE操作,将该Key对应的Entry从Map中移除。
  • CLEAN操作,构建并完善Entry。
  • DIRTY操作,为当前Entry创建Editor。
  • READ操作,不作处理。

接下来是processJournal:

private fun processJournal() {
    var size = 0L
    val iterator = lruEntries.values.iterator()
    while (iterator.hasNext()) {
        val entry = iterator.next()
        if (entry.currentEditor == null) {
            //干净数据
            for (i in 0 until valueCount) {
                size += entry.lengths[i]
            }
        } else {
            //脏数据
            entry.currentEditor = null
            for (i in 0 until valueCount) {
                fileSystem.delete(entry.cleanFiles[i])
                fileSystem.delete(entry.dirtyFiles[i])
            }
            iterator.remove()
        }
    }
    this.size = size
}

可以看到,processJournal就是遍历Map,并对干净数据和脏数据进行分别处理:

  • 干净数据,累计缓存总size。
  • 脏数据,删除脏数据的所有缓存文件并移除该Entry。

再次回头看initialize方法,进行完readJournal以及processJournal之后,初始化就已经完成了。

假如说在这个过程中,文件读取出现问题,证明日志文件可能已经损坏了,就会继续往下走到delect。并把错误抛出。

读取完日志文件后,再会执行一次writeJournal方法:

private fun writeJournal() {
    journalWriter?.close()
​
    //将新日志文件先写入到临时目录
    fileSystem.write(journalFileTmp) {
        //日志头部信息,不需要关注
        writeUtf8(MAGIC).writeByte('\n'.code)
        writeUtf8(VERSION).writeByte('\n'.code)
        writeDecimalLong(appVersion.toLong()).writeByte('\n'.code)
        writeDecimalLong(valueCount.toLong()).writeByte('\n'.code)
        writeByte('\n'.code)
​
        //遍历Map的Entry
        for (entry in lruEntries.values) {
            if (entry.currentEditor != null) {
                //脏数据
                writeUtf8(DIRTY)
                writeByte(' '.code)
                writeUtf8(entry.key)
                writeByte('\n'.code)
            } else {
                //干净数据
                writeUtf8(CLEAN)
                writeByte(' '.code)
                writeUtf8(entry.key)
                entry.writeLengths(this)
                writeByte('\n'.code)
            }
        }
    }
​
    if (fileSystem.exists(journalFile)) {
        //有旧文件,把新日志文件交换并删除旧文件
        fileSystem.atomicMove(journalFile, journalFileBackup)
        fileSystem.atomicMove(journalFileTmp, journalFile)
        fileSystem.delete(journalFileBackup)
    } else {
        //没有旧文件,直接将新日志文件放入到正式目录
        fileSystem.atomicMove(journalFileTmp, journalFile)
    }
​
    //创建新的Writer以及重置一些参数
    journalWriter = newJournalWriter()
    operationsSinceRewrite = 0
    hasJournalErrors = false
    mostRecentRebuildFailed = false
}

自此初始化已经说完了,流程图总结:

graph TD
initialize --> a(readJournalLine)
subgraph readJournal
a --> |iterate| a
end
a --> processJournal --> writeJournal --> End

接下来说缓存的操作。

对数据的操作不外乎增删改查,而DiskLruCache的增和改都使用edit方法进行,也即只有三个方法对缓存进行操作:edit、get、remove。

先说get

顾名思义,get是从缓存中读取数据。

operator fun get(key: String): Snapshot? {
    //检查缓存是否已经close、key是否有效、是否已经初始化。
    checkNotClosed()
    validateKey(key)
    initialize()

    //构建Snapshot
    val snapshot = lruEntries[key]?.snapshot() ?: return null

    //操作计数+1
    operationsSinceRewrite++
    //写入日志记录
    journalWriter!!.apply {
        writeUtf8(READ)
        writeByte(' '.code)
        writeUtf8(key)
        writeByte('\n'.code)
    }

    if (journalRewriteRequired()) {
        //进行清理工作
        launchCleanup()
    }

    //返回Snapshot
    return snapshot
}

代码很简单,重点就是构建Snapshot以及将操作写入日志。Snapshot并不是数据本身,同样也只是缓存文件的路径。Coil会将其包装成SourceResult,并在decode步骤进行实际的文件读取。

再说remove

fun remove(key: String): Boolean {
    //同样的检查
    checkNotClosed()
    validateKey(key)
    initialize()

    //获取待删除的Entry
    val entry = lruEntries[key] ?: return false
    //删除
    val removed = removeEntry(entry)
    if (removed && size <= maxSize) mostRecentTrimFailed = false
    return removed
}

private fun removeEntry(entry: Entry): Boolean {

    if (entry.lockingSnapshotCount > 0) {
        //该Entry还有未释放的Snapshot,也即正在被使用中,写入一行DIRTY日志
        journalWriter?.apply {
            writeUtf8(DIRTY)
            writeByte(' '.code)
            writeUtf8(entry.key)
            writeByte('\n'.code)
            flush()
        }
    }
    if (entry.lockingSnapshotCount > 0 || entry.currentEditor != null) {
        //标记为zombie并返回。
        entry.zombie = true
        return true
    }

    //detach当前Entry的Editor
    entry.currentEditor?.detach()

    for (i in 0 until valueCount) {
        //删除缓存文件并计算size
        fileSystem.delete(entry.cleanFiles[i])
        size -= entry.lengths[i]
        entry.lengths[i] = 0
    }

    //操作计数+1
    operationsSinceRewrite++
    //写入REMOVE操作到日志文件
    journalWriter?.apply {
        writeUtf8(REMOVE)
        writeByte(' '.code)
        writeUtf8(entry.key)
        writeByte('\n'.code)
    }
    //Map中移除Entry
    lruEntries.remove(entry.key)

    if (journalRewriteRequired()) {
        //同样的清理行为
        launchCleanup()
    }

    return true
}

remove的代码也不太难,只有其中一个地方需要解释一下。当Entry有未释放的Snapshot或者当前的Editor不为空(正在被编辑)时,remove行为不是被实际执行,而是将Entry的zombie置为true。而这个zombie则表明,该Entry应该被删除,只是当前处于正在被使用的状态下不直接删除,等到用完了就应该删除掉该Entry。不得不说,这个zombie的变量名取得相当有趣且贴切。

最后是比较复杂的edit

DiskLruCache的edit方法本身其实并不复杂,这里就不展开了,实际上只是构建了一个Editor并返回,实际上所有的实际操作都以Editor作为工具的。这里先看看Editor的代码:

inner class Editor(val entry: Entry) {

    private var closed = false

    val written = BooleanArray(valueCount)

    fun file(index: Int): Path {
        synchronized(this@DiskLruCache) {
            check(!closed) { "editor is closed" }
            written[index] = true
            return entry.dirtyFiles[index].also(fileSystem::createFile)
        }
    }

    fun detach() {
        if (entry.currentEditor == this) {
            entry.zombie = true
        }
    }

    fun commit() = complete(true)

    fun commitAndGet(): Snapshot? {
        synchronized(this@DiskLruCache) {
            commit()
            return get(entry.key)
        }
    }

    fun abort() = complete(false)

    private fun complete(success: Boolean) {
        synchronized(this@DiskLruCache) {
            check(!closed) { "editor is closed" }
            if (entry.currentEditor == this) {
                completeEdit(this, success)
            }
            closed = true
        }
    }
}

首先捋清楚对一个缓存文件进行修改的流程:

graph LR
a(Editor.file) --> b(edit the file) --> c(Editor.commit)

file方法获取到文件的路径(同样也是使用OKIO的Path),接着对文件进行编辑(新增或者修改),最后调用commit提交修改。

重点是commit方法,我们详细来分析一下。commit的调用链如下:

graph LR
Editor.commit --> Editor.complete --> DiskLruCache.completeEdit

我们直接看DiskLruCache的completeEdit方法的代码:

private fun completeEdit(editor: Editor, success: Boolean) {
    val entry = editor.entry
    check(entry.currentEditor == editor)

    if (success && !entry.zombie) {
        //编辑成功并且该Entry不需要删除
        for (i in 0 until valueCount) {
            if (editor.written[i] && !fileSystem.exists(entry.dirtyFiles[i])) {
                //检查编辑过的缓存文件是否真实存在,不存在则认为编辑失败,打断编辑。
                editor.abort()
                return
            }
        }

        for (i in 0 until valueCount) {
            val dirty = entry.dirtyFiles[i]
            val clean = entry.cleanFiles[i]
            if (fileSystem.exists(dirty)) {
                //将修改后的脏数据转成干净数据
                fileSystem.atomicMove(dirty, clean)
            } else {
                fileSystem.createFile(entry.cleanFiles[i])
            }
            //计算size
            val oldLength = entry.lengths[i]
            val newLength = fileSystem.metadata(clean).size ?: 0
            entry.lengths[i] = newLength
            size = size - oldLength + newLength
        }
    } else {
        //编辑失败或者该Entry为zombie
        for (i in 0 until valueCount) {
            //删除所有的编辑后的脏数据
            fileSystem.delete(entry.dirtyFiles[i])
        }
    }

    entry.currentEditor = null
    if (entry.zombie) {
        //Entry为zombie则移除
        removeEntry(entry)
        return
    }

    //操作计数+1
    operationsSinceRewrite++
    //写日志文件
    journalWriter!!.apply {
        if (success || entry.readable) {
            entry.readable = true
            writeUtf8(CLEAN)
            writeByte(' '.code)
            writeUtf8(entry.key)
            entry.writeLengths(this)
            writeByte('\n'.code)
        } else {
            lruEntries.remove(entry.key)
            writeUtf8(REMOVE)
            writeByte(' '.code)
            writeUtf8(entry.key)
            writeByte('\n'.code)
        }
        flush()
    }

    if (size > maxSize || journalRewriteRequired()) {
        //清理行为
        launchCleanup()
    }
}

代码逻辑的本身并不难,只是这里我们需要再次明确什么是脏数据什么是干净数据。

干净数据是经过了DiskLruCache“洗过了”,能供外部正确读取的数据,从逻辑层面来说,对外部是只读的。

脏数据则是可被读写的。

这就是同样是读取缓存文件,DiskLruCache的get方法是从clean里面取,而Editor.file方法是从dirty里面取的原因。这样做的好处是,即使对缓存进行了一个错误的写入行为(比如写到一半程序崩溃了),也不会因此污染到其他人对该缓存的读取行为,因为这个错误的写入行为还没有被“洗干净”,意味着还没有真正提交到缓存里。

当一个dirty的数据执行了commit,提交到DiskLruCache里面,就会被洗干净,存放到clean里面,此时再调用get进行读取缓存,就是编辑后的数据了。

最后说一下清理行为。

上面的代码中,最后都会调用一个launchCleanup的方法。由于只有edit会有可能增加缓存的占用,所以maxSize的判断只有在completeEdit里才有出现。maxSize的判断很容易理解,当超过了设定的maxSize时,就需要进行缓存的清理了。而launchCleanup还有另一个执行条件,取决于journalRewriteRequire这个方法的返回。

顾名思义,journalRewriteRequire正是用来判断是否需要重写日志文件的。日志文件总是会将每个操作一条不漏地记录下来,如果没有自清理的机制的话,这个日志文件就会不断膨胀。

我们看看这个journalRewriteRequire的代码:

private fun journalRewriteRequired() = operationsSinceRewrite >= 2000

当操作数到达2000时,日志文件就需要被重写。留心的读者会发现,上面将缓存操作的时候,每一个都出现了操作数+1的代码。无论是edit、get还是remove,都计入操作数中。而文件的重新则是调用writeJournal方法实现的,上面已经分析过了,只会根据内存中的Map构建日志文件。

下面我们看launchCleanup方法:

private fun launchCleanup() {
    //协程执行
    cleanupScope.launch {
        synchronized(this@DiskLruCache) {
            if (!initialized || closed) return@launch
            try {
                //缩减缓存的size
                trimToSize()
            } catch (_: IOException) {
                mostRecentTrimFailed = true
            }
            try {
                if (journalRewriteRequired()) {
                    //重写日志文件
                    writeJournal()
                }
            } catch (_: IOException) {
                mostRecentRebuildFailed = true
                journalWriter = blackholeSink().buffer()
            }
        }
    }
}

private fun trimToSize() {
    while (size > maxSize) {
        //一直删除最远使用的Entry直到size小于maxSize
        if (!removeOldestEntry()) return
    }
    mostRecentTrimFailed = false
}

private fun removeOldestEntry(): Boolean {
    for (toEvict in lruEntries.values) {
        //循环删除非zombie的Entry
        if (!toEvict.zombie) {
            removeEntry(toEvict)
            return true
        }
    }
    return false
}

代码也同样很简单,也只有一个地方需要稍微解释一下。

为什么直接遍历删除Entry就能做到removeOldest呢?这正是日志文件最重要的功能。由于日志文件的写入先天就存在着时间关系,越迟写入的则表明越新。并且读取日志文件同样也是从前到后读取,所以说初始化时构建的Map里的Entry,本身就存在着越来越新的关系。而遍历Map是从头开始的,所以每一次删除都是Map中“最老”的Entry。

总结

自从,Coil的缓存都讲完了。当了解过后才发现,不管是内存缓存还是磁盘缓存,采用的策略其实都是很常见的东西。通过这次对Coil缓存实现的源码分析,补上了一点知识缺口,受益匪浅。