Model-View-Intentを使用したリアクティブアプリケーション。 パート3:State Reducer





前のパートでは、単方向データストリームを使用するModel-View-Intentパターンで単純な画面を実装する方法について説明しました。 3番目の部分では、State Reducerを使用して、MVIでより複雑な画面を構築します。



2番目のパートを読んでいない場合は、さらに読む前に読む必要があります。これは、Presenterを介してViewをビジネスロジックに接続する方法と、データが一方向に移動する方法を説明しているためです。



次に、より複雑な画面を作成しましょう。





ご覧のとおり、この画面には、カテゴリ別にグループ化されたアイテム(製品)のリストが表示されます。 アプリケーションには、各カテゴリの3つの要素のみが表示され、ユーザーは[さらにダウンロード]をクリックして、選択したカテゴリのすべての製品をダウンロードできます(http-request)。 )もちろん、これらのアクションはすべて同時に実行でき、それぞれが実行されない場合があります(たとえば、インターネットがない場合)



この手順を段階的に実装しましょう。 最初に、Viewインターフェースを定義しましょう。



public interface HomeView { public Observable<Boolean> loadFirstPageIntent(); public Observable<Boolean> loadNextPageIntent(); public Observable<Boolean> pullToRefreshIntent(); public Observable<String> loadAllProductsFromCategoryIntent(); public void render(HomeViewState viewState); }
      
      





Viewの実装は非常に単純なので、ここではコードを示しません( githubにあります )。

次に、モデルに注目しましょう。 前の部分で述べたように、モデルは状態を反映する必要があります。 そこで、 HomeViewStateというモデルを紹介します。



 public final class HomeViewState { private final boolean loadingFirstPage; private final Throwable firstPageError; private final List<FeedItem> data; private final boolean loadingNextPage; private final Throwable nextPageError; private final boolean loadingPullToRefresh; private final Throwable pullToRefreshError; // ...  ... // ...  ... }
      
      





FeedItemは、RecyclerViewに表示されるすべての要素が実装する必要がある単なるインターフェイスであることに注意してください。 たとえば、 ProductはFeedItemを実装しますSectionHeaderカテゴリの表示名もFeedItemを実装します 。 追加のカテゴリ要素をロードできることを示すUI要素はFeedItemであり、特定のカテゴリに追加の要素をロードしているかどうかを表示するために内部にその状態が含まれています。



 public class AdditionalItemsLoadable implements FeedItem { private final int moreItemsAvailableCount; private final String categoryName; private final boolean loading; private final Throwable loadingError; // ...  ... // ...  ... }
      
      





また、 FeedItemsの読み込みを担当するHomeFeedLoaderビジネスロジック要素を作成します。



 public class HomeFeedLoader { public Observable<List<FeedItem>> loadNewestPage() { ... } public Observable<List<FeedItem>> loadFirstPage() { ... } public Observable<List<FeedItem>> loadNextPage() { ... } public Observable<List<Product>> loadProductsOfCategory(String categoryName) { ... } }
      
      





プレゼンターですべてを段階的に接続しましょう。 ここでプレゼンターの一部として提示されたコードの一部は、実際のアプリケーションでInteractorに移植される可能性が高いことに注意してください(読みやすくするために行いませんでした)。 最初に、初期データをロードします。



 class HomePresenter extends MviBasePresenter<HomeView, HomeViewState> { private final HomeFeedLoader feedLoader; @Override protected void bindIntents() { 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); } }
      
      





すべてが順調に進んでいますが、パート2の「検索画面」の実装方法に大きな違いはありません。次に、Pull-To-Refreshサポートを追加してみましょう。



 class HomePresenter extends MviBasePresenter<HomeView, HomeViewState> { private final HomeFeedLoader feedLoader; @Override protected void bindIntents() { 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()は新しい要素のみを返しますが、以前にダウンロードした以前の要素はどうですか? 「従来の」MVPでは、誰かがview.addNewItems(newItems)を実行できますが、最初の部分では、なぜこれが悪い考えなのかについて説明しました(「State Problem」)。現在直面している問題は次のとおりです:Pull-To-Refresh Pull-To-Refreshから返される要素と以前の要素を結合するため、以前のHomeViewStateに依存します。



ご列席の皆様、愛と好意をお願いします-State Reducer



画像



State Reducerは、関数型プログラミングの概念です。 入力で前の状態を取得し、前の状態から新しい状態を計算します。



 public State reduce( State previous, Foo foo ){ State newState; // ...   State      Foo return newState; }
      
      





つまり、reduce()メソッドは以前の状態とfooを組み合わせて新しい状態を計算します。 Fooは通常、以前の状態に適用する変更です。 この場合、以前のHomeViewState(loadFirstPageIntentから元々取得された)とPull-To-Refreshの結果を組み合わせたいと思います。 RxJavaにはthis- scan()のための特別な演算子があることがわかります。 コードを少し変更しましょう。 部分的な変更(上記のコードではFooと呼ばれます)を反映し、新しい状態の計算に使用される別のクラスを作成する必要があります。



入居者
 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); HomeViewState initialState = ... ; //     Observable<HomeViewState> stateObservable = allIntents.scan(initialState, this::viewStateReducer) subscribeViewState(stateObservable, HomeView::render); } private HomeViewState viewStateReducer(HomeViewState previousState, PartialState changes){ ... } }
      
      







現在、各インテントは、Observable <HomeViewState>ではなくObservable <PartialState>を返します。 次に、 Observable.merge()を使用してそれらを1つのObservableに結合し、最後にObservable.scan()演算子を適用します。 これは、ユーザーインテントを実行するたびに、このインテントがPartialStateオブジェクトを作成し、それがHomeViewStateに縮小され、それがビュー(HomeView.render(HomeViewState))に表示されることを意味します。 欠けている唯一の部分は、混合機能自体です。 HomeViewStateクラス自体は変更されていませんが、Builder(Builderパターン)が追加されているため、便利な方法で新しいHomeViewStateオブジェクトを作成できます。 ミックス機能を実装しましょう:



viewStateReducer
 private HomeViewState viewStateReducer(HomeViewState previousState, PartialState changes){ if (changes instanceof PartialState.FirstPageLoading) return previousState.toBuilder() .firstPageLoading(true) .firstPageError(null) .build() if (changes instanceof PartialState.FirstPageError) return previousState.builder() .firstPageLoading(false) .firstPageError(((PartialState.FirstPageError) changes).getError()) .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) .nextPageError(null) .build(); if (changes instanceof PartialState.PullToRefreshError) return previousState.builder() .pullToRefreshLoading(false) // Hide pull to refresh indicator .pullToRefreshError(((PartialState.PullToRefreshError) changes).getError()) .build(); if (changes instanceof PartialState.PullToRefreshData) { List<FeedItem> data = new ArrayList<>(); data.addAll(((PullToRefreshData) changes).getData()); 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); }
      
      







これらのinstanceofチェックのすべてがあまり良くないことを知っていますが、それはこの記事のポイントではありません。 上の例のように、技術ブロガーが「悪い」コードを書くのはなぜですか? 読者にソースコード全体(商品のバスケットを使用したアプリケーションなど)や特定のデザインパターンを意識させることなく、特定のトピックに集中したいからです。 したがって、コードを改善するだけでなく、読みやすさを低下させる可能性のある記事のパターンを避けることをお勧めします。 この記事の焦点はState Reducerです。 instanceofチェックで彼を見ると、誰でも彼が何をしているかを理解できます。 アプリケーションでinstanceofチェックを使用する必要がありますか? いいえ、デザインパターンまたは他のソリューションを使用します。 たとえば、パブリックHomeViewState computeNewState(previousState)メソッドを使用して、PartialStateをインターフェイスとして宣言できます。 一般に、MVIを使用してアプリケーションを開発する場合、Paco EstevezのRxSealedUnionsが役立つことがあります。



さて、私はあなたがState Reducerのアイデアを得たと思います。 残りの機能、つまりページネーションと特定のカテゴリの要素をさらに読み込む機能を実装しましょう。



入居者
 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){ 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()); 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() .loading(true).error(null).build(); List<FeedItem> data = new ArrayList<>(); data.addAll(previousState.getData()); data.set(indexLoadMoreItem, itemsThatIndicatesError); 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); 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); //     (  ) 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); } }
      
      







