[譯]使用MVI打造響應(yīng)式APP(三):狀態(tài)折疊器

原文:REACTIVE APPS WITH MODEL-VIEW-INTENT - PART3 - STATE REDUCER
作者:Hannes Dorfmann
譯者:卻把清梅嗅

上一章節(jié)中,我們針對 如何使用單向流和 Model-View-Intent 模式構(gòu)建一個(gè)簡單的頁面 進(jìn)行了探討辩块;本章節(jié)澡屡,我們將在reducer的幫助下實(shí)現(xiàn)MVI模式中更加復(fù)雜的頁面。

如果你還未閱讀前兩個(gè)章節(jié),閱讀本文之前您應(yīng)該先去閱讀它們,從而對如下兩個(gè)問題的答案有初步的了解:

  • 1.我們?nèi)绾瓮ㄟ^PresenterView層和業(yè)務(wù)邏輯相關(guān)聯(lián)?
  • 2.數(shù)據(jù)流是如何保證單向性的区拳?

如下圖所示,現(xiàn)在我們構(gòu)建這樣一個(gè)復(fù)雜的頁面:

image

如你所見意乓,屏幕中顯示的是按照類別進(jìn)行歸類的商品列表樱调;App每次只會為每個(gè)分類展示3個(gè)條目,當(dāng)用戶點(diǎn)擊了 加載更多 按鈕時(shí)届良,將會通過網(wǎng)絡(luò)請求去加載該分類下所有的條目啊奄。

此外罪裹,用戶還可以執(zhí)行 下拉刷新 的操作,并且一旦用戶向下滾動(dòng)到列表末尾,分頁功能就會繼續(xù)加載下一頁的數(shù)據(jù)——當(dāng)然械蹋,所有這些行為可以同時(shí)執(zhí)行扯再,并且每個(gè)行為都可能會收到失敵旨省(即沒有互聯(lián)網(wǎng)連接)开镣。

讓我們一步步來,首先荚藻,我們先對View層的接口進(jìn)行實(shí)現(xiàn):

public interface HomeView {

  /**
   * 加載第一頁數(shù)據(jù)的intent
   *
   * @return 發(fā)射的數(shù)據(jù)是沒有意義的屋灌,true或者false沒有區(qū)別
   */
  public Observable<Boolean> loadFirstPageIntent();

  /**
   * 分頁加載下一頁的intent
   *
   * @return 發(fā)射的數(shù)據(jù)是沒有意義的,true或者false沒有區(qū)別
   */
  public Observable<Boolean> loadNextPageIntent();

  /**
   * 對下拉刷新的響應(yīng)intent
   *
   * @return 發(fā)射的數(shù)據(jù)是沒有意義的应狱,true或者false沒有區(qū)別
   */
  public Observable<Boolean> pullToRefreshIntent();

  /**
   * 根據(jù)當(dāng)前分類加載所有條目的intent
   *
   * @return 指定分類共郭,String代表分類的名字
   */
  public Observable<String> loadAllProductsFromCategoryIntent();

  /**
   * 對ViewState進(jìn)行渲染
   */
  public void render(HomeViewState viewState);
}

View層具體的實(shí)現(xiàn)簡單明了,本文將不進(jìn)行展示(但你可以在Github上找到它)侦香。

接下來讓我們把目光轉(zhuǎn)向Model落塑,正如前文所提到的,Model應(yīng)該反應(yīng)了狀態(tài)罐韩,現(xiàn)在我來介紹一下Model的具體實(shí)現(xiàn):HomeViewState憾赁。

public final class HomeViewState {

  private final boolean loadingFirstPage; // RecyclerView加載狀態(tài)的指示器
  private final Throwable firstPageError; // 如果非空,展示一個(gè)error
  private final List<FeedItem> data;   // 列表的數(shù)據(jù)
  private final boolean loadingNextPage; // RecyclerView分頁加載狀態(tài)的指示器
  private final Throwable nextPageError; // 如果非空散吵,展示分頁error的toast
  private final boolean loadingPullToRefresh; // 展示下拉刷新狀態(tài)的指示器
  private final Throwable pullToRefreshError; // 非空意味著下拉刷新的error

   // ... 構(gòu)造器 ...
   // ... getter方法  ...
}

