Android Glide源码剖析系列(四)缓存机制及其原理


Glide源码剖析系列


为什么选择Glide?

  • 多种图片格式的缓存,适用于更多的内容表现形式(如Gif、WebP、缩略图、Video)
  • 生命周期集成(根据Activity或者Fragment的生命周期管理图片加载请求)Glide可以感知调用页面的生命周期,这就是优势
  • 高效处理Bitmap(bitmap的复用和主动回收,减少系统回收压力)
  • 高效的缓存策略,灵活(Picasso只会缓存原始尺寸的图片,Glide缓存的是多种规格),加载速度快且内存开销小(默认Bitmap格式的不同,使得内存开销是Picasso的一半)

小结:支持图片格式多;Bitmap复用和主动回收;生命周期感应;优秀的缓存策略;加载速度快(Bitmap默认格式RGB565)

Glide简单使用

Glide.with(this)
        .load("https://t7.baidu.com/it/u=3779234486,1094031034&fm=193&f=GIF")
        .error(R.drawable.aaa)
        .placeholder(R.drawable.ic_android_black_24dp)
        .fallback(R.drawable.aaa)
        .diskCacheStrategy(DiskCacheStrategy.ALL)
        .skipMemoryCache(false)
        .into(imageView);

阅读这篇文章之前,如果对图片加载流程不熟悉,强烈建议先阅读并理解Android Glide源码解析系列(三)深入理解Glide图片加载流程,因为Glide图片缓存的读取和保存都是在加载过程中完成的。

上文提到过,Glide在加载图片的时候,有对图片进行缓存处理,分别是内存缓存和磁盘缓存。这两级缓存的作用各不相同,内存缓存的主要作用是防止应用重复将图片数据读取到内存当中,而硬盘缓存的主要作用是防止应用从网络或其他地方重复获取图片资源。

缓存key

Glide图片缓存的读取和保存都需要用key标识,通过上篇文章我们知道key在Engine#load()方法中创建:

  public <R> LoadStatus load(
      GlideContext glideContext,
      Object model, Key signature, int width, int height, Class<?> resourceClass, Class<R> transcodeClass,
      Priority priority, DiskCacheStrategy diskCacheStrategy,
      Map<Class<?>, Transformation<?>> transformations,
      boolean isTransformationRequired,
      boolean isScaleOnlyOrNoTransform,
      Options options,
      boolean isMemoryCacheable,
      boolean useUnlimitedSourceExecutorPool,
      boolean useAnimationPool,
      boolean onlyRetrieveFromCache,
      ResourceCallback cb,
      Executor callbackExecutor) {
    long startTime = VERBOSE_IS_LOGGABLE ? LogTime.getLogTime() : 0;

    EngineKey key =
        keyFactory.buildKey(
            model,  //图片地址
            signature,  //签名信息
            width, height, //图片宽
            transformations,
            resourceClass,
            transcodeClass,
            options);  //配置信息

    ……

    return null;
  }

使用EngineKeyFactory构建EngineKey对象,这就是最终用于图片缓存的key。我们可以看到key的值由model、signature、width、height等多个参数共同决定,所以改变任何一个参数都会产生一个新的缓存key。

准备好了key,就可以开始缓存工作了。

内存缓存

内存缓存就是把已经加载到内存中的图片缓存起来,当下次需要展示这张的时候直接从内存中读取,这样不仅可以减少重新获取图片(例如网络下载、从磁盘读取)造成的资源浪费,也避免重复将图片载入到内存中。这样就大大提高了图片加载的速度。

例如在列表中加载大量网络图片时,已经加载过一次的图片可以直接从内存中读取并展示出来,这样就大大提升了App性能,用户使用起来当然更加顺滑。

Glide默认为我们开启了内存缓存,虽然大部分场景下开启内存缓存是更好的选择,但是如果在特殊情况下不需要开启内存缓存,Glide也为我们提供了接口来关闭内存缓存功能。

Glide.with(this)
        .load("https://t7.baidu.com/it/u=3779234486,1094031034&fm=193&f=GIF")
        .skipMemoryCache(true)  //true表示关闭内存缓存
        .into(imageView);

Glide内存缓存的实现采取的是方案是:LruCache算法+弱引用机制。

