[Перевод] Реактивные приложения с Model-View-Intent. Часть 3: State Reducer

gw6ww0knnaevmfj8-v6xdrory6e.jpeg

В предыдущей части мы обсудили, как реализовать простой экран с паттерном Model-View-Intent, использующим однонаправленный поток данных. В третьей части мы построим более сложный экран с MVI с помощью State Reducer.

Если вы еще не прочитали вторую часть — вам стоит это сделать перед дальнейшим чтением, потому что там описывается как мы соединили View с бизнес-логикой через Presenter и как данные двигаются в одном направлении.

Теперь давайте создадим более сложный экран:


Как вы видите, этот экран отображает список элементов (продуктов), сгруппированных по категории. Приложение отображает только 3 элемента каждой категории и пользователь может нажать «Загрузить еще», чтобы загрузить все продукты выбранной категории (http-запрос) Также пользователь может сделать Pull-To-Refresh, и когда он доберется до конца списка — загрузятся больше категорий (пагинация) Само собой, все эти действия могут быть выполнены одновременно, и каждый из них может не выполниться (например при отсутствии интернета)

Давайте реализуем это шаг за шагом. Во-первых, давайте определим интерфейс View.

public interface HomeView {

  public Observable loadFirstPageIntent();
  public Observable loadNextPageIntent();
  public Observable pullToRefreshIntent();
  public Observable loadAllProductsFromCategoryIntent();
  public void render(HomeViewState viewState);
}


Реализация View достаточно прямолинейна, и потому я не буду показывать код здесь (можно найти на github).
Теперь давайте сфокусируемся на модели. Как уже упоминалось в предыдущих частях — модель должна отражать состояние. Так что, представляю вам модель, которая называется HomeViewState.

public final class HomeViewState {

  private final boolean loadingFirstPage;
  private final Throwable firstPageError;
  private final List 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;

   // ... конструктор ...
   // ... геттеры  ...
}


А так же создадим элемент бизнес-логики HomeFeedLoader, который отвечает за загрузку FeedItems:

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


Теперь давайте всё соединим шаг за шагом в нашем Презентере. Имейте в виду, что некоторый код, представленный здесь, как часть Презентера, скорее всего должен быть перенесен в Interactor в реальном приложении (что я не сделал для лучшей читаемости). Во-первых, загрузим начальные данные:

class HomePresenter extends MviBasePresenter {

  private final HomeFeedLoader feedLoader;