請注意龙考,FeedItem 僅僅是一個(gè)接口蟆肆,每個(gè)條目都需要實(shí)現(xiàn)該接口,然后交給RecyclerView去展示晦款。比如 Product 實(shí)現(xiàn)了 FeedItem炎功;此外,列表中的類別標(biāo)題 SectionHeader 也實(shí)現(xiàn)了 FeedItem缓溅;還有蛇损,作為UI中的元素之一,表示 “可以加載該類別更多” 的指示器同樣也是 FeedItem坛怪,其內(nèi)部還持有了一個(gè)小狀態(tài)——該狀態(tài)代表了當(dāng)前是否 正在加載更多條目 淤齐。

public class AdditionalItemsLoadable implements FeedItem {
  private final int moreItemsAvailableCount;
  private final String categoryName;
  private final boolean loading; // true 代表item正處于加載狀態(tài)
  private final Throwable loadingError; // 標(biāo)志loading時(shí)捕獲到了error

   // ... 構(gòu)造器 ...
   // ... getter方法  ...

這之后便是壓軸的業(yè)務(wù)邏輯組件 HomeFeedLoader ,它負(fù)責(zé)對 FeedItems 進(jìn)行加載:

public class HomeFeedLoader {

  // 通常由 下拉刷新 動(dòng)作觸發(fā)
  public Observable<List<FeedItem>> loadNewestPage() { ... }

  // 加載第一頁
  public Observable<List<FeedItem>> loadFirstPage() { ... }

  // 加載下一頁
  public Observable<List<FeedItem>> loadNextPage() { ... }

  // 加載某個(gè)分類的其它產(chǎn)品
  public Observable<List<Product>> loadProductsOfCategory(String categoryName) { ... }
}

現(xiàn)在袜匿,讓我們一步步將這些點(diǎn)在Presenter中進(jìn)行連接更啄。請注意,接下來Presenter中展示的部分代碼居灯,在真實(shí)的開發(fā)中祭务,應(yīng)該被轉(zhuǎn)移到Interactor(交互器)中(這并非是為了更好的可讀性)。首先怪嫌,我們先開始對初始化數(shù)據(jù)進(jìn)行加載:

class HomePresenter extends MviBasePresenter<HomeView, HomeViewState> {

  private final HomeFeedLoader feedLoader;

  @Override protected void bindIntents() {

    // 在真實(shí)的開發(fā)中义锥,應(yīng)該被轉(zhuǎn)移到Interactor中
    Observable<HomeViewState> loadFirstPage = intent(HomeView::loadFirstPageIntent)
        .flatMap(ignored -> feedLoader.loadFirstPage()
            .map(items -> new HomeViewState(items, false, null) )
            .startWith(new HomeViewState(emptyList, true, null) )
            .onErrorReturn(error -> new HomeViewState(emptyList, false, error))

    subscribeViewState(loadFirstPage, HomeView::render);
  }
}

到目前為止感覺良好,和上一章節(jié)我們實(shí)現(xiàn)的Search界面相比喇勋,沒有什么太大的不同缨该。

現(xiàn)在我們嘗試添加對 下拉刷新 的支持:偎行、

class HomePresenter extends MviBasePresenter<HomeView, HomeViewState> {

  private final HomeFeedLoader feedLoader;