ページネーション(次の「ページ」に要素をロードする)の実装は、プルツーリフレッシュと非常に似ていますが、ロードされた要素を先頭に追加するのではなく、リストの最後に追加します(プルツーリフレッシュと同様) )より興味深いのは、特定のカテゴリの要素をさらに読み込む方法です。 選択したカテゴリのダウンロードインジケータまたはやり直し/エラーボタンを表示するには、すべてのFeedItemのリストで対応するAdditionalItemsLoadableオブジェクトを見つけるだけです。 次に、この項目を変更して、ダウンロードの進行状況バーまたはやり直し/エラーボタンを表示します。 特定のカテゴリのすべての要素を正常にロードした場合、SectionHeaderとAdditionalItemsLoadableを探し、それらの間のすべての要素を新しいロードされた要素に置き換えます。



おわりに



この記事の目的は、State Reducerを使用して、小さく明確なコードで複雑な画面を設計する方法を示すことでした。 少し戻って、State Reducerを使用せずに「従来の」MVPまたはMVVMでこれを実装する方法を考えてください。 State Reducerを使用できる重要なポイントは、状態を反映するモデルがあることです。 したがって、このシリーズの記事の最初の部分からモデルが何であるかを理解することは非常に重要でした。 また、State Reducerは、State(またはむしろModel)が単一のソースからのものであることが確実な場合にのみ使用できます。 したがって、単方向のデータフローも非常に重要です。 このシリーズの第1部と第2部でこれらのトピックに焦点を当てた理由が明確になったと思います。すべてのポイントが結び付いたときに、同じ「あは!」の瞬間があることを願っています。 そうでない場合でも、心配しないでください、それは私に多くの時間(そして多くの練習、そして多くの間違いと繰り返し)を要しました。

おそらく、検索画面でState Reducerを使用しなかった理由(2番目の部分)を疑問に思っているかもしれません。 State Reducerの使用は、以前の状態に何らかの形で依存している場合に基本的に意味があります。



最後に大事なことを言いますが、私が検討したいのは、すべてのデータが不変であることに気づかなかった場合です(詳細には触れません)(常に新しいHomeViewStateを作成し、どのオブジェクトでもsetterメソッドを呼び出しません)。 したがって、マルチスレッドに問題はありません。 State Reducerはhttp応答の順序に関係なく正しい状態を生成できるため、ユーザーはプルツーリフレッシュと同時に新しいページを読み込み、特定のカテゴリの要素をさらに読み込むことができます。 さらに、 副作用のない単純な関数を使用してコードを記述しました。 これにより、コードを非常にテストしやすく、再現性があり、高度に並列化でき、議論しやすくなります。



もちろん、State ReducerはMVI用に考案されたものではありません。 State Reducerの概念は、さまざまなプログラミング言語の多くのライブラリ、フレームワーク、およびシステムで見つけることができます。 State Reducerは、一方向のデータストリームとState-reflectiveモデルを使用して、Model-View-Intentの哲学と完全に統合されています。



次のパートでは、MVIを使用して再利用可能なリアクティブUIコンポーネントを作成する方法に焦点を当てます。



All Articles