DiskLruCache学习

一. 用法

DiskLruCache是Google官方推荐的磁盘缓存方案,很多优秀的App都在使用这一方案,在Android DiskLruCache完全解析, 硬盘缓存的最佳方案这篇博客中,很详细的介绍了如何使用DiskLruCache,通过这篇博文可以将DiskLruCache的用法总结为以下几个步骤:

1.1 写缓存

  1. 确定缓存目录, 获取App版本号, 调用DiskaLruCache.open创建DiskLruCache对象
  2. 通过DiskLruCache.editor()获取DiskLruCache.Editor对象
  3. 通过Editor.newOutputStream()获取输出流,之后利用该输出流将缓存文件写入磁盘
  4. 调用Editor.commit(),DiskLruCache.flush()刷新日志文件

1.2 读缓存

  1. 通过DiskLruCache.get()获取Snapshot对象
  2. 通过Snapshot.getInputStream()获取输入流,利用该输入流读取缓存文件

1.3 日志文件

DiskLruCache主要通过日志文件来记录和管理缓存文件,在DiskLruCache的源码中有一段注释详细陈述了日志文件的格式:

libcore.io.DiskLruCache
1
100
2

CLEAN 3400330d1dfc7f3f7f4b8d4d803dfcf6 832 21054
DIRTY 335c4c6028171cfddfbaae1a9c313c52
CLEAN 335c4c6028171cfddfbaae1a9c313c52 3934 2342
REMOVE 335c4c6028171cfddfbaae1a9c313c52
DIRTY 1ab96a171faeeee38496d8b330771a7a
CLEAN 1ab96a171faeeee38496d8b330771a7a 1600 234
READ 335c4c6028171cfddfbaae1a9c313c52
READ 3400330d1dfc7f3f7f4b8d4d803dfcf6

前五行是日志文件的头部信息,其意义分别是

  • 第一行的libcore.io.DiskLruCache是文件的MAGIC, 用来标识该文件是DiskLruCache的日志文件
  • 第二行是DiskLruCache自身的版本号
  • 第三行是App的版本号,通过DiskLruCache.open的第二个参数设置
  • 第四行是DiskLruCache.open的第三个参数,代表一个key值可以缓存多少个Entry
  • 第五行是一个空行

从第六行开始记录了缓存文件的相应操作:

  • 每次调用DiskLruCache.edit()时,都会向日志文件写入一条DIRTY数据,表示当前正在准备写入一条缓存数据, DIRTY后面各一个空格写入缓存的key值
  • 当调用Editor.commit()将缓存写入成功之后,会在DIRTY数据下一行写入一条key值相同的CLEAN数据, CLEAN后面隔一个空格写入相同的key值,key值后隔一个空格写入以字节为单位的该缓存文件的大小,如果在DiskLruCache.open中第三个参数valueCount传大于1的值,那么每一个key值可以对应多个缓存文件,相应的一条CLEAN数据后面就会记录多个缓存文件的大小,其数目等于valueCount;如果调用Editor.abort(),那么会在DIRTY数据下一行下入一条REMOVE记录。也就是说, 每一条DIRTY数据下一行都有条CLEAN数据或REMOVE
    数据,DIRTY数据不可以单独存在,否则这条数据就会被删除掉; 当调用DiskLruCache.get()时都会想日志文件写如一条READ数据,表示正在读取缓存文件

二.源码分析

下面就根据这几个步骤结合源码来看一下DiskLruCache的具体实现

2.1 重要变量和类

2.1.1 变量

  • journalWriter: Writer 用于向日志文件写入内容
  • lruEntries: LinkedHashMap<String, Entry> 每一个缓存文件都有一个对应的Entry对象,lruEntries用来存放key值对应的Entry对象
  • redundantOpCount:记录操作缓存的次数,如果该值达到2000,DiskLruCache就会重新构建日志文件,将其中一些冗余的数据删除
  • size: 记录总有缓存文件总大小
  • maxSize: 所有缓存文件大小的总和的上限值,如果超过该值,那么就会删除一些缓存文件
  • cleanupCallable: Callable 当缓存文件总大小超过上限时会触发该任务,用于清除一些缓存文件,从而减少缓存文件总和