  @Override protected void bindIntents() {

    // 在真實(shí)的開發(fā)中川背,應(yīng)該被轉(zhuǎn)移到Interactor中
    Observable<HomeViewState> loadFirstPage = ... ;

    Observable<HomeViewState> pullToRefresh = intent(HomeView::pullToRefreshIntent)
        .flatMap(ignored -> feedLoader.loadNewestPage()
            .map( items -> new HomeViewState(...))
            .startWith(new HomeViewState(...))
            .onErrorReturn(error -> new HomeViewState(...)));

    Observable<HomeViewState> allIntents = Observable.merge(loadFirstPage, pullToRefresh);

    subscribeViewState(allIntents, HomeView::render);
  }
}

稍微等一下:feedLoader.loadNewestPage() 僅僅返回了新的條目數(shù)據(jù),但是之前我們已經(jīng)加載了的條目怎么辦蛤袒?

“傳統(tǒng)”的MVP模式中熄云,我們可以調(diào)用類似view.addNewItems(newItems)的方法,但是在 第一篇文章 中妙真,我們已經(jīng)探討了為什么這不是一個(gè)好主意(狀態(tài)問題)缴允。

我們當(dāng)前面臨的問題是,下拉刷新依賴了之前的狀態(tài)珍德,因?yàn)槲覀兿胍獙⑾吕⑿路祷氐臈l目和之前已經(jīng)加載的條目進(jìn)行 合并练般。

女士們,先生們锈候,現(xiàn)在薄料,讓我們熱情地歡迎狀態(tài)折疊器(State Reducer)的到來!

[圖片上傳失敗...(image-672708-1552483601199)]

State Reducer是函數(shù)式編程中的一個(gè)概念泵琳,它 將前一個(gè)狀態(tài)作為輸入摄职,并根據(jù)前一個(gè)狀態(tài)計(jì)算得出一個(gè)新的狀態(tài)誊役,就像這樣:

public State reduce( State previous, Foo foo ){
  State newState;
  // ... 根據(jù)前一個(gè)狀態(tài)計(jì)算得出一個(gè)新的狀態(tài) ...
  return newState;
}

因此上述問題的解決方案是,我們定義一個(gè)Foo組件谷市,通過其類似reduce()的函數(shù)蛔垢,結(jié)合之前的狀態(tài)計(jì)算出一個(gè)新的狀態(tài)。

這個(gè)名為Foo的組件通常意味著我們希望對之前狀態(tài)所進(jìn)行的改變迫悠,在我們的案例中鹏漆,我們希望將 最初通過loadFirstPageIntent計(jì)算得到的HomeViewState下拉刷新得到的結(jié)果 進(jìn)行reduce

你猜怎么著创泄,RxJava有一個(gè)名為 scan() 的操作符甫男,讓我們對我們的代碼進(jìn)行略微的重構(gòu),我們需要引入另外一個(gè)表示 部分改變 的類—— 上面我們將其稱之為Foo验烧,它將用于計(jì)算新的狀態(tài)板驳。

class HomePresenter extends MviBasePresenter<HomeView, HomeViewState> {

  private final HomeFeedLoader feedLoader;

