Volley源码解析-一次完整的StringRequest请求(二)

本文是承接Volley源码解析之---一次完整的StringRequest请求(1)的第二篇。上一篇文章,主要介绍了NetworkDispatcher以及BasicNetwork等,如果没有读过上篇文章的建议先读上一篇文章再读这个,才能更好地连贯起来。接下来我们将继续讲解CacheDispatcherRequestQueue.add。看看他们分别都干了些什么!

CacheDispatcher

在上一篇文章中我们提到,RequestQueueStart方法中开启了CacheDispatcher。在我看来,它是用来协助缓存请求的。先来看看它的构造函数:

 /**
   * Creates a new cache triage dispatcher thread.  You must call {@link #start()}
   * in order to begin processing.
   *
   * @param cacheQueue Queue of incoming requests for triage
   * @param networkQueue Queue to post requests that require network to
   * @param cache Cache interface to use for resolution
   * @param delivery Delivery interface to use for posting responses
   */
  public CacheDispatcher(
          BlockingQueue<Request<?>> cacheQueue, BlockingQueue<Request<?>> networkQueue,
          Cache cache, ResponseDelivery delivery) {
      mCacheQueue = cacheQueue;
      mNetworkQueue = networkQueue;
      mCache = cache;
      mDelivery = delivery;
  }

可以看到他其实就是比NetworkDispatcher多了一个mCacheQueue的阻塞队列,其他三个参数的意义和NetworkDispatcher的参数含义是一样的。这里我就不重复啰嗦了。

接着我们看看它的run方法。

 @Override
  public void run() {
      if (DEBUG) VolleyLog.v("start new dispatcher");
      //设置线程的优先级
      Process.setThreadPriority(Process.THREAD_PRIORITY_BACKGROUND);

      // Make a blocking call to initialize the cache.
      //初始化缓存
      mCache.initialize();

      while (true) {
          try {
              // Get a request from the cache triage queue, blocking until
              // at least one is available.从队列里面取出请求,如果没有就阻塞
              final Request<?> request = mCacheQueue.take();
              request.addMarker("cache-queue-take");

              // If the request has been canceled, don't bother dispatching it.
              if (request.isCanceled()) {
                  //判断是否已经被取消,如果被取消了那么就直接finish掉本次请求,进行下一次请求
                  request.finish("cache-discard-canceled");
                  continue;
              }

              // Attempt to retrieve this item from cache. 根据缓存的key从缓存里面获取缓存的数据
              Cache.Entry entry = mCache.get(request.getCacheKey());
              if (entry == null) {
                  //如果缓存中没有找到key对应的数据,那么就将本次请求放入mNetworkQueue
                  request.addMarker("cache-miss");
                  // Cache miss; send off to the network dispatcher.
                  mNetworkQueue.put(request);
                  continue;
              }
              //缓存数据不为空。接下来做相应的操作
              // If it is completely expired, just send it to the network.
              if (entry.isExpired()) {
                  //判断缓存是否过期,如果过期了,同样将请求添加到mNetworkQueue但是同时给请求设置CacheEntry
                  request.addMarker("cache-hit-expired");
                  request.setCacheEntry(entry);
                  mNetworkQueue.put(request);
                  continue;
              }

              // We have a cache hit; parse its data for delivery back to the request.
              request.addMarker("cache-hit");
              //缓存命中,将缓存数据转换成Response对象
              Response<?> response = request.parseNetworkResponse(
                      new NetworkResponse(entry.data, entry.responseHeaders));
              request.addMarker("cache-hit-parsed");
              //判断缓存是否需要刷新。如果不需要刷新,直接通过mDelivery将请求结果回调给请求调用者
              if (!entry.refreshNeeded()) {
                  // Completely unexpired cache hit. Just deliver the response.
                  mDelivery.postResponse(request, response);
              } else {
                  //如果需要刷新,则先直接将请求的结果回调给请求调用者,但是同时将请求加入mNetworkQueue进行网络请求
                  // Soft-expired cache hit. We can deliver the cached response,
                  // but we need to also send the request to the network for
                  // refreshing.
                  request.addMarker("cache-hit-refresh-needed");
                  request.setCacheEntry(entry);

                  // Mark the response as intermediate.
                  response.intermediate = true;

                  // Post the intermediate response back to the user and have
                  // the delivery then forward the request along to the network.
                  mDelivery.postResponse(request, response, new Runnable() {
                      @Override
                      public void run() {
                          try {
                              mNetworkQueue.put(request);
                          } catch (InterruptedException e) {
                              // Not much we can do about this.
                          }
                      }
                  });
              }

          } catch (InterruptedException e) {
              // We may have been interrupted because it was time to quit.
              if (mQuit) {
                  return;
              }
          }
      }
  }