2.1.2 类

  • Snapshot: 调用DiskLruCache.get()会获取一个Snapshot对象,通过Snapshot可以获取缓存文件的输入流
  • Editor: 编辑器,对于缓存文件的操作以及日志文件的更新都是通过这个类完成
  • Entry: 每一个缓存文件都有一个对应的Entry对象, DiskLruCache通过操作Entry对象来完成对缓存文件的操作
  • StrictLineReader: 封装了输入流,提供可以每次读取一行内容的方法

2.2 DiskLruCache.open


    static final String JOURNAL_FILE = "journal";
    static final String JOURNAL_FILE_BACKUP = "journal.bkp";

    public static DiskLruCache open(File directory, int appVersion, int valueCount, long maxSize)
            throws IOException {

        if (maxSize <= 0) {
            throw new IllegalArgumentException("maxSize <= 0");
        }
        if (valueCount <= 0) {
            throw new IllegalArgumentException("valueCount <= 0");
        }

        //如果备份文件存在则使用备份文件
        File backupFile = new File(directory, JOURNAL_FILE_BACKUP);
        if (backupFile.exists()) {
            File journalFile = new File(directory, JOURNAL_FILE);
            //如果日志文件存在,删除备份文件
            if (journalFile.exists()) {
                backupFile.delete();
            } else {
                //将备份文件重命名为日志文件
                renameTo(backupFile, journalFile, false);
            }
        }

        // Prefer to pick up where we left off.
        DiskLruCache cache = new DiskLruCache(directory, appVersion, valueCount, maxSize);
        //如果日志文件存在的话,读取日志文件并处理,填充lruEntries, 然后直接返回DiskLruCache对象
        if (cache.journalFile.exists()) {
            try {
                //读取journal文件, 【2.2.1】
                cache.readJournal();
                //处理读取的journal文件内容, 【2.2.2】
                cache.processJournal();
                return cache;
            } catch (IOException journalIsCorrupt) {
                ...
                cache.delete();
            }
        }

        //如果没有已有的日志文件,创建对应的缓存目录, 并初始化日志文件
        directory.mkdirs();
        cache = new DiskLruCache(directory, appVersion, valueCount, maxSize);
        //新建日志文件,【2.2.3】
        cache.rebuildJournal();
        return cache;
    }

  1. 首先检查备份文件是否存在,之后再确定日志文件是否存在, 如果日志文件存在,则删除备份文件,如果日志文件不存在但备份文件村咋,将备份文件重命名为日志文件
  2. 创建DiskLruCache对象,构造函数中几个参数的意义分别是:directory - 缓存目录; appVersion - 应用版本号; valueCount - 一个可以可以缓存几个Entry, 一般都传1; maxSize - 所有缓存文件大小的总和占据的最大存储空间
  3. 如果日志文件已存在,读取日志文件并根据日志文件进行一些必要的操作,如删除以DIRTY开头的文件
  4. 如果日志文件不存在,创建缓存目录,并新建日志文件

2.2.1 DiskLruCache.readJournal

    
    private void readJournal() throws IOException {
        StrictLineReader reader = new StrictLineReader(new FileInputStream(journalFile), Util.US_ASCII);
        try {
            //读取第一行魔数
            String magic = reader.readLine();
            //读取第二行version
            String version = reader.readLine();
            //读取第三行appVersion
            String appVersionString = reader.readLine();
            //读取第四行valueCount
            String valueCountString = reader.readLine();
            //读取第五行空行
            String blank = reader.readLine();
            //确保头部信息正确
            if (!MAGIC.equals(magic)
                    || !VERSION_1.equals(version)
                    || !Integer.toString(appVersion).equals(appVersionString)
                    || !Integer.toString(valueCount).equals(valueCountString)
                    || !"".equals(blank)) {
                throw new IOException(...);
            }

            int lineCount = 0;
            while (true) {
                try {
                    //处理这一行内容,【2.2.1.1】
                    readJournalLine(reader.readLine());
                    lineCount++;
                } catch (EOFException endOfJournal) {
                    break;
                }
            }
            //处理了多少行 - lruEntries.size()
            redundantOpCount = lineCount - lruEntries.size();

            // 如果遇到IO异常, 重新构建日志文件
            if (reader.hasUnterminatedLine()) {
                rebuildJournal();
            } else {
                journalWriter = new BufferedWriter(new OutputStreamWriter(
                        new FileOutputStream(journalFile, true), Util.US_ASCII));
            }
        } finally {
            Util.closeQuietly(reader);
        }
    }

