Coil之DiskLruCache

·  阅读 165

首先Coil的DiskLruCache是基于DiskLruCache修改的,这个是谷歌官方收录的代码,但是并没有用于Android源码中.Coil在它的基础上使用Kotlin和okio进行了一些修改.

1. Journal文件

DiskLruCache是通过在缓存文件夹下的journal文件进行管理的,因此能够看懂journal文件就很关键

image-20220316143942491

前五行就是journal文件的头

  • 第一行就是一个固定的字符串
  • 第二行是DiskLruCache的版本号,也是固定为1
  • 第三行是传入的app版本号
  • 第四行是每个key对应几个文件,coil设置的为2,一个用于图片请求头,一个用于保存图片原图
  • 第五行为空

之后就是操作记录了

  • DIRTY 表示一个缓存正在写入中.正常情况每一条DIRTY数据都会有另一条数据与之对应,写入成功就是CLEAN,失败就是REMOVE
  • REMOVE除了上一条的情况下会出现以外,还有就是在手动调用remove(key:String)时也会写入一条REMOVE数据
  • READ代表一次读取记录
  • CLEAN记录中除了包含key还按顺序记录了图片的请求头和图片原图的大小

2. DiskLruCache 源码分析

于是再结合代码去理解这些操作记录是如何起作用的

image-20220316151155718

这是DiskLruCache的整体结构,先从它的三个内部类讲起

  • Snapshot就是每条操作记录的文件快照,可以通过它获取到key对应的缓存文件
  • Editor提供操作记录的编辑功能
  • Entry就是每一条操作记录对应的实体类,包含了该entry是否可读,是否可以被释放,以及缓存文件对应的路径

通过这三个类完成对缓存文件的读写管理操作

初始化

DiskLruCache的内部维护了一个lruEntries = LinkedHashMap<String, Entry>(0, 0.75f, true)

initialize()进行初始化时会调用readJournal()processJournal()lurEnties进行初始化,以及计算当前大小,并把脏数据删除.

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)) {
                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) -> {
                val parts = line.substring(secondSpace + 1).split(' ')
                entry.readable = true
                entry.currentEditor = null
                entry.setLengths(parts)
            }
            secondSpace == -1 && firstSpace == DIRTY.length && line.startsWith(DIRTY) -> {
                entry.currentEditor = Editor(entry)
            }
            secondSpace == -1 && firstSpace == READ.length && line.startsWith(READ) -> {
                // This work was already done by calling lruEntries.get().
            }
            else -> throw IOException("unexpected journal line: $line")
        }
    }

大概的流程是获取key,如果是REMOVE操作就调用lruEntries.remove(key),如果不是REMOVE就继续执行,lruEntries不存在key的记录,就创建entry并存入.如果是CLEAN合法操作就就将其初始化,设置readable=true currentEditor=null 初始化文件长度,对于DIRTY操作就是说明缓存文件还正在创建,会为它创建一个Editor.READ操作忽略即可.

经过上面这个过程,正常情况下DIRTY不会单独出现,会和REMOVE、CLEAN成对出现,也就是说,经过上面这个流程,基本上加入到lruEntries里面的只有CLEAN且没有被REMOVE的key

回到readJournal()中,会计算我们读取的行数再减去lruEntries的大小就是无用的记录条数,并保存到operationsSinceRewrite中.如果大于2000之后就会触发清理操作,会最久没有使用过的缓存记录从lruEntries中移除直到它的大小小于设置的maxSize

存入缓存
fun edit(key: String): Editor? {
    checkNotClosed()
    validateKey(key)
    initialize()

    var entry = lruEntries[key]

    if (entry?.currentEditor != null) {
        return null // Another edit is in progress.
    }

    if (entry != null && entry.lockingSnapshotCount != 0) {
        return null // We can't write this file because a reader is still reading it.
    }

    if (mostRecentTrimFailed || mostRecentRebuildFailed) {
        // The OS has become our enemy! If the trim job failed, it means we are storing more
        // data than requested by the user. Do not allow edits so we do not go over that limit
        // any further. If the journal rebuild failed, the journal writer will not be active,
        // meaning we will not be able to record the edit, causing file leaks. In both cases,
        // we want to retry the clean up so we can get out of this state!
        launchCleanup()
        return null
    }

    // Flush the journal before creating files to prevent file leaks.
    journalWriter!!.apply {
        writeUtf8(DIRTY)
        writeByte(' '.code)
        writeUtf8(key)
        writeByte('\n'.code)
        flush()
    }

    if (hasJournalErrors) {
        return null // Don't edit; the journal can't be written.
    }

    if (entry == null) {
        entry = Entry(key)
        lruEntries[key] = entry
    }
    val editor = Editor(entry)
    entry.currentEditor = editor
    return editor
}