LruCache算法(Least Recently Used):也叫近期最少使用算法。它的主要算法原理就是把最近使用的对象强引用存储在LinkedHashMap中,并且把最近最少使用的对象在缓存值达到阈值之前从内存中移除。

关于LruCache算法可以参考Android 缓存策略之LruCache

除了使用LruCache算法缓存图片之外,Glide还会将正在使用的图片对象弱引用保存到HashMap中,具体实现类是ActiveResources,后面会介绍这个类。

继续分析内存缓存,Glide读取内存缓存入口同样是在Engine#load()方法中:

  public <R> LoadStatus load(
      GlideContext glideContext,
      Object model,
      Key signature,
      int width,
      int height,
      Class<?> resourceClass,
      Class<R> transcodeClass,
      Priority priority,
      DiskCacheStrategy diskCacheStrategy,
      Map<Class<?>, Transformation<?>> transformations,
      boolean isTransformationRequired,
      boolean isScaleOnlyOrNoTransform,
      Options options,
      boolean isMemoryCacheable,
      boolean useUnlimitedSourceExecutorPool,
      boolean useAnimationPool,
      boolean onlyRetrieveFromCache,
      ResourceCallback cb,
      Executor callbackExecutor) {
    long startTime = VERBOSE_IS_LOGGABLE ? LogTime.getLogTime() : 0;

    EngineKey key =
        keyFactory.buildKey(model, signature, width, height, transformations, resourceClass, transcodeClass, options);

    EngineResource<?> memoryResource;
    synchronized (this) {
      memoryResource = loadFromMemory(key, isMemoryCacheable, startTime);  //从内存中读取资源

      if (memoryResource == null) {
        return waitForExistingOrStartNewJob(
            glideContext,
            model,
            signature,
            width,
            height,
            resourceClass,
            transcodeClass,
            priority,
            diskCacheStrategy,
            transformations,
            isTransformationRequired,
            isScaleOnlyOrNoTransform,
            options,
            isMemoryCacheable,
            useUnlimitedSourceExecutorPool,
            useAnimationPool,
            onlyRetrieveFromCache,
            cb,
            callbackExecutor,
            key,
            startTime);
      }
    }

    // Avoid calling back while holding the engine lock, doing so makes it easier for callers to
    // deadlock.
    cb.onResourceReady(
        memoryResource, DataSource.MEMORY_CACHE, /* isLoadedFromAlternateCacheKey= */ false);
    return null;
  }

通过这段代码可以得出两个结论

  • 当Glide加载图片时,首先会从内存缓存中去查找,如果内存中有符合条件的图片缓存,就不需要重新把图片加载进内存中了。
  • 内存缓存的优先级是高于磁盘缓存的。

继续分析loadFromMemory(key, isMemoryCacheable, startTime)方法:

  private EngineResource<?> loadFromMemory(
      EngineKey key, boolean isMemoryCacheable, long startTime) {
    if (!isMemoryCacheable) {  //1
      return null;
    }

    EngineResource<?> active = loadFromActiveResources(key);  //2
    if (active != null) {
      if (VERBOSE_IS_LOGGABLE) {
        logWithTimeAndKey("Loaded resource from active resources", startTime, key);
      }
      return active;
    }

    EngineResource<?> cached = loadFromCache(key);  //3
    if (cached != null) {
      if (VERBOSE_IS_LOGGABLE) {
        logWithTimeAndKey("Loaded resource from cache", startTime, key);
      }
      return cached;
    }

    return null;
  }

  @Nullable
  private EngineResource<?> loadFromActiveResources(Key key) {
    EngineResource<?> active = activeResources.get(key);
    if (active != null) {
      active.acquire();
    }

    return active;
  }

  private EngineResource<?> loadFromCache(Key key) {
    EngineResource<?> cached = getEngineResourceFromCache(key);
    if (cached != null) {
      cached.acquire();
      activeResources.activate(key, cached);
    }
    return cached;
  }

  private EngineResource<?> getEngineResourceFromCache(Key key) {
    Resource<?> cached = cache.remove(key);

    final EngineResource<?> result;
    if (cached == null) {
      result = null;
    } else if (cached instanceof EngineResource) {
      // Save an object allocation if we've cached an EngineResource (the typical case).
      result = (EngineResource<?>) cached;
    } else {
      result =
          new EngineResource<>(
              cached, /*isMemoryCacheable=*/ true, /*isRecyclable=*/ true, key, /*listener=*/ this);
    }
    return result;
  }
  1. 判断是否开启了内存缓存功能,默认开启,可以在RequestOption中关闭内存缓存
  2. 正在使用的图片缓存activeResources中查找图片资源,找到直接返回,否则继续在内存缓存中查找
  3. 如果内存缓存中查找到图片资源,将图片从cache中移除,并且添加到正在使用的图片缓存activeResources