从代码中可以看到,读取日志文件每一行内容主要使用了StrictLineReader这个类, 这个类实际上封装了InputStream, 内部有一个缓存数组,每次缓存8192个字节,从而提高了读取的效率,当遇到换行符时即判定为一行内容

  1. 首先读取前五行,校验是否是正确的头部信息
  2. c处理完前五行之后,依次读取每一行内容并根据内容进行操作
  3. 如果遇到IO异常,重新构建日志文件;否则一切正常的话, 初始化journalWriter(用来写入日志文件)

2.2.1.1 DiskLruCache.readJournalLine


    private void readJournalLine(String line) throws IOException {
        //获取第一个空格的位置
        int firstSpace = line.indexOf(' ');
        if (firstSpace == -1) {
            throw new IOException("unexpected journal line: " + line);
        }

        //第一个空格后面是key的起始位置
        int keyBegin = firstSpace + 1;
        //获取第二个空格的位置, 一般如果是CLEAN的话会有第二个空格
        int secondSpace = line.indexOf(' ', keyBegin);
        final String key;
        if (secondSpace == -1) {
            key = line.substring(keyBegin);
            //如果这一行的开头是REMOVE, 从lruEntries中删除key
            if (firstSpace == REMOVE.length() && line.startsWith(REMOVE)) {
                lruEntries.remove(key);
                return;
            }
        } else {
            key = line.substring(keyBegin, secondSpace);
        }

        //从lruEntries中获取key值对应的Entry, 如果lruEntries中没有对应的Entry,生成一个新的Entry并放入lruEntries
        Entry entry = lruEntries.get(key);
        if (entry == null) {
            entry = new Entry(key);
            lruEntries.put(key, entry);
        }

        //如果这一行是以CLEAN开头, 获取第二个空格之后内容
        if (secondSpace != -1 && firstSpace == CLEAN.length() && line.startsWith(CLEAN)) {
            String[] parts = line.substring(secondSpace + 1).split(" ");
            entry.readable = true;
            entry.currentEditor = null;
            entry.setLengths(parts);
        }
        //如果这一行是以DIRTY开头,将currentEditor指向一个新的Editor
        else if (secondSpace == -1 && firstSpace == DIRTY.length() && line.startsWith(DIRTY)) {
            entry.currentEditor = new Editor(entry);
        }
        //如果这一行以READ开头,啥也不干
        else if (secondSpace == -1 && firstSpace == READ.length() && line.startsWith(READ)) {
            // This work was already done by calling lruEntries.get().
        }
        else {
            throw new IOException("unexpected journal line: " + line);
        }
    }

  1. 根据空格依次获得每一行的开头标识(DIRTY, CLEAN, REMOVE)以及对应的key值
  2. 如果这一行是以REMOVE开头,从lruEntries中删除key对应的Entry并返回
  3. lruEntries中获取key值对应的Entry,如果没有则生成一个新的Entry对象并放入lruEntries,这个操作的目的是为了保持日志文件和内存中lruEntries的数据的一致性
  4. 如果这一行是以CLEAN开头,获取key值以后的内容(记录一个或多个对象缓存文件的大小),同时标记entry.readable = true, entry.currentEditor = null,即表示该缓存文件为可读的
  5. 如果这一行是以DIRTY开头的,将entry.currentEditor指向一个新创建的Editor对象

2.2.2 DiskLruCache.processJournal