  @Override 
  protected void bindIntents() {
    Observable 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 {

  private final HomeFeedLoader feedLoader;

  @Override 
  protected void bindIntents() {
    Observable loadFirstPage = ... ;

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

    Observable allIntents = Observable.merge(loadFirstPage, pullToRefresh);

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


Но постойте: feedLoader.loadNewestPage () возвращает только новые элементы, а что же с предыдущими элементами, которые мы загрузили ранее? В «традиционном» MVP кто-то может сделать view.addNewItems (newItems), но в первой части мы уже обсудили, почему это плохая идея («Проблема состояния») Проблема, с которой мы сейчас столкнулись, такова: Pull-To-Refresh зависит от предыдущего HomeViewState, так как мы хотим объединить предыдущие элементы с элементами, которые вернулись от Pull-To-Refresh.

Дамы и Господа, прошу любить и жаловать — State Reducer


image

State Reducer является концептом из функционального программирования. Он принимает предыдущее состояние на вход и вычисляет новое состояние из предыдущего состояния:

public State reduce( State previous, Foo foo ){
  State newState;
  // ... вычисление нового State на основании предыдущего с применением Foo
  return newState;
}


Идея в том, что метод reduce () объединяет предыдущее состояние с foo, чтобы вычислить новое состояние. Foo обычно представляет собой изменения, которые мы хотим применить к предыдущему состоянию. В нашем случае, мы хотим объединить предыдущий HomeViewState (изначально полученный от loadFirstPageIntent) с результатами от Pull-To-Refresh. Оказывается, в RxJava есть специальный оператор для этого — scan (). Давайте изменим наш код немного. Нам нужно создать еще один класс, который будет отражать частичное изменение (в коде выше он называется Foo) и использоваться для вычисления нового состояния:

HomePresenter
class HomePresenter extends MviBasePresenter {

  private final HomeFeedLoader feedLoader;

  @Override 
  protected void bindIntents() {
    Observable 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 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 allIntents = Observable.merge(loadFirstPage, pullToRefresh);
    HomeViewState initialState = ... ; // Показать загрузку первой страницы
    Observable stateObservable = allIntents.scan(initialState, this::viewStateReducer)

    subscribeViewState(stateObservable, HomeView::render);
  }

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


Теперь каждый Intent возвращает Observable вместо Observable. Затем мы объединяем их в один Observable с помощью Observable.merge () и наконец применяем оператор Observable.scan (). Это означает, что когда бы пользователь не запустил intent, этот intent создаст объекты PartialState, которые будут сведены к HomeViewState, который в свою очередь будет отображен на View (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 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 в вашем приложении? Нет, используйте паттерны проектирования или другие решения. К примеру, можно объявить PartialState интерфейсом с методом public HomeViewState computeNewState (previousState). В целом, вы можете найти RxSealedUnions от Paco Estevez полезным, когда будете разрабатывать приложения с MVI.

Окей, я думаю вы поняли идею работы State Reducer. Давайте реализуем оставшийся функционал: пагинацию и возможность загрузить больше элементов определенной категории.

HomePresenter
class HomePresenter extends MviBasePresenter {

  private final HomeFeedLoader feedLoader;

  @Override 
  protected void bindIntents() {

    Observable loadFirstPage = ... ;
    Observable pullToRefresh = ... ;

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

      Observable 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 allIntents = Observable.merge(loadFirstPage, pullToRefresh, nextPage, loadMoreFromCategory);
    HomeViewState initialState = ... ;
    Observable 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 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 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 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 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);
  }
}


Реализация пагинации (загрузки следующей «страницы» с элементами) довольно похожа на pull-to-refresh за исключением того, что мы добавляем загруженные элементы в конец списка, вместо того, чтобы добавлять их в начало (как мы делаем при pull-to-refresh) Интереснее то, как мы загружаем больше элементов определенной категории. Для отображения индикатора загрузки или кнопки повтора/ошибки для выбранной категории мы просто должны найти соответствующий объект AdditionalItemsLoadable в списке всех FeedItem. Затем мы меняем этот элемент, чтобы отобразить индикатор загрузки или кнопку повтора/ошибку. Если мы успешно загрузили все элементы в определенной категории — мы ищем SectionHeader и AdditionalItemsLoadable, а затем заменяем все элементы между ними новыми загруженными элементами.

Заключение


Целью этой статьи было показать, как State Reducer может помочь нам проектировать сложные экраны с помощью небольшого и понятного кода. Просто сделайте шаг назад и подумайте, как бы вы реализовали это с «традиционным» MVP или MVVM без State Reducer? Ключевым моментом, который позволяет нам использовать State Reducer, является то, что у нас присутствует модель, которая отражает состояние. Поэтому, было очень важно понять из первой части этой серии статей, что такое Модель. Так же, State Reducer может быть использован, только если мы можем быть уверены, что Состояние (а точнее — Модель) исходит из единого источника. Поэтому, однонаправленный поток данных также очень важен. Я думаю, теперь понятно, почему мы остановились на этих темах в первой и второй части этой серии статей, и надеюсь у вас произошел тот самый «ага!» момент, когда все точки соединяются вместе. Если нет — не переживайте, для меня это заняло достаточно много времени (и много практики, и много ошибок и повторений).
Возможно, вы задаетесь вопросом — почему мы не использовали State Reducer на экране поиска (во второй части). Использование State Reducer в основном имеет смысл, когда мы каким-то образом зависим от предыдущего состояния.

Последнее, но не менее важное, на чем я хочу остановиться — если вы еще не заметили (без погружения в детали) все наши данные — неизменные (мы всегда создаем новый HomeViewState, мы никогда не вызываем setter-метод на любом из объектов). Поэтому, у вас не должно возникнуть проблем с многопоточностью. Пользователь может сделать pull-to-refresh и в то же время загрузить новую страницу и загрузить больше элементов определенной категории, потому что State Reducer в состоянии произвести правильное состояние без зависимости от порядка http-ответов. В дополнение, мы написали наш код с помощью простых функций, без побочных эффектов. Это делает наш код супертестируемым, воспроизводимым, высокопараллелизуемым и простым в обсуждении.

Конечно, State Reducer не был изобретен для MVI. Вы можете найти концепты State Reducer во множестве библиотек, фреймворков и систем на разных языках программирования. State Reducer великолепно встраивается в философию Model-View-Intent с однонаправленным потоком данных и Моделью, отражающей Состояние.

В следующей части мы сфокусируемся на том, как создать переиспользуемые и реактивные UI-компоненты с MVI.

© Habrahabr.ru