我们知道内存缓存使用的数据结构是LinkedHashMap,那么正在使用的图片缓存是如何保存的?答案或许就藏在ActiveResources类。

final class ActiveResources {
  @VisibleForTesting final Map<Key, ResourceWeakReference> activeEngineResources = new HashMap<>();

  synchronized void activate(Key key, EngineResource<?> resource) {
    ResourceWeakReference toPut =
        new ResourceWeakReference(
            key, resource, resourceReferenceQueue, isActiveResourceRetentionAllowed);

    ResourceWeakReference removed = activeEngineResources.put(key, toPut);
    if (removed != null) {
      removed.reset();
    }
  }

  @Nullable
  synchronized EngineResource<?> get(Key key) {
    ResourceWeakReference activeRef = activeEngineResources.get(key);
    if (activeRef == null) {
      return null;
    }

    EngineResource<?> active = activeRef.get();
    if (active == null) {
      cleanupActiveReference(activeRef);
    }
    return active;
  }
}

把正在使用的图片对象的弱引用添加到HashMap:activeEngineResources中,使用弱引用是因为持有图片对象的Activity或Fragment有可能会被销毁,这样做可以及时清除缓存并释放内存,防止内存泄漏。

内存缓存功能小结:

  • 读取缓存的顺序是:正在使用的图片缓存 > 内存缓存 > 磁盘缓存
  • 缓存正在使用的图片采取HashMap+弱引用,而内存缓存使用LinkedHashMap

说了这么多好像都是内存缓存的读取过程,那么内存缓存是在哪里保存的呢?上篇文章分析图片加载流程的时候,我们知道DecodeJob在成功获取到图片资源后,会调用notifyEncodeAndRelease()方法处理资源,其中有一项工作就是把图片写入到缓存中。