private void processJournal() throws IOException {
        //删除journal.tmp文件
        deleteIfExists(journalFileTmp);
        //遍历lruEntries
        for (Iterator<Entry> i = lruEntries.values().iterator(); i.hasNext(); ) {
            Entry entry = i.next();
            if (entry.currentEditor == null) {
                //如果currentEditor为null, 代表该entry是CLEAN的,增加size
                for (int t = 0; t < valueCount; t++) {
                    size += entry.lengths[t];
                }
            } else {
                //如果currentEditor不为null, 代表该entry是DIRTY的,删除对应的缓存文件和临时文件
                //并从lruEntries中删除该entry
                entry.currentEditor = null;
                for (int t = 0; t < valueCount; t++) {
                    deleteIfExists(entry.getCleanFile(t));
                    deleteIfExists(entry.getDirtyFile(t));
                }
                i.remove();
            }
        }
    }

  1. 如果临时日志文件存在,删除临时文件
  2. 遍历lruEntries, 如果entry.currentEditor == null代表这一个entry是CLEAN的,将entry对应的文件大小统计到所有缓存文件大小总和中;相反,则代表entry是DIRTY的,删除对应的缓存文件,并从lruEntries中删除(NOTE:这里其实针对的是只有DIRTY记录的缓存文件,因为正常情况下,每一条DIRTY数据后都会紧跟一条CLEAN或者REMOVE数据,如果有CLEAN或REMOVE数据,在之前的【2.2.1.1】readJournalLine中都已经经过了处理,其所对应的entry的currentEditor肯定为null或不在lruEntries中了)

2.2.3 DiskLruCache.rebuildJournal

    private synchronized void rebuildJournal() throws IOException {
        if (journalWriter != null) {
            journalWriter.close();
        }

        //先写入journal.tmp文件
        Writer writer = new BufferedWriter(
                new OutputStreamWriter(new FileOutputStream(journalFileTmp), Util.US_ASCII));
        try {
            //写入头部信息
            writer.write(MAGIC);
            writer.write("\n");
            writer.write(VERSION_1);
            writer.write("\n");
            writer.write(Integer.toString(appVersion));
            writer.write("\n");
            writer.write(Integer.toString(valueCount));
            writer.write("\n");
            writer.write("\n");

            for (Entry entry : lruEntries.values()) {
                if (entry.currentEditor != null) {
                    //如果currentEditor不为null, 则写入DIRTY开头的行
                    writer.write(DIRTY + ' ' + entry.key + '\n');
                } else {
                    //写入以CLEAN开头的行
                    writer.write(CLEAN + ' ' + entry.key + entry.getLengths() + '\n');
                }
            }
        } finally {
            writer.close();
        }

        if (journalFile.exists()) {
            renameTo(journalFile, journalFileBackup, true);
        }
        renameTo(journalFileTmp, journalFile, false);
        journalFileBackup.delete();

        journalWriter = new BufferedWriter(
                new OutputStreamWriter(new FileOutputStream(journalFile, true), Util.US_ASCII));
    }

  1. 首先写入固定头部
  2. 遍历lruEntries,根据entry.currentEditor是否等于null,写入DIRTY或者CLEAN记录

2.3 DiskLruCache.edit

    
    public Editor edit(String key) throws IOException {
        return edit(key, ANY_SEQUENCE_NUMBER);
    }

    private synchronized Editor edit(String key, long expectedSequenceNumber) throws IOException {
        //如果journalWritter == null, 抛出异常
        checkNotClosed();
        //校验key是否合法
        validateKey(key);
        //从lruEntries中获取Entry
        Entry entry = lruEntries.get(key);
        if (expectedSequenceNumber != ANY_SEQUENCE_NUMBER && (entry == null
                || entry.sequenceNumber != expectedSequenceNumber)) {
            return null; // Snapshot is stale.
        }
        if (entry == null) {
            entry = new Entry(key);
            lruEntries.put(key, entry);
        } else if (entry.currentEditor != null) {
            return null; // Another edit is in progress.
        }

        Editor editor = new Editor(entry);
        entry.currentEditor = editor;

        // 先写入DIRTY行
        journalWriter.write(DIRTY + ' ' + key + '\n');
        journalWriter.flush();
        return editor;
    }
  1. 检查journal是否为null, 如果是抛出异常
  2. 检查key值是否符合[a-z0-9_-]{1,120}规则
  3. 根据key获取对应的Entry
  4. 新建一个Editor对象,将entry.currentEditor指向新建的Editor对象
  5. 向日志文件写入DIRTY数据