  @Override protected void bindIntents() {

    Observable<PartialState> loadFirstPage = intent(HomeView::loadFirstPageIntent)
        .flatMap(ignored -> feedLoader.loadFirstPage()
            .map(items -> new PartialState.FirstPageData(items) )
            .startWith(new PartialState.FirstPageLoading(true) )
            .onErrorReturn(error -> new PartialState.FirstPageError(error))

    Observable<PartialState> pullToRefresh = intent(HomeView::pullToRefreshIntent)
        .flatMap(ignored -> feedLoader.loadNewestPage()
            .map( items -> new PartialState.PullToRefreshData(items)
            .startWith(new PartialState.PullToRefreshLoading(true)))
            .onErrorReturn(error -> new PartialState.PullToRefreshError(error)));

    Observable<PartialState> allIntents = Observable.merge(loadFirstPage, pullToRefresh);
    // 展示第一頁數(shù)據(jù)加載中...
    HomeViewState initialState = ... ;
    Observable<HomeViewState> stateObservable = allIntents.scan(initialState, this::viewStateReducer)

    subscribeViewState(stateObservable, HomeView::render);
  }

  private HomeViewState viewStateReducer(HomeViewState previousState, PartialState changes){
     ...
   }
 }

我們在這里做了什么?相比較直接返回Observable<HomeViewState>碍拆,現(xiàn)在每個(gè)Intent返回的是Observable<PartialState>若治。這之后我們通過merge()操作符將其全部合并為一個(gè)可觀察的流中,并最終應(yīng)用到了reducer的函數(shù)中(即Observable.scan())感混。

這意味著端幼,無論何時(shí)用戶發(fā)起了一個(gè)intent,這個(gè)intent將會生產(chǎn)一個(gè)PartialState的實(shí)例,然后被reduced得到了HomeViewState,最終弧满,被View層進(jìn)行展示(HomeView.render(HomeViewState))婆跑。

唯一遺漏的部分應(yīng)該就是state reducer的函數(shù)本身了,如上文中的定義一樣庭呜,HomeViewState類本身并未發(fā)生了改變滑进,但是我們通過Builder模式添加了一個(gè)Builder,這樣我們就可以非常便捷地創(chuàng)建一個(gè)新的HomeViewState實(shí)例募谎。

現(xiàn)在讓我們開始實(shí)現(xiàn)state reducer的函數(shù):

private HomeViewState viewStateReducer(HomeViewState previousState, PartialState changes){
    if (changes instanceof PartialState.FirstPageLoading)
        return previousState.toBuilder() // 根據(jù)當(dāng)前狀態(tài)復(fù)制一個(gè)內(nèi)部同樣狀態(tài)的對象
        .firstPageLoading(true) // 展示progressBar
        .firstPageError(null) // 不展示error
        .build()

    if (changes instanceof PartialState.FirstPageError)
     return previousState.builder()
         .firstPageLoading(false) // 隱藏progressBar
         .firstPageError(((PartialState.FirstPageError) changes).getError()) // 展示error
         .build();

     if (changes instanceof PartialState.FirstPageLoaded)
       return previousState.builder()
           .firstPageLoading(false)
           .firstPageError(null)
           .data(((PartialState.FirstPageLoaded) changes).getData())
           .build();

     if (changes instanceof PartialState.PullToRefreshLoading)
      return previousState.builder()
            .pullToRefreshLoading(true) // 展示下拉刷新的UI指示器
            .nextPageError(null)
            .build();

    if (changes instanceof PartialState.PullToRefreshError)
      return previousState.builder()
          .pullToRefreshLoading(false) // 隱藏下拉刷新的UI指示器
          .pullToRefreshError(((PartialState.PullToRefreshError) changes).getError())
          .build();

    if (changes instanceof PartialState.PullToRefreshData) {
      List<FeedItem> data = new ArrayList<>();
      data.addAll(((PullToRefreshData) changes).getData()); // 將新的數(shù)據(jù)插入到當(dāng)前列表的頂部
      data.addAll(previousState.getData());
      return previousState.builder()
        .pullToRefreshLoading(false)
        .pullToRefreshError(null)
        .data(data)
        .build();
    }


   throw new IllegalStateException("Don't know how to reduce the partial state " + changes);
}

我知道扶关,這些代碼看起來并不優(yōu)雅,但這不是本文的重點(diǎn)——為什么博主會在他的文章中展示如此 “丑陋” 的代碼数冬?

因?yàn)槲蚁M軌蜿U述一個(gè)觀點(diǎn)节槐,我認(rèn)為 讀者并不應(yīng)該為源碼中錯(cuò)綜復(fù)雜的邏輯買單 ,比如拐纱,我們的購物車App中铜异,也不需要讀者對某些設(shè)計(jì)模式有額外的知識儲備。

因此秸架,我認(rèn)為博客文章中最好避免出現(xiàn)設(shè)計(jì)模式揍庄,這的確會展示出更好的代碼,但其本身就意味著 更高的閱讀理解成本咕宿。

回顧本文币绩,其重點(diǎn)是對State Reducer進(jìn)行配置蜡秽,通過上述的代碼,大家都能夠更快更準(zhǔn)確地去了解它是什么缆镣。但你會在實(shí)際開發(fā)中這樣編寫代碼嗎芽突?當(dāng)然不會,我會去使用設(shè)計(jì)模式或者其它的解決方案董瞻,比如使用 public HomeViewState computeNewState(previousState) 之類的方法將PartialState定義為接口寞蚌。

好吧,我想你已經(jīng)了解了State Reducer是如何工作的钠糊,讓我們實(shí)現(xiàn)剩下來的功能:分頁以及能夠加載某個(gè)指定分類更多的Item:

class HomePresenter extends MviBasePresenter<HomeView, HomeViewState> {

  private final HomeFeedLoader feedLoader;

  @Override protected void bindIntents() {

    Observable<PartialState> loadFirstPage = ... ;
    Observable<PartialState> pullToRefresh = ... ;

    Observable<PartialState> nextPage =
      intent(HomeView::loadNextPageIntent)
          .flatMap(ignored -> feedLoader.loadNextPage()
              .map(items -> new PartialState.NextPageLoaded(items))
              .startWith(new PartialState.NextPageLoading())
              .onErrorReturn(PartialState.NexPageLoadingError::new));

      Observable<PartialState> loadMoreFromCategory =
          intent(HomeView::loadAllProductsFromCategoryIntent)
              .flatMap(categoryName -> feedLoader.loadProductsOfCategory(categoryName)
                  .map( products -> new PartialState.ProductsOfCategoryLoaded(categoryName, products))
                  .startWith(new PartialState.ProductsOfCategoryLoading(categoryName))
                  .onErrorReturn(error -> new PartialState.ProductsOfCategoryError(categoryName, error)));


    Observable<PartialState> allIntents = Observable.merge(loadFirstPage, pullToRefresh, nextPage, loadMoreFromCategory);
    // 展示第一頁正在加載
    HomeViewState initialState = ... ;
    Observable<HomeViewState> stateObservable = allIntents.scan(initialState, this::viewStateReducer)

    subscribeViewState(stateObservable, HomeView::render);
  }

  private HomeViewState viewStateReducer(HomeViewState previousState, PartialState changes){
    // ... 第一頁的部分狀態(tài)處理和下拉刷新 ...

      if (changes instanceof PartialState.NextPageLoading) {
       return previousState.builder().nextPageLoading(true).nextPageError(null).build();
     }

     if (changes instanceof PartialState.NexPageLoadingError)
       return previousState.builder()
           .nextPageLoading(false)
           .nextPageError(((PartialState.NexPageLoadingError) changes).getError())
           .build();


     if (changes instanceof PartialState.NextPageLoaded) {
       List<FeedItem> data = new ArrayList<>();
       data.addAll(previousState.getData());
        // 將新的數(shù)據(jù)添加到list的尾部
       data.addAll(((PartialState.NextPageLoaded) changes).getData());

       return previousState.builder().nextPageLoading(false).nextPageError(null).data(data).build();
     }

     if (changes instanceof PartialState.ProductsOfCategoryLoading) {
         int indexLoadMoreItem = findAdditionalItems(categoryName, previousState.getData());

         AdditionalItemsLoadable ail = (AdditionalItemsLoadable) previousState.getData().get(indexLoadMoreItem);

         AdditionalItemsLoadable itemsThatIndicatesError = ail.builder() // 創(chuàng)建所有item的副本
         .loading(true).error(null).build();

         List<FeedItem> data = new ArrayList<>();
         data.addAll(previousState.getData());
         data.set(indexLoadMoreItem, itemsThatIndicatesError); // 這將會展示一個(gè)loading的指示器

         return previousState.builder().data(data).build();
      }

     if (changes instanceof PartialState.ProductsOfCategoryLoadingError) {
       int indexLoadMoreItem = findAdditionalItems(categoryName, previousState.getData());

       AdditionalItemsLoadable ail = (AdditionalItemsLoadable) previousState.getData().get(indexLoadMoreItem);

       AdditionalItemsLoadable itemsThatIndicatesError = ail.builder().loading(false).error( ((ProductsOfCategoryLoadingError)changes).getError()).build();

       List<FeedItem> data = new ArrayList<>();
       data.addAll(previousState.getData());
       data.set(indexLoadMoreItem, itemsThatIndicatesError); // 這將會展示一個(gè)error和重試的button
       return previousState.builder().data(data).build();
     }

     if (changes instanceof PartialState.ProductsOfCategoryLoaded) {
       String categoryName = (ProductsOfCategoryLoaded) changes.getCategoryName();
       int indexLoadMoreItem = findAdditionalItems(categoryName, previousState.getData());
       int indexOfSectionHeader = findSectionHeader(categoryName, previousState.getData());

       List<FeedItem> data = new ArrayList<>();
       data.addAll(previousState.getData());
       removeItems(data, indexOfSectionHeader, indexLoadMoreItem); // 移除指定分類下的所有item

       // 添加指定分類下的所有item (包括之前已經(jīng)被移除的)
       data.addAll(indexOfSectionHeader + 1,((ProductsOfCategoryLoaded) changes).getData());

       return previousState.builder().data(data).build();
     }

     throw new IllegalStateException("Don't know how to reduce the partial state " + changes);
  }
}

實(shí)現(xiàn)分頁加載和下拉刷新十分相似挟秤,異同之處僅僅在于前者是把加載到的數(shù)據(jù)添加在列表末尾,而下拉刷新則是把數(shù)據(jù)展示在界面頂部抄伍。

更有趣的是我們?nèi)绾吾槍δ硞€(gè)類別去加載更多條目:為了展示某個(gè)類別的加載指示器和錯(cuò)誤/重試的按鈕艘刚,我們只需在所有的FeedItems列表中找到對應(yīng)的AdditionalItemsLoadable對象,然后我們將其改變?yōu)檎故炯虞d指示器或者錯(cuò)誤/重試的按鈕截珍。

如果我們已成功加載某個(gè)類別的所有條目攀甚,我們將搜索SectionHeaderAdditionalItemsLoadable,并用新加載的列表替換這里的所有條目岗喉,僅此而已秋度。

結(jié)語

本文的目的是向您展示 狀態(tài)折疊器(State Reducer) 如何幫助我們通過 簡潔且易讀 的代碼構(gòu)建復(fù)雜的頁面。現(xiàn)在回過頭來思考钱床,“傳統(tǒng)”的MVP或者MVVM針對這些功能荚斯,在不使用State Reducer的前提下是如何實(shí)現(xiàn)這些功能的。

顯然查牌,能夠使用State Reducer的關(guān)鍵是我們有一個(gè)反映狀態(tài)的Model類事期,這也印證了該系列的第一篇文章中所闡述的,為什么理解 Model 是那么的重要僧免。

此外刑赶,只有當(dāng)我們確定狀態(tài)(或準(zhǔn)確的Model)來自單一的數(shù)據(jù)源時(shí),才能使用State Reducer懂衩,因此單向數(shù)據(jù)流同樣非常重要。

我希望我們花費(fèi)在 閱讀理解 前兩篇博客的時(shí)間是有意義的金踪,現(xiàn)在浊洞,所有的點(diǎn)都成功的連在了一起,是時(shí)候歡呼了胡岔。

如果還沒有法希,不用擔(dān)心,對此我也花了相當(dāng)長的時(shí)間才完全理解——還有很多次練習(xí)靶瘸、錯(cuò)誤和重試苫亦。

在第二篇博客中毛肋,針對搜索界面,我們并未使用State Reducer屋剑。這是因?yàn)槿绻覀円阅撤N方式依賴于先前的狀態(tài)润匙,State Reducer是有意義的。而在“搜索界面”中唉匾,我們不依賴于先前的狀態(tài)孕讳。

雖然在最后,但是我還是想重申巍膘,也許你還沒有注意到厂财,那就是我們的data都是不可變的——我們總是創(chuàng)建HomeViewState新的實(shí)例,而不是在已有的對象上調(diào)用其setter方法峡懈,這也使得多線程不再是問題璃饱。

用戶可以在加載下一頁的同時(shí)開始下拉刷新并加載某個(gè)類別的更多條目,因?yàn)?code>State Reducer總是能夠產(chǎn)生正確的狀態(tài)肪康,卻不依賴于http響應(yīng)的任何特定順序帜平。另外,我們用純函數(shù)編寫了代碼梅鹦,沒有任何副作用裆甩。這使我們的代碼非常具有可測試性、可重現(xiàn)性齐唆、易于推演和高度可并行化(即多線程)嗤栓。

當(dāng)然,State Reducer并非是MVI發(fā)明的箍邮,您可以在多種編程語言的許多三方庫茉帅,框架和系統(tǒng)中找到其概念。它完全符合Model-View-Intent的理念锭弊,具有單向的數(shù)據(jù)流和表示狀態(tài)的Model堪澎。

在下一個(gè)部分中,我們將聚焦于如何通過MVI 構(gòu)建 可復(fù)用響應(yīng)式 的UI組件味滞,敬請關(guān)注樱蛤。


系列目錄

《使用MVI打造響應(yīng)式APP》原文

《使用MVI打造響應(yīng)式APP》譯文

《使用MVI打造響應(yīng)式APP》實(shí)戰(zhàn)


關(guān)于我

Hello,我是卻把清梅嗅剑鞍,如果您覺得文章對您有價(jià)值昨凡,歡迎 ??,也歡迎關(guān)注我的博客或者Github蚁署。