#DecodeJob.java
  private void notifyEncodeAndRelease(
      Resource<R> resource, DataSource dataSource, boolean isLoadedFromAlternateCacheKey) {
    GlideTrace.beginSection("DecodeJob.notifyEncodeAndRelease");
    try {
      if (resource instanceof Initializable) {
        ((Initializable) resource).initialize();  //准备显示资源
      }

      Resource<R> result = resource;

      notifyComplete(result, dataSource, isLoadedFromAlternateCacheKey);  //资源获取成功

  }

继续分析notifyComplete()方法

  private void notifyComplete(
      Resource<R> resource, DataSource dataSource, boolean isLoadedFromAlternateCacheKey) {
    setNotifiedOrThrow();
    callback.onResourceReady(resource, dataSource, isLoadedFromAlternateCacheKey);
  }

callback.onResourceReady()方法在EngineJob中实现。继续追踪代码:

#EngineJob.java
  @Override
  public void onResourceReady(
      Resource<R> resource, DataSource dataSource, boolean isLoadedFromAlternateCacheKey) {
    synchronized (this) {
      this.resource = resource;
      this.dataSource = dataSource;
      this.isLoadedFromAlternateCacheKey = isLoadedFromAlternateCacheKey;
    }
    notifyCallbacksOfResult();
  }

  void notifyCallbacksOfResult() {
    ResourceCallbacksAndExecutors copy;
    Key localKey;
    EngineResource<?> localResource;
    synchronized (this) {
      //我们省略了异常情况的处理代码和一些注释代码

      engineResource = engineResourceFactory.build(resource, isCacheable, key, resourceListener); //1
     
      hasResource = true;
      copy = cbs.copy();  //2
      incrementPendingCallbacks(copy.size() + 1);

      localKey = key;
      localResource = engineResource;
    }

    engineJobListener.onEngineJobComplete(this, localKey, localResource);  //3

    for (final ResourceCallbackAndExecutor entry : copy) {
      entry.executor.execute(new CallResourceReady(entry.cb));  //4
    }
    decrementPendingCallbacks();
  }
  1. engineResource 是资源的一个包装类,负责计算资源被引用的次数,次数为0的时候可以回收资源
  2. copy内部包装的是Executors.mainThreadExecutor()主线程池,方便切换到主线程
  3. EngineJob执行完毕,把获取的图片加入activeResources并且从EngineJob缓存列表移除当前job
  @Override
  public synchronized void onEngineJobComplete(
      EngineJob<?> engineJob, Key key, EngineResource<?> resource) {
    // A null resource indicates that the load failed, usually due to an exception.
    if (resource != null && resource.isMemoryCacheable()) {
      activeResources.activate(key, resource);
    }

    jobs.removeIfCurrent(key, engineJob);
  }
  1. 把任务切换到主线程执行,也就是说之前的操作都是在子线程中处理
    @Override
    public void run() {
      // Make sure we always acquire the request lock, then the EngineJob lock to avoid deadlock
      // (b/136032534).
      synchronized (cb.getLock()) {
        synchronized (EngineJob.this) {
          if (cbs.contains(cb)) {
            // Acquire for this particular callback.
            engineResource.acquire();  //增加资源引用次数
            callCallbackOnResourceReady(cb); 
            removeCallback(cb);
          }
          decrementPendingCallbacks();
        }
      }
    }

  @GuardedBy("this")
  void callCallbackOnResourceReady(ResourceCallback cb) {
    try {
      // This is overly broad, some Glide code is actually called here, but it's much
      // simpler to encapsulate here than to do so at the actual call point in the
      // Request implementation.
      cb.onResourceReady(engineResource, dataSource, isLoadedFromAlternateCacheKey);  //在SingleRequest中实现
    } catch (Throwable t) {
      throw new CallbackException(t);
    }
  }

图片准备完毕,cb.onResourceReady()回调方法在SingleRequest中实现

#SingleRequest.java
  @Override
  public void onResourceReady(
      Resource<?> resource, DataSource dataSource, boolean isLoadedFromAlternateCacheKey) {
    stateVerifier.throwIfRecycled();
    Resource<?> toRelease = null;
    try {
      synchronized (requestLock) {
        loadStatus = null;
        if (resource == null) {
          GlideException exception =
              new GlideException(
                  "Expected to receive a Resource<R> with an "
                      + "object of "
                      + transcodeClass
                      + " inside, but instead got null.");
          onLoadFailed(exception);
          return;
        }

        Object received = resource.get();
        if (received == null || !transcodeClass.isAssignableFrom(received.getClass())) {
          toRelease = resource;
          this.resource = null;
          GlideException exception =
              new GlideException(
                  "Expected to receive an object of "
                      + transcodeClass
                      + " but instead"
                      + " got "
                      + (received != null ? received.getClass() : "")
                      + "{"
                      + received
                      + "} inside"
                      + " "
                      + "Resource{"
                      + resource
                      + "}."
                      + (received != null
                          ? ""
                          : " "
                              + "To indicate failure return a null Resource "
                              + "object, rather than a Resource object containing null data."));
          onLoadFailed(exception);
          return;
        }

        if (!canSetResource()) {
          toRelease = resource;
          this.resource = null;
          // We can't put the status to complete before asking canSetResource().
          status = Status.COMPLETE;
          GlideTrace.endSectionAsync(TAG, cookie);
          return;
        }

        onResourceReady(
            (Resource<R>) resource, (R) received, dataSource, isLoadedFromAlternateCacheKey);
      }
    } finally {
      if (toRelease != null) {
        engine.release(toRelease);
      }
    }
  }

finally语句中,如果图片资源被标记为需要释放,会调用engine.release(toRelease)释放图片资源

#Engine.java
  public void release(Resource<?> resource) {
    if (resource instanceof EngineResource) {
      ((EngineResource<?>) resource).release();
    } else {
      throw new IllegalArgumentException("Cannot release anything but an EngineResource");
    }
  }

调用EngineResource#release()方法

  void release() {
    boolean release = false;
    synchronized (this) {
      if (acquired <= 0) {
        throw new IllegalStateException("Cannot release a recycled or not yet acquired resource");
      }
      if (--acquired == 0) {
        release = true;
      }
    }
    if (release) {
      listener.onResourceReleased(key, this);
    }
  }

acquired变量用于计算图片资源被引用的次数,acquired==0表示该图片资源可以被回收,在Engine的onResourceReleased()方法中实现资源回收

#Engine.java
  @Override
  public void onResourceReleased(Key cacheKey, EngineResource<?> resource) {
    activeResources.deactivate(cacheKey);
    if (resource.isMemoryCacheable()) {
      cache.put(cacheKey, resource);
    } else {
      resourceRecycler.recycle(resource, /*forceNextFrame=*/ false);
    }
  }

首先从activeResources中移除图片缓存,同时将图片添加到内存缓存中。

内存缓存的读取与添加原理已经分析完毕,概括起来就是:

正在使用的图片资源以弱引用的方式缓存到activeResources中,不在使用中的图片以强引用的方式缓存到LruCache中。
加载图片时首先在activeResources中查找资源,然后才在LruCache缓存中查找。

磁盘缓存

磁盘缓存是将图片存储到设备本地存储空间,下次使用图片可以直接从本地读取缓存文件,这样可以防止应用从网络上重复下载和读取图片。

Glide同样为我们提供了磁盘缓存的配置接口

Glide.with(this)
        .load("https://t7.baidu.com/it/u=3779234486,1094031034&fm=193&f=GIF")
        .diskCacheStrategy(DiskCacheStrategy.NONE)
        .into(imageView);

Glide支持多种磁盘缓存策略:

  • NONE:禁用磁盘缓存
  • DATA:缓存decode操作之前图片资源
  • RESOURCE:缓存decode之后的图片资源
  • AUTOMATIC:根据DataFetcher和EncodeStrategy自动调整缓存策略
  • ALL:远程图片同时支持RESOURCE和DATA,本地图片只支持RESOURCE

通过上篇文章我们知道,如果在内存缓存中没有找到图片,就会开启一个新线程DecodeJob从磁盘缓存读取图片,磁盘缓存读取失败才真正开始加载model指定的图片。

#DataCacheGenerator.java
  @Override
  public boolean startNext() {
    if (dataToCache != null) {
      Object data = dataToCache;
      dataToCache = null;
      try {
        boolean isDataInCache = cacheData(data); //添加磁盘缓存
        if (!isDataInCache) {
          return true;
        }
      } catch (IOException e) {
        if (Log.isLoggable(TAG, Log.DEBUG)) {
          Log.d(TAG, "Failed to properly rewind or write data to cache", e);
        }
      }
    }

    if (sourceCacheGenerator != null && sourceCacheGenerator.startNext()) {
      return true;
    }
    sourceCacheGenerator = null;

    loadData = null;
    boolean started = false;
    while (!started && hasNextModelLoader()) {
      //加载model指定的图片
    }
    return started;
  }

加载图片成功之后需要将图片加入到磁盘缓存中,dataToCache 表示的是待缓存的图片资源,调用cacheData()方法把图片添加到磁盘缓存:

  private boolean cacheData(Object dataToCache) throws IOException {
    long startTime = LogTime.getLogTime();
    boolean isLoadingFromSourceData = false;
    try {
      DataRewinder<Object> rewinder = helper.getRewinder(dataToCache);
      Object data = rewinder.rewindAndGet();
      Encoder<Object> encoder = helper.getSourceEncoder(data);
      DataCacheWriter<Object> writer = new DataCacheWriter<>(encoder, data, helper.getOptions());
      DataCacheKey newOriginalKey = new DataCacheKey(loadData.sourceKey, helper.getSignature());
      DiskCache diskCache = helper.getDiskCache();  //1
      diskCache.put(newOriginalKey, writer);
      if (Log.isLoggable(TAG, Log.VERBOSE)) {
        Log.v(
            TAG,
            "Finished encoding source to cache"
                + ", key: "
                + newOriginalKey
                + ", data: "
                + dataToCache
                + ", encoder: "
                + encoder
                + ", duration: "
                + LogTime.getElapsedMillis(startTime));
      }

      if (diskCache.get(newOriginalKey) != null) {  //2
        originalKey = newOriginalKey;
        sourceCacheGenerator =
            new DataCacheGenerator(Collections.singletonList(loadData.sourceKey), helper, this);
        // We were able to write the data to cache.
        return true;
      } else {
        if (Log.isLoggable(TAG, Log.DEBUG)) {
          Log.d(
              TAG,
              "Attempt to write: "
                  + originalKey
                  + ", data: "
                  + dataToCache
                  + " to the disk"
                  + " cache failed, maybe the disk cache is disabled?"
                  + " Trying to decode the data directly...");
        }

        isLoadingFromSourceData = true;
        cb.onDataFetcherReady(
            loadData.sourceKey,
            rewinder.rewindAndGet(),
            loadData.fetcher,
            loadData.fetcher.getDataSource(),
            loadData.sourceKey);
      }
      // We failed to write the data to cache.
      return false;
    } finally {
      if (!isLoadingFromSourceData) {
        loadData.fetcher.cleanup();
      }
    }
  }
  1. 磁盘缓存是通过DiskLruCache来实现的,所以通过DecodeHelper获取DiskLruCache并把图片put进去
  2. diskCache.get(newOriginalKey):在磁盘缓存中读取newOriginalKey对应的图片:如果读取成功说明图片成功缓存到磁盘,初始化DataCacheGenerator(为读取磁盘缓存埋下伏笔)并且返回true;如果读取失败说明图片没有缓存到磁盘中,直接解析图片资源并且返回false

磁盘缓存的添加过程为磁盘缓存的读取埋下了一个重要的伏笔,也就是DataCacheGenerator。我们重新回到startNext()方法:

#DataCacheGenerator.java
  @Override
  public boolean startNext() {
    if (dataToCache != null) {
      Object data = dataToCache;
      dataToCache = null;
      try {
        boolean isDataInCache = cacheData(data); //添加磁盘缓存
        if (!isDataInCache) {
          return true;
        }
      } catch (IOException e) {
        if (Log.isLoggable(TAG, Log.DEBUG)) {
          Log.d(TAG, "Failed to properly rewind or write data to cache", e);
        }
      }
    }

    if (sourceCacheGenerator != null && sourceCacheGenerator.startNext()) {  //注释1
      return true;
    }
    sourceCacheGenerator = null;

    loadData = null;
    boolean started = false;
    while (!started && hasNextModelLoader()) {
      //注释2:加载model指定的图片
      loadData = helper.getLoadData().get(loadDataListIndex++);
      if (loadData != null
          && (helper.getDiskCacheStrategy().isDataCacheable(loadData.fetcher.getDataSource())
              || helper.hasLoadPath(loadData.fetcher.getDataClass()))) {
        started = true;
        startNextLoad(loadData);
      }
    }
    return started;
  }

图片成功添加到磁盘缓存,那么cacheData(data)方法返回true,并且sourceCacheGenerator初始化成功,执行注释1处代码:

#DataCacheGenerator.java
  @Override
  public boolean startNext() {
    GlideTrace.beginSection("DataCacheGenerator.startNext");
    try {
      while (modelLoaders == null || !hasNextModelLoader()) {
        sourceIdIndex++;
        if (sourceIdIndex >= cacheKeys.size()) {
          return false;
        }
        //注释3
        Key sourceId = cacheKeys.get(sourceIdIndex);
        @SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops")
        Key originalKey = new DataCacheKey(sourceId, helper.getSignature()); 
        cacheFile = helper.getDiskCache().get(originalKey);  
        if (cacheFile != null) {
          this.sourceKey = sourceId;
          modelLoaders = helper.getModelLoaders(cacheFile);
          modelLoaderIndex = 0;
        }
      }

      loadData = null;
      boolean started = false;
      //加载缓存图片
      while (!started && hasNextModelLoader()) {
        ModelLoader<File, ?> modelLoader = modelLoaders.get(modelLoaderIndex++);
        loadData =
            modelLoader.buildLoadData(
                cacheFile, helper.getWidth(), helper.getHeight(), helper.getOptions());
        if (loadData != null && helper.hasLoadPath(loadData.fetcher.getDataClass())) {
          started = true;
          loadData.fetcher.loadData(helper.getPriority(), this);
        }
      }
      return started;
    } finally {
      GlideTrace.endSection();
    }
  }

注释3:创建缓存key在DiskLruCache中查找缓存图片,如果找到了缓存图片就直接进入到缓存图片的加载流程;否则回到注释2处加载model指定的图片

磁盘缓存的添加和读取过程分析完毕。最后附上一张流程图:


最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,271评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,275评论 2 380
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,151评论 0 336
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,550评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,553评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,559评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,924评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,580评论 0 257
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,826评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,578评论 2 320
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,661评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,363评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,940评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,926评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,156评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,872评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,391评论 2 342

推荐阅读更多精彩内容