每当调用editor()时都会先在日志文件中写入一条DIRTY数据,表示正在准备操作缓存文件

2.4 Editor.newOutputStream

    public OutputStream newOutputStream(int index) throws IOException {
            if (index < 0 || index >= valueCount) {
                throw new IllegalArgumentException(...);
            }
            synchronized (DiskLruCache.this) {
                if (entry.currentEditor != this) {
                    throw new IllegalStateException();
                }
                if (!entry.readable) {
                    written[index] = true;
                }
                //先在临时文件中写入
                File dirtyFile = entry.getDirtyFile(index);
                FileOutputStream outputStream;
                try {
                    outputStream = new FileOutputStream(dirtyFile);
                } catch (FileNotFoundException e) {
                    // Attempt to recreate the cache directory.
                    directory.mkdirs();
                    try {
                        outputStream = new FileOutputStream(dirtyFile);
                    } catch (FileNotFoundException e2) {
                        // We are unable to recover. Silently eat the writes.
                        return NULL_OUTPUT_STREAM;
                    }
                }
                //FaultHidingOutputStream是一个代理类,实际还是调用outputStream的方法
                //只不过异常发生时会不会抛出异常
                return new FaultHidingOutputStream(outputStream);
            }
        }

获取的是临时文件的输出流

2.5 Editor.commit

      public void commit() throws IOException {
          if (hasErrors) {
              //如果有错误,删除缓存文件, 【2.5.1】
              completeEdit(this, false);
              remove(entry.key); // The previous entry is stale.
          } else {
              completeEdit(this, true);
          }
          committed = true;
      }

如果IO输出过程有错误发生,从lruEntries中删除相应entry,同时删除对应的缓存文件; 无论是否错误,都会调用DiskLruCache.compleEdit方法, 区别在于错误时传入的第二个参数为false, 正常时为true

hasError是在FaultHidingOutputStream中当出现IO异常时设为true

2.5.1 DiskLruCache.completeEdit

    
    private synchronized void completeEdit(Editor editor, boolean success) throws IOException {
        Entry entry = editor.entry;
        if (entry.currentEditor != editor) {
            throw new IllegalStateException();
        }


        if (success && !entry.readable) {
            for (int i = 0; i < valueCount; i++) {
                if (!editor.written[i]) {
                    editor.abort();
                    throw new IllegalStateException(...);
                }
                //确保临时文件存在
                if (!entry.getDirtyFile(i).exists()) {
                    editor.abort();
                    return;
                }
            }
        }

        for (int i = 0; i < valueCount; i++) {
            File dirty = entry.getDirtyFile(i);
            if (success) {
                if (dirty.exists()) {
                    File clean = entry.getCleanFile(i);
                    dirty.renameTo(clean);
                    long oldLength = entry.lengths[i];
                    long newLength = clean.length();
                    entry.lengths[i] = newLength;
                    size = size - oldLength + newLength;
                }
            } else {
                deleteIfExists(dirty);
            }
        }

        redundantOpCount++;
        entry.currentEditor = null;
        if (entry.readable | success) {
            entry.readable = true;
            //向日志文件写入CLEAN行
            journalWriter.write(CLEAN + ' ' + entry.key + entry.getLengths() + '\n');
            if (success) {
                entry.sequenceNumber = nextSequenceNumber++;
            }
        } else {
            //从lruEntries中删除,并向日志文件写入REMOVE行
            lruEntries.remove(entry.key);
            journalWriter.write(REMOVE + ' ' + entry.key + '\n');
        }
        journalWriter.flush();

        if (size > maxSize || journalRebuildRequired()) {
            executorService.submit(cleanupCallable);
        }
    }
  1. 确保临时的缓存文件存在,不存在则调用Editor.abort
  2. 如果传入的success = true即代表IO输出成功,则将临时缓存文件重命名为正式的缓存文件,同时更新缓存文件大小总和;如果sucess = false代表出现错误,则删除临时缓存文件
  3. 递增redundantOpCount
  4. 如果IO输出成功,想日志文件写入CLEAN行数据,否则从lruEntries中删除对应的entry,并向日志文件写入REMOVE行内容
  5. 如果缓存文件总大小超出上限或者redundantOpCount大于等于2000时,在线程池中执行cleanupCallable任务