同样的上面我在必要的地方都添加了注释,然后我们开始讲解一个一个的重点。

  • 第一个mCache.initialize();从方法名可以知道是用来初始化什么东西的,那么这个方法都初始化了什么呢。让我们跳到方法里面看看,注默认的Cache是DiskBasedCache.
/**
    * 扫描当前所有的缓存文件,初始化DiskBasedCache,如果根目录不存在就创建根目录
    * Initializes the DiskBasedCache by scanning for all files currently in the
    * specified root directory. Creates the root directory if necessary.
    */
   @Override
   public synchronized void initialize() {
       if (!mRootDirectory.exists()) {
           if (!mRootDirectory.mkdirs()) {
               VolleyLog.e("Unable to create cache dir %s", mRootDirectory.getAbsolutePath());
           }
           return;
       }

       File[] files = mRootDirectory.listFiles();
       if (files == null) {
           return;
       }
       for (File file : files) {
           BufferedInputStream fis = null;
           try {
               fis = new BufferedInputStream(new FileInputStream(file));
               CacheHeader entry = CacheHeader.readHeader(fis);
               entry.size = file.length();
               putEntry(entry.key, entry);
           } catch (IOException e) {
               if (file != null) {
                  file.delete();
               }
           } finally {
               try {
                   if (fis != null) {
                       fis.close();
                   }
               } catch (IOException ignored) { }
           }
       }
   }

从上面的代码我们可以看到,initialize就是通过遍历缓存目录中的文件,将缓存的请求头读取到内存中即mEntries变量中,以供之后查询缓存使用。那么CacheHeader都包括了那些字段呢,一起来看看CacheHeader.readHeader(fis)方法。

  /**
        * Reads the header off of an InputStream and returns a CacheHeader object.
        * @param is The InputStream to read from.
        * @throws IOException
        */
       public static CacheHeader readHeader(InputStream is) throws IOException {
           CacheHeader entry = new CacheHeader();
           int magic = readInt(is);
           if (magic != CACHE_MAGIC) {
               // don't bother deleting, it'll get pruned eventually
               throw new IOException();
           }
           //缓存标示
           entry.key = readString(is);
           //资源的唯一标示
           entry.etag = readString(is);
           if (entry.etag.equals("")) {
               entry.etag = null;
           }
           //
           entry.serverDate = readLong(is);
           //上一次修改的时间
           entry.lastModified = readLong(is);
           //硬过期时间,缓存无效
           entry.ttl = readLong(is);
           //软过期时间,虽然过期了,但是缓存还能使用
           entry.softTtl = readLong(is);
           //响应头
           entry.responseHeaders = readStringStringMap(is);

           return entry;
       }

可以看到CacheHeader除了保存了响应头之外,还保存了ttl时间以及softTtl时间,这两个时间都是和缓存过期期限有关系的,等会我们会更详细的解释,先记住有这么一个东西,lastModified是上一次资源的时间,我们可以利用这个来判断服务器上的资源是否真的改变了。Etag是资源的唯一标示,也可以用来判断资源是否过期。关于EtaglastModified在什么情况下用来判断资源是否过期以及如何判断我们在上面一篇文章中已经有详细的说明,没有看的同学找到这篇文章看一看哦,当然如果你感兴趣的话。想要知道缓存文件是如何存储缓存数据的可以找到目录里的缓存文件看看。