先验证key,再获取到对应的entry,如果这个entry当前的editer不为空,或者还在使用中,就返回null,代表当前还无法对它写入缓存,否则就继续向下,添加一条DIRTY记录代表这个缓存文件还在使用中,如果写入失败也会返回null,最终会创建一个Editor并返回,这时我们就可以使用它写入缓存了,写入完成后,我们应该调用Editor的commitAndGet这个方法又会最终调用到DiskLruCache.completeEdit(editor: Editor, success: Boolean)方法,该方法会检测所有已写入的文件是否存在,不存在将会调用Editor.abort将产生的临时文件删除,接下来如果entry.zombie==true就会调用removeEntry(entry)将它移出并返回.然后来到最后一步,根据(success == true ||readable==true,将readable设置为true,写入一条CLEAN记录。否则就会从lruEntries.remove(key),写入一条REMOVE记录

读取缓存

读取缓存就会比较简单,调用get(key)方法即可获取对应entry的snapshot,通过它可以获取到缓存的Path,通过okio即可读取文件,并且也会在journal文件中添加一条READ记录

清理缓存

一般来说,不需要我们手动调用清除缓存,读取和存入缓存都会触发检测数据占用检测方法,使用launchCleanup()进行清理.主要逻辑是在removeEntry方法中

private fun removeEntry(entry: Entry): Boolean {
        // If we can't delete files that are still open, mark this entry as a zombie so its files
        // will be deleted when those files are closed.
        if (entry.lockingSnapshotCount > 0) {
            // Mark this entry as 'DIRTY' so that if the process crashes this entry won't be used.
            journalWriter?.apply {
                writeUtf8(DIRTY)
                writeByte(' '.code)
                writeUtf8(entry.key)
                writeByte('\n'.code)
                flush()
            }
        }
        if (entry.lockingSnapshotCount > 0 || entry.currentEditor != null) {
            entry.zombie = true
            return true
        }

        // Prevent the edit from completing normally.
        entry.currentEditor?.detach()

        for (i in 0 until valueCount) {
            fileSystem.delete(entry.cleanFiles[i])
            size -= entry.lengths[i]
            entry.lengths[i] = 0
        }

        operationsSinceRewrite++
        journalWriter?.apply {
            writeUtf8(REMOVE)
            writeByte(' '.code)
            writeUtf8(entry.key)
            writeByte('\n'.code)
        }
        lruEntries.remove(entry.key)

        if (journalRewriteRequired()) {
            launchCleanup()
        }

        return true
    }

这里会对还在使用中的文件写入DIRTY记录,在发生崩溃.或者重新启动程序后,也能保证这条记录不会被再次使用,同时如果entry还在编辑中时,会将其标记为可回收的,在它的编辑完成后再次将它释放.接下来就会对entry对应的缓存文件进行删除,写入REMOVE操作,从lruEntry中移除ta

关于DiskLruCache.Entry的一些细节

lengths,cleanFiles,dirtyFiles长度都是2的数组,他们都保存了请求头和图片的相关数据,lengths是大小,cleanFiles是缓存文件,dirtyFiles是临时的缓存文件

  init {
            // The names are repetitive so re-use the same builder to avoid allocations.
            val fileBuilder = StringBuilder(key).append('.')
            val truncateTo = fileBuilder.length
            for (i in 0 until valueCount) {
                fileBuilder.append(i)
                cleanFiles += directory / fileBuilder.toString()
                fileBuilder.append(".tmp")
                dirtyFiles += directory / fileBuilder.toString()
                fileBuilder.setLength(truncateTo)
            }
        }

通过Entry类的初始化函数就可以明白了,通过两次循环分别将请求头的缓存文件(以key+.0命名)和临时文件(以key+.0.tmp命名),图片的缓存文件缓存文件(以key+.1命名)和临时文件(以key+.1.tmp命名),以路径的形式将他们保存至cleanFilesditryFiles中.在SnapshotEditor中只需要传入0或1就能使用缓存的两个文件了

以上就是个人对Coil的DiskLruCache的全部理解

分类:
Android
标签:
分类:
Android
标签:
收藏成功!
已添加到「」, 点击更改