2.5.1.1 DiskLruCache.cleanupCallable

    private final Callable<Void> cleanupCallable = new Callable<Void>() {
        public Void call() throws Exception {
            synchronized (DiskLruCache.this) {
                if (journalWriter == null) {
                    return null; // Closed.
                }
                trimToSize();
                if (journalRebuildRequired()) {
                    //【2.2.3】
                    rebuildJournal();
                    redundantOpCount = 0;
                }
            }
            return null;
        }
    };
    
    private void trimToSize() throws IOException {
        //如果缓存的文件总大小超过了maxSize, 删除缓存文件直到小于上限,并更新日志文件
        while (size > maxSize) {
            Map.Entry<String, Entry> toEvict = lruEntries.entrySet().iterator().next();
            remove(toEvict.getKey());
        }
    }
    
    private boolean journalRebuildRequired() {
        final int redundantOpCompactThreshold = 2000;
        return redundantOpCount >= redundantOpCompactThreshold //
                && redundantOpCount >= lruEntries.size();
    }

2.6 DiskLruCache.flush

   public synchronized void flush() throws IOException {
        //确保journalWriter不等于null
        checkNotClosed();
        //【2.5.1.1】, 删除缓存文件,直到总大小小于上限
        trimToSize();
        journalWriter.flush();
    }

2.7 DiskLruCache.get

    
    public synchronized Snapshot get(String key) throws IOException {
        //确保journalWriter不为null
        checkNotClosed();
        //验证key值符合规则
        validateKey(key);
        Entry entry = lruEntries.get(key);
        if (entry == null) {
            return null;
        }

        if (!entry.readable) {
            return null;
        }
        //创建缓存文件输入流
        InputStream[] ins = new InputStream[valueCount];
        try {
            for (int i = 0; i < valueCount; i++) {
                ins[i] = new FileInputStream(entry.getCleanFile(i));
            }
        } catch (FileNotFoundException e) {
            // A file must have been deleted manually!
            for (int i = 0; i < valueCount; i++) {
                if (ins[i] != null) {
                    Util.closeQuietly(ins[i]);
                } else {
                    break;
                }
            }
            return null;
        }

        redundantOpCount++;
        //更新日志文件,添加READ行
        journalWriter.append(READ + ' ' + key + '\n');
        if (journalRebuildRequired()) {
            executorService.submit(cleanupCallable);
        }

        return new Snapshot(key, entry.sequenceNumber, ins, entry.lengths);
    }
  1. 确保journalWriter不等于null, key值符合规则
  2. 创建缓存文件输入流
  3. 向日志文件中写入READ行
  4. 返回Snapshot对象

2.8 DiskLruCache.close

    public synchronized void close() throws IOException {
        if (journalWriter == null) {
            return; // Already closed.
        }
        for (Entry entry : new ArrayList<Entry>(lruEntries.values())) {
            if (entry.currentEditor != null) {
                entry.currentEditor.abort();
            }
        }
        trimToSize();
        journalWriter.close();
        journalWriter = null;
    }
  1. 遍历lruEntries,如果entry.currentEditor != null, 调用Editor.abort(abort方法实际调用DiakLruache.completeEdit【2.5.1】, 第二个参数传入false)
  2. 检查缓存总大小是否超出上限,如果超出,删除一些缓存文件直到小于上限
  3. 关闭journalWrite
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,242评论 5 459
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,769评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,484评论 0 319
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,133评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,007评论 4 355
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,080评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,496评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,190评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,464评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,549评论 2 309
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,330评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,205评论 3 312
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,567评论 3 298
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,889评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,160评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,475评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,650评论 2 335

推荐阅读更多精彩内容