就这样完成了初始化,那么继续,跟NetworkDispatcher一样首先从阻塞队列里面中取出一个请求,不过这个是``mCacheQueue里面取出Request,不是从mNetworkQueue里面取,因为这是缓存请求。同样的由于是个阻塞队列,所以如果没有请求 那么就阻塞等待。跟NetworkDispatcher`一样在处理请求之前,先判断一下请求是否被取消,如果已经被取消了,那么就finish掉整个请求,进行下一次请求。代码如下:

  // If the request has been canceled, don't bother dispatching it.
               if (request.isCanceled()) {
                   //判断是否已经被取消,如果被取消了那么就直接finish掉本次请求,进行下一次请求
                   request.finish("cache-discard-canceled");
                   continue;
               }

当获取了请求之后,根据请求的CacheKey从缓存中取数据,如果缓存命中,则使用缓存的数据,如果命中缓存失败,则将请求添加进mNetworkQueue进行网络请求。

// Attempt to retrieve this item from cache. 根据缓存的key从缓存里面获取缓存的数据
               Cache.Entry entry = mCache.get(request.getCacheKey());
               if (entry == null) {
                   //如果缓存中没有找到key对应的数据,那么就将本次请求放入mNetworkQueue
                   request.addMarker("cache-miss");
                   // Cache miss; send off to the network dispatcher.
                   mNetworkQueue.put(request);
                   continue;
               }

缓存命中(即有该请求的缓存数据),则判断缓存是否过期 entry.isExpired(),如果过期了那么就将请求加入mNetworkQueue进行网络请求。首先让我们来看看,entry.isExpired()这个方法。

      /** True if the entry is expired. */
       boolean isExpired() {
           return this.ttl < System.currentTimeMillis();
       }

原来这个方法就是判断一下,ttl小于当前时间,如果小于这说明缓存过期啦,应该重新请求新的数据了。那么ttl是哪里来的,接下来我们还会看到softTtl,那么它又是什么决定的,接下来我们一起看一下。 还记得我们这个方法Response<JSONObject> parseNetworkResponse(NetworkResponse response)吗,这是我们将NetworkResponse转换成Response的方法。因为我们是讲解StringRequest请求,所以来看看在转换的过程中都干了些什么。

    @Override
   protected Response<String> parseNetworkResponse(NetworkResponse response) {
       String parsed;
       try {
           parsed = new String(response.data, HttpHeaderParser.parseCharset(response.headers));
       } catch (UnsupportedEncodingException e) {
           parsed = new String(response.data);
       }
       return Response.success(parsed, HttpHeaderParser.parseCacheHeaders(response));
   }

注意啦。在return的时候调用了HttpHeaderParser.parseCacheHeaders(response)函数。正如其名这个就是将Response的转成CacheHeader。接下来一起来看看这个方法:

/**
    * Extracts a {@link com.android.volley.Cache.Entry} from a {@link NetworkResponse}.
    *
    * @param response The network response to parse headers from
    * @return a cache entry for the given response, or null if the response is not cacheable.
    */
   public static Cache.Entry parseCacheHeaders(NetworkResponse response) {
       long now = System.currentTimeMillis();

       Map<String, String> headers = response.headers;

       long serverDate = 0;
       long lastModified = 0;
       long serverExpires = 0;
       long softExpire = 0;
       long finalExpire = 0;
       long maxAge = 0;
       long staleWhileRevalidate = 0;
       boolean hasCacheControl = false;
       boolean mustRevalidate = false;

       String serverEtag = null;
       String headerValue;
       //获取服务器时间
       headerValue = headers.get("Date");
       if (headerValue != null) {
           serverDate = parseDateAsEpoch(headerValue);
       }
       //获取缓存控制字段
       headerValue = headers.get("Cache-Control");
       if (headerValue != null) {
           hasCacheControl = true;
           String[] tokens = headerValue.split(",");
           for (int i = 0; i < tokens.length; i++) {
               String token = tokens[i].trim();
               if (token.equals("no-cache") || token.equals("no-store")) {
                   //如果`no-cache`或者`no-store`都是控制不使用缓存直接向服务器请求,都表示则直接return null
                   return null;
               } else if (token.startsWith("max-age=")) {
                   //表示缓存在xxx秒之后过期。
                   try {
                       maxAge = Long.parseLong(token.substring(8));
                   } catch (Exception e) {
                   }
               } else if (token.startsWith("stale-while-revalidate=")) {
                   //缓存过期之后还能使用该缓存的时间额度
                   try {
                       staleWhileRevalidate = Long.parseLong(token.substring(23));
                   } catch (Exception e) {
                   }
               } else if (token.equals("must-revalidate") || token.equals("proxy-revalidate")) {
                   //必须在缓存过期之后立马重新请求数据
                   mustRevalidate = true;
               }
           }
       }
       //获取超期时间,不过这个是返回的服务器上面的时间为基准的,
       // 所以如果客户端和服务器端时间相差很大,那么就会很不标准,
       // 所以后来在Cache-Control里面添加max-age来控制,max-age优先级更高
       headerValue = headers.get("Expires");
       if (headerValue != null) {
           serverExpires = parseDateAsEpoch(headerValue);
       }
      //资源上一次修改的时间
       headerValue = headers.get("Last-Modified");
       if (headerValue != null) {
           lastModified = parseDateAsEpoch(headerValue);
       }
       //资源标示
       serverEtag = headers.get("ETag");

       // Cache-Control takes precedence over an Expires header, even if both exist and Expires
       // is more restrictive.
       //Cache-Control优先于Expires Header,所以先判断是否存在CacheControl 并且没有no-cache或no-store字段
       if (hasCacheControl) {
           //软过期时间(即虽然缓存过期了但是仍然可以使用缓存的时间范围),软超期时间等于现在的时间 + max-age * 1000
           softExpire = now + maxAge * 1000;
           //如果mustRevalidate存在,那么这个时候真正超期时间就等于软过期时间
           //不存在的话,真正超期时间 = 软过期时间 + staleWhileRevalidate * 1000
           finalExpire = mustRevalidate
                   ? softExpire
                   : softExpire + staleWhileRevalidate * 1000;
       } else if (serverDate > 0 && serverExpires >= serverDate) {
           //Expire头部在HTTP协议中就是软超期时间,所以这个时候真正超期时间 == 软超期时间
           // Default semantic for Expire header in HTTP specification is softExpire.
           softExpire = now + (serverExpires - serverDate);
           finalExpire = softExpire;
       }

       Cache.Entry entry = new Cache.Entry();
       entry.data = response.data;
       entry.etag = serverEtag;
       entry.softTtl = softExpire;
       entry.ttl = finalExpire;
       entry.serverDate = serverDate;
       entry.lastModified = lastModified;
       entry.responseHeaders = headers;

       return entry;
   }

从上面这个方法我们可以看到缓存中每个字段是如何计算得到的,尤其是软超期时间和真正超期的时间。我总结一下:

  • 当Response中有Cache-Control并且没有no-cache以及no-store字段时,软过期时间等于当前时间+max-age * 1000 .如果存在must-revalidate或者proxy-revalidate时,则真正过期时间等于软过期时间 + stale-while-revalidate
  • 如果CacheControl 不存在,则软过期时间 == 真正过期时间 == softExpire = now + (serverExpires - serverDate)
  • 不过值得注意的是,Expire的日期是根据服务器的时间来定的,如果服务器和客户端的时间相差很大的话那么时间就不一致了。
    而且我们可以知道ttl是最终过期时间,softTtl是软过期时间。所以entry.isExpired()true则说明缓存过期了,则需要重新请求网络,所以直接添加到mNetworkQueue里面进行网络请求。有些同学可能觉得奇怪既然都要重新请求了为什么还要把缓存中的entry添加到reqeust这个对象呢,执行request.setCacheEntry(entry);呢,在上一篇我们提到过虽然缓存过期了,但是并不代表服务器上的资源真的改变了,所以这个时候将上一次的LastModified以及etag传递过去,可以用于服务器验证资源是否过期的校验。接下来,当缓存没有真正过期,则从缓存中拿出上一次响应的数据,然后转换成Response对象。但是现在还不能吧结果返回给请求调用者,还需要判断一下,接着看entry.refreshNeeded();,从方法上来看,是判断是否需要更新,具体看看是如何判断的
 /** True if a refresh is needed from the original data source. */
        boolean refreshNeeded() {
            return this.softTtl < System.currentTimeMillis();
        }

可以看到这个方法是根据软过期时间(softTtl)来判断是否需要刷新,上面我们提到过,如果超过了软过期时间,虽然缓存还是可以用的,但是需要同时请求服务器获取新的数据,所以接下来就是根据是否需要刷新做不同的处理,如果不需要刷新那么就直接将Response回调给请求调用者,即StringRequestListener里面。同样的 request.setCacheEntry(entry)便于服务器验证,减少不必要的数据请求。
至此,我们把CacheDispatcherrun方法给理清楚了。
接下来,我们还剩下一个分析点没有分析了。那就是RequestQueue.add 对于这个方法,我自己一开始也有几个疑问:

  1. add之后做了什么
  2. request添加进去之后是不是通过CatchDispatcher来处理的。

直接看代码,才能找到答案,所以接下来我们一起来看看RequestQueue.add都做了什么吧。

RequestQueue

首先贴上add方法的源码:

   /**
     * Adds a Request to the dispatch queue.
     * @param request The request to service
     * @return The passed-in request
     */
    public <T> Request<T> add(Request<T> request) {
        // Tag the request as belonging to this queue and add it to the set of current requests.
        //设置这个请求属于这个ReqeustQueue,并将这个请求添加到这个请求队列的当前请求队列中
        //mCurrentRequests 是存放当前所有的请求的一个集合
        request.setRequestQueue(this);
        synchronized (mCurrentRequests) {
            mCurrentRequests.add(request);
        }

        // Process requests in the order they are added.
        //给request设置序号
        request.setSequence(getSequenceNumber());
        request.addMarker("add-to-queue");

        // If the request is uncacheable, skip the cache queue and go straight to the network.
        //判断是否可以缓存,如果不可以,就直接将请求添加进mNetworkQueue进行网络请求,默认都是true,当然你可以设成false
        if (!request.shouldCache()) {
            mNetworkQueue.add(request);
            return request;
        }

        // Insert request into stage if there's already a request with the same cache key in flight.
        //将请求插入Map,如果这里已经相同的cachekey的请求正在请求中
        synchronized (mWaitingRequests) {
            //mWaitingRequest 是存放同一个cacheKey请求的多个请求。
            String cacheKey = request.getCacheKey(); //获取请求的cacheKey
            if (mWaitingRequests.containsKey(cacheKey)) {
                //已经添加进了队列,那么将request添加到 队列中
                // There is already a request in flight. Queue up.
                Queue<Request<?>> stagedRequests = mWaitingRequests.get(cacheKey);
                if (stagedRequests == null) {
                    stagedRequests = new LinkedList<>();
                }
                stagedRequests.add(request);
                mWaitingRequests.put(cacheKey, stagedRequests);
                if (VolleyLog.DEBUG) {
                    VolleyLog.v("Request for cacheKey=%s is in flight, putting on hold.", cacheKey);
                }
            } else {
                // Insert 'null' queue for this cacheKey, indicating there is now a request in
                // flight.
                //mWaitingRequests里面还不存在,cacheKey的请求,则将cacheKey放入mWaitingRequest并且将当前请求加入mCacheQueue中
                mWaitingRequests.put(cacheKey, null);
                mCacheQueue.add(request);
            }
            return request;
        }
    }

上面我已经添加了对应的注释,首先我们将Request与当前RequestQueue关联起来,然后将reqeust加入mCurrentRequests里面 我们可以看到因为每一个请求都会加进去,所以这个集合就是记录了所有的请求。然后根据request是否可以缓存,如果不可缓存,那么直接添加到NetworkQueue进行网络请求,否则继续往下。 可以缓存,那么先获取到CacheKey,然后判断mWaitingRequests中是否存在CacheKey.等等这个mWaitingRequests又是什么东东。在我看来,mWaitingRequests就是存储相同Cachekey的Request,这样可以避免同一种请求同时被添加进mCacheQueue中,可以在第一个请求结束之后再添加进cacheQueue队列中,这样之后的请求就可以直接从缓存中取,即快速又减少了网络的重复访问。这样是不是很好。当然如果现在mWaitingRequest里面不包括当前请求CacheKey那么就添加进mWaitingRequest,并且将请求添加到mCacheQueue进行缓存请求。 其实Add方法很简单,就是根据 Request.shouldCache判断应该进行网络请求还是缓存请求。那么请求添加进去了。你好不好奇,那些重复的Request什么时候加入CacheQueue或者怎么处理,那么就应该一起来看看RequestQueue.finish方法了。

    /**
     * Called from {@link Request#finish(String)}, indicating that processing of the given request
     * has finished.
     *
     * <p>Releases waiting requests for <code>request.getCacheKey()</code> if
     *      <code>request.shouldCache()</code>.</p>
     */
    <T> void finish(Request<T> request) {
        // Remove from the set of requests currently being processed.
        //从当前请求集合里面移除要finish掉的请求
        synchronized (mCurrentRequests) {
            mCurrentRequests.remove(request);
        }
        //回调finish给Listener
        synchronized (mFinishedListeners) {
          for (RequestFinishedListener<T> listener : mFinishedListeners) {
            listener.onRequestFinished(request);
          }
        }

        //判断request是否shouldCache
        if (request.shouldCache()) {
            synchronized (mWaitingRequests) {
                String cacheKey = request.getCacheKey();
                //根据cacheKey 从mWaitingRequests中移除,同时将返回的相同cachekey的请求放入mCacheQueue中,
                // 这样只要缓存没过期就可以从缓存中取数据
                Queue<Request<?>> waitingRequests = mWaitingRequests.remove(cacheKey);
                if (waitingRequests != null) {
                    if (VolleyLog.DEBUG) {
                        VolleyLog.v("Releasing %d waiting requests for cacheKey=%s.",
                                waitingRequests.size(), cacheKey);
                    }
                    // Process all queued up requests. They won't be considered as in flight, but
                    // that's not a problem as the cache has been primed by 'request'.
                    //从英文就可以知道,因为当前request已经请求过了 所以接下来的请求可以从缓存中拿响应
                    mCacheQueue.addAll(waitingRequests);
                }
            }
        }
    }

看到了吧,相同的CacheKey的请求就是在这里处理的哦。 终于终于把一次完整的StringRequest请求给讲清楚了。在写的过程中,我自己也对这个每一个点越来越理解,所以有时候如果你学习了一个新东西,尽管网上有很多的资料了,但是你自己写一下总结的文章,在写的过程中,潜移默化中你掌握的更深本来以前不理解的地方也更加清晰。所以鼓励大家都写起来,不为其他,为了让自己真正掌握,俗话说好记性不如烂笔头。
哈哈,扯得多了点,但是还没完呢,最后一个流程图,给我和你们一起缕缕整个过程。见流程图。

整体流程图

TIM图片20170807193054.png

CacheDispatcher

CacheDispatcher.png

NetworkDispatcher

NetworkDipatcher.png

好了,各位Volley源码的分析就到此结束了。

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

推荐阅读更多精彩内容