如果您覺得文章還差了那么點(diǎn)東西便脊,也請通過關(guān)注督促我寫出更好的文章——萬一哪天我進(jìn)步了呢?

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末光戈,一起剝皮案震驚了整個(gè)濱河市哪痰,隨后出現(xiàn)的幾起案子遂赠,更是在濱河造成了極大的恐慌,老刑警劉巖晌杰,帶你破解...
    沈念sama閱讀 212,029評論 6 492
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件跷睦,死亡現(xiàn)場離奇詭異,居然都是意外死亡乎莉,警方通過查閱死者的電腦和手機(jī)送讲,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 90,395評論 3 385
  • 文/潘曉璐 我一進(jìn)店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來惋啃,“玉大人哼鬓,你說我怎么就攤上這事”呙穑” “怎么了异希?”我有些...
    開封第一講書人閱讀 157,570評論 0 348
  • 文/不壞的土叔 我叫張陵,是天一觀的道長绒瘦。 經(jīng)常有香客問我称簿,道長,這世上最難降的妖魔是什么惰帽? 我笑而不...
    開封第一講書人閱讀 56,535評論 1 284
  • 正文 為了忘掉前任憨降,我火速辦了婚禮,結(jié)果婚禮上该酗,老公的妹妹穿的比我還像新娘授药。我一直安慰自己,他們只是感情好呜魄,可當(dāng)我...
    茶點(diǎn)故事閱讀 65,650評論 6 386
  • 文/花漫 我一把揭開白布悔叽。 她就那樣靜靜地躺著,像睡著了一般爵嗅。 火紅的嫁衣襯著肌膚如雪娇澎。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 49,850評論 1 290
  • 那天睹晒,我揣著相機(jī)與錄音趟庄,去河邊找鬼。 笑死册招,一個(gè)胖子當(dāng)著我的面吹牛岔激,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播是掰,決...
    沈念sama閱讀 39,006評論 3 408
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼辱匿!你這毒婦竟也來了键痛?” 一聲冷哼從身側(cè)響起炫彩,我...
    開封第一講書人閱讀 37,747評論 0 268
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎絮短,沒想到半個(gè)月后江兢,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 44,207評論 1 303
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡丁频,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 36,536評論 2 327
  • 正文 我和宋清朗相戀三年杉允,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片席里。...
    茶點(diǎn)故事閱讀 38,683評論 1 341
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡叔磷,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出奖磁,到底是詐尸還是另有隱情改基,我是刑警寧澤,帶...
    沈念sama閱讀 34,342評論 4 330
  • 正文 年R本政府宣布咖为,位于F島的核電站秕狰,受9級特大地震影響,放射性物質(zhì)發(fā)生泄漏躁染。R本人自食惡果不足惜鸣哀,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 39,964評論 3 315
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望吞彤。 院中可真熱鬧我衬,春花似錦、人聲如沸备畦。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,772評論 0 21
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽懂盐。三九已至褥赊,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間莉恼,已是汗流浹背拌喉。 一陣腳步聲響...
    開封第一講書人閱讀 32,004評論 1 266
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留俐银,地道東北人尿背。 一個(gè)月前我還...
    沈念sama閱讀 46,401評論 2 360
  • 正文 我出身青樓,卻偏偏與公主長得像捶惜,于是被迫代替她去往敵國和親田藐。 傳聞我的和親對象是個(gè)殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 43,566評論 2 349

推薦閱讀更多精彩內(nèi)容

  • 版權(quán)聲明: 以下內(nèi)容來自微信公共帳號“EOS技術(shù)愛好者”,搜索“EOSTechLover”即可訂閱汽久,翻譯Locha...
    Lochaiching閱讀 2,012評論 0 1
  • 我已經(jīng)記不得我是第幾次被驚醒了鹤竭,我睡了醒醒了睡,每次閉眼都是噩夢景醇,一場大火臀稚,一具尸體。那尸體看不清臉三痰,只是覺得熟悉...
    Dellciy閱讀 286評論 0 1
  • 總是聽到有人說吧寺,現(xiàn)在這個(gè)世界太功利了。 但是散劫,功利點(diǎn)不好嗎?因?yàn)樗姓J(rèn)每個(gè)人的努力稚机。 前幾天看了薛之謙上《吐槽大會...
    許大純閱讀 651評論 0 0
  • 笨拙的螃蟹在幾年前講四課評選的時(shí)候已經(jīng)講過抒钱,模模糊糊的,記得當(dāng)時(shí)我對繪本也就颜凯,略知一二谋币,找名氣比較大的一些繪本,例...
    風(fēng)清云淡_bdfd閱讀 3,856評論 0 0
  • 各大公號的推文都是要女性如何提升自己诅蝶,如何自律,如何讓自己更優(yōu)秀募壕,如何调炬,如何……。 作為女...
    由零開始_b936閱讀 452評論 6 1