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

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

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

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

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

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

image

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

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

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

public interface HomeView {

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

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

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

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

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

View層具體的實(shí)現(xiàn)簡(jiǎ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分頁(yè)加載狀態(tài)的指示器
  private final Throwable nextPageError; // 如果非空,展示分頁(yè)error的toast
  private final boolean loadingPullToRefresh; // 展示下拉刷新狀態(tài)的指示器
  private final Throwable pullToRefreshError; // 非空意味著下拉刷新的error

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

請(qǐng)注意,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é)對(duì) FeedItems 進(jìn)行加載:

public class HomeFeedLoader {

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

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

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

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

現(xiàn)在,讓我們一步步將這些點(diǎn)在Presenter中進(jìn)行連接。請(qǐng)注意,接下來Presenter中展示的部分代碼,在真實(shí)的開發(fā)中,應(yīng)該被轉(zhuǎn)移到Interactor(交互器)中(這并非是為了更好的可讀性)。首先,我們先開始對(duì)初始化數(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)在我們嘗試添加對(duì) 下拉刷新 的支持:、

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的組件通常意味著我們希望對(duì)之前狀態(tài)所進(jìn)行的改變,在我們的案例中,我們希望將 最初通過loadFirstPageIntent計(jì)算得到的HomeViewState下拉刷新得到的結(jié)果 進(jìn)行reduce。

你猜怎么著,RxJava有一個(gè)名為 scan() 的操作符,讓我們對(duì)我們的代碼進(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);
    // 展示第一頁(yè)數(shù)據(jù)加載中...
    HomeViewState initialState = ... ;
    Observable<HomeViewState> stateObservable = allIntents.scan(initialState, this::viewStateReducer)

    subscribeViewState(stateObservable, HomeView::render);
  }

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

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

這意味著,無論何時(shí)用戶發(fā)起了一個(gè)intent,這個(gè)intent將會(huì)生產(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)的對(duì)象
        .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)——為什么博主會(huì)在他的文章中展示如此 “丑陋” 的代碼?

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

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

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

好吧,我想你已經(jīng)了解了State Reducer是如何工作的,讓我們實(shí)現(xiàn)剩下來的功能:分頁(yè)以及能夠加載某個(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);
    // 展示第一頁(yè)正在加載
    HomeViewState initialState = ... ;
    Observable<HomeViewState> stateObservable = allIntents.scan(initialState, this::viewStateReducer)

    subscribeViewState(stateObservable, HomeView::render);
  }

  private HomeViewState viewStateReducer(HomeViewState previousState, PartialState changes){
    // ... 第一頁(yè)的部分狀態(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); // 這將會(huì)展示一個(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); // 這將會(huì)展示一個(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)分頁(yè)加載和下拉刷新十分相似,異同之處僅僅在于前者是把加載到的數(shù)據(jù)添加在列表末尾,而下拉刷新則是把數(shù)據(jù)展示在界面頂部。

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

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

結(jié)語

本文的目的是向您展示 狀態(tài)折疊器(State Reducer) 如何幫助我們通過 簡(jiǎn)潔且易讀 的代碼構(gòu)建復(fù)雜的頁(yè)面?,F(xiàn)在回過頭來思考,“傳統(tǒng)”的MVP或者MVVM針對(duì)這些功能,在不使用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)心,對(duì)此我也花了相當(dāng)長(zhǎng)的時(shí)間才完全理解——還有很多次練習(xí)、錯(cuò)誤和重試。

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

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

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

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

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


系列目錄

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

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

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


關(guān)于我

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

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

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

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

友情鏈接更多精彩內(nèi)容