GrabDuck

Реактивные приложения с Model-View-Intent. Часть 1: Модель

:

При работе с платформой Android я столкнулся со множеством проблем, потому что проектировал свои Модели неправильно. Мои приложения были недостаточно реактивными. Теперь используя RxJava и Model-View-Intent (MVI) я, наконец, добился нужного уровня реактивности. Об этом я пишу цикл статей. В первой части расскажу о модели и объясню, чем она важна.

Что я имел в виду, когда сказал, что проектировал модели неправильно? Многие архитектурные шаблоны разделяют «View» от «Model». Самые популярные в разработке Android — Model-View-Controller (MVC), Model-View-Presenter (MVP) и Model-View-ViewModel (MVVM). По названиям видно, что все они используют «Model». Я понял, что большую часть времени у меня вообще не было модели.

Пример. Задача — загрузить список людей с сервера. «Традиционная» имплементация MVP выглядит примерно так:

class PersonsPresenter extends Presenter<PersonsView> {

  public void load(){
    getView().showLoading(true); // Показать ProgressBar на экране

    backend.loadPersons(new Callback(){
      public void onSuccess(List<Person> persons){
        getView().showPersons(persons); // Показать список людей на экране
      }

      public void onError(Throwable error){
        getView().showError(error); // Показать сообщение об ошибке на экране
      }
    });
  }
}

Что такое модель и где она? Модель — это не бэкенд и не List, который мы получаем. Это сущность, которую отображает View вместе с другими индикаторами загрузки или сообщениями об ошибке. С моей точки зрения, Модель должна выглядеть так:
class PersonsModel {
  // В реальном приложении поля будут приватными
  // и у нас будут геттеры для доступа к ним
  final boolean loading;
  final List<Person> persons;
  final Throwable error;

  public(boolean loading, List<Person> persons, Throwable error){
    this.loading = loading;
    this.persons = persons;
    this.error = error;
  }
}

И тогда Presenter может быть реализован следующим образом:
class PersonsPresenter extends Presenter<PersonsView> {

  public void load(){
    getView().render( new PersonsModel(true, null, null) ); // Показать ProgressBar

    backend.loadPersons(new Callback(){
      public void onSuccess(List<Person> persons){
        getView().render( new PersonsModel(false, persons, null) ); // Показать список людей
      }

      public void onError(Throwable error){
          getView().render( new PersonsModel(false, null, error) ); // Показать сообщение об ошибке
      }
    });
  }
}

Теперь у View есть модель, которая отобразится на экране.

Первое определение MVC предложил Трюгве Реенскауг в 1979 году. Оно отражает похожую идею: View наблюдает, как меняется модель. Исследователи описывали термином MVC большое количество шаблонов, которые не попадают под формулировку Реенскауга. Например, серверные разработчики используют фреймворки MVC, у iOS есть ViewController. Возникают вопросы:

  • Что означает MVC в Android?
  • Activity — контроллер?
  • Что такое ClickListener?

Сегодня термин MVC употребляют и толкуют неправильно, поэтому мы приостановим дискуссию, чтобы это не вышло из-под контроля. Вернемся к моему начальному утверждению. В разработке Android мы сталкиваемся с рядом проблем, которые можно избежать с помощью модели:
  1. Проблема состояния.
  2. Изменения ориентации экрана.
  3. Навигация по бэкстеку.
  4. Смерть процесса.
  5. Неизменяемость и однонаправленный поток данных.
  6. Отлаживание и воспроизведение состояний.
  7. Тестируемость.

Посмотрим, как «традиционная» реализация MVP и MVVM справляется с проблемами, и как модель избавляет от распространенных ошибок.

Проблема состояния

Реактивные приложения — модное определение приложений с UI, которые реагируют на изменения состояния. Состояние — то, что мы видим на экране. Например, состояние загрузки, когда View отображает ProgressBar. Как фронтэнд-разработчики мы склонны фокусироваться на UI: хороший UI определяет, насколько успешно приложение, как много людей будут им пользоваться.

Еще раз взгляните на код Presenter выше — не тот, который использует PersonsModel. Presenter определяет состояние UI, именно он говорит View, что отображать. То же относится и к MVVM. В этой статье я выделяю две реализации MVVM: одна использует Android Data Binding, другая — RxJava. В MVVM с Data Binding состояние находится во ViewModel:

class PersonsViewModel {
  ObservableBoolean loading;
  // ... остальные поля пропущены для лучшей читаемости

  public void load(){

    loading.set(true);

    backend.loadPersons(new Callback(){
      public void onSuccess(List<Person> persons){
      loading.set(false);
      // ... остальные действия, например установка списка
      }

      public void onError(Throwable error){
        loading.set(false);
        // ... остальные действия, например установка сообщения об ошибке
      }
    });
  }
}

В MVVM с RxJava мы не используем механизм Data Binding, но связываем Observable с виджетами UI во View:
class RxPersonsViewModel {
  private PublishSubject<Boolean> loading;
  private PublishSubject<List<Person> persons;
  private PublishSubject loadPersonsCommand;

  public RxPersonsViewModel(){
    loadPersonsCommand.flatMap(ignored -> backend.loadPersons())
      .doOnSubscribe(ignored -> loading.onNext(true))
      .doOnTerminate(ignored -> loading.onNext(false))
      .subscribe(persons)
      // Может быть реализовано совершенно иначе
  }

  public Observable<Boolean> loading(){
    return loading;
  }

  public Observable<List<Person>> persons(){
    return persons;
  }

  // Всякий раз когда этот метод вызывается (например при вызове onNext()) загружаем Persons
  public PublishSubject loadPersonsCommand(){
    return loadPersonsCommand;
  }
}

Примеры кода не идеальны, ваша реализация может выглядеть по-другому. Главное, что в MVP и MVVM состоянием управляет Presenter или ViewModel. К чему это приводит:

1. У бизнес-логики есть собственное состояние, так же, как у Presenter или ViewModel. Вы пытаетесь синхронизировать состояния бизнес-логики и Presenter, чтобы они были одинаковыми. Устанавливаете видимость какого-то виджета прямо во View, или Android сам восстанавливает состояние из bundle во время пересоздания.

2. Presenter и ViewModel имеют произвольное количество входных точек. View запускает событие, которое обрабатывает Presenter. Но и Presenter имеет много каналов вывода — как view.showLoading() или view.showError() в MVP. А ViewModel предлагает множественные Observables. Это приводит к конфликтующим состояниям View, Presenter и бизнес-логики, особенно при работе с несколькими потоками.

В лучшем случае появятся визуальные ошибки, например, отображение индикатора загрузки (состояние загрузки) и индикатора ошибки (состояние ошибки) одновременно.


В худшем случае в инструменте для создания отчетов о сбоях — например, в Crashlytics — вы получите баг-репорт, который не сможете воспроизвести и быстро исправить.

Рассмотрим ситуацию, когда у нас единый источник состояния, передаваемый снизу вверх — от бизнес-логики к View. Мы уже видели похожую идею в начале статьи, когда говорили о модели.

class PersonsModel {
  final boolean loading;
  final List<Person> persons;
  final Throwable error;

  public(boolean loading, List<Person> persons, Throwable error){
    this.loading = loading;
    this.persons = persons;
    this.error = error;
  }
}

Модель отражает состояние — если это понять, вы избежите множество связанных с состоянием проблем. У Presenter будет только один источник вывода: getView().render(PersonsModel). Это отражает простую математическую функцию f(x) = y. Может быть несколько входных значений, например f(a,b,c), но выход всегда будет один.

Математика нравится не всем, но математики, в отличие от программистов, не знают, что такое баг. Понимание сущности модели и того, как правильно ее сформировать, очень важно: оно решает проблему состояния.

Изменения ориентации экрана

В Android изменение ориентации экрана — сложная проблема. Разумеется, ее можно игнорировать и перезагружать всё на каждый поворот экрана. Большую часть времени ваше приложение работает в оффлайне, и данные приходят из локальной базы данных или другого локального кэша. Как следствие — после изменения ориентации экрана данные загружаются очень быстро.

Однако, лично мне не нравится видеть индикатор загрузки даже несколько миллисекунд, потому что, по моему мнению, это не похоже на плавный интерфейс.. Поэтому разработчики используют MVP c retain презентером. Во время поворота экрана View может быть отделено (и удалено), тогда как Presenter продолжает существовать в памяти, и View снова прикрепляется к нему.

Та же идея возможна при использовании MVVM с RxJava. Но как только View отпишется от своей ViewModel, observable stream — наблюдаемый поток — уничтожится. Этого можно избежать, используя Subjects. В MVVM с Data Binding ViewModel напрямую связана со View механизмом Data Binding. Чтобы избежать утечек памяти, нужно уничтожать ViewModel, когда меняется ориентация экрана.

Главная проблема с retain презентером или ViewModel — как вернуть состояние View к тому, что было до поворота экрана, чтобы View и Presenter снова были в одном состоянии? Я написал MVP библиотеку Mosby, она включает компонент ViewState, который синхронизирует состояние вашей бизнес-логики и View. Moxy, еще одна библиотека MVP, использует команды и воспроизводит состояние View после того, как изменилась ориентации экрана:
image
Есть и другие решения проблемы состояния View. Сделаем шаг назад и обобщим: все эти библиотеки пытаются решить проблему состояния.

Итак, еще раз: если у нас одна «Модель», отражающая текущее «Состояние», и один метод для обработки «Модели», мы решаем проблему состояния просто вызовом getView().render(PersonsModel) (с последней Моделью, когда повторно присоединяем View к Presenter).

Навигация по бэкстеку

Сохранять ли Presenter или ViewModel, если мы больше не используем View? Например, Fragment (View) был заменен другим Fragment, и пользователь перешел на другой экран. View больше не прикреплена к Presenter — он не сможет обновить View с последними данными от бизнес-логики. Что, если пользователь вернется назад, например, нажав кнопку «Назад» и удалив последнюю транзакцию в бэк-стеке? Нужно ли перезагрузить данные или использовать существующий Presenter?

Пользователь, вернувшись на предыдущий экран, хочет продолжить работу с приложением там же, где остановился. Эту проблему мы обсуждали, говоря об изменении ориентации экрана. Решение очевидно: как только пользователь возвращается из бэк-стека, вызываем getView().render(PersonsModel) с моделью, которая отражает состояние.

Смерть процесса

Существует общее недопонимание в разработке под Android, что смерть процесса — это плохо, и нам нужны библиотеки, которые помогают восстанавливать состояние после смерти процесса. Процесс умирает в двух случаях: если операционная система Android нуждается в ресурсах для других приложений или для экономии батареи. Этого не случится, когда ваше приложение на переднем плане и его активно используют. Не стоит спорить с платформой. Если у вас есть долгие операции в фоновом режиме, применяйте Service — так система поймет, что приложение используется.

Если процесс умер, Android предоставит некоторые методы жизненного цикла, например onSaveInstanceState(), чтобы сохранить состояние. И снова мы задаем вопросы:

  • Нужно ли сохранять информацию из View в bundle?
  • Есть ли у презентера свое состояние, которое мы также должны сохранить в bundle?
  • Что насчет состояния бизнес-логики?

Сделаем вывод. Как описано в предыдущих пунктах, нам нужна модель, которая отражает полное состояние. Тогда мы просто сохраним модель в bundle и восстановим позже. Но часто лучшее решение — это не сохранение состояния, а перезагрузка всего экрана, как при первом запуске приложения. Представьте приложение, которое отображает список новостей. Наше приложение убито, мы сохраняем состояние. Через 6 часов, когда пользователь откроет приложение и состояние восстановится, приложение отобразит устаревший контент. В этом сценарии лучше перезагружать данные, а не сохранять состояние.

Неизменяемость и однонаправленный поток данных

Не будем рассуждать о преимуществах неизменяемости, нам доступно много информации об этом. Мы хотим получить единый источник достоверных данных. Для этого нужна неизменяемая модель, которая отражает состояние. Другие компоненты приложения не должны влиять на нашу модель или на состояние.

Представьте, что мы пишем простое приложение-счётчик, которое отображает текущее значение в TextView и имеет всего две кнопки — «Увеличить» и «Уменьшить». В этом случае наша модель — значение счетчика. Если это неизменяемая модель, как нам изменить счетчик? Мы не будем изменять TextView сразу после каждого нажатия кнопки.

  1. Наша модель должна использовать только view.render(…) метод.
  2. Прямые изменения модели невозможны.
  3. У нас должен быть единственный источник достоверных данных — бизнес-логика.

В бизнес-логике есть приватное поле с текущей моделью, и она создает новую модель с большим или меньшим значением относительно старой модели.

image

Мы устанавливаем однонаправленный поток данных, где бизнес-логика — единый источник данных, который создает неизменяемые экземпляры модели. Это выглядит технически усложненным для простого счетчика, ведь счетчик — это простое приложение. Множество приложений разработчики начинают с простых вещей, но затем усложняют. Однонаправленный поток данных и неизменяемая модель важны даже для простых приложений — для разработчиков они останутся простыми, даже когда сложность возрастет.

Отлаживаемые и воспроизводимые состояния

Однонаправленный поток данных обеспечивает простую отладку в нашем приложении. Было бы неплохо получать полный отчет о сбое из Crashlytics, чтобы воспроизвести и быстро устранить этот сбой. Вся нужная нам информация — это текущая модель и действие, которое хотел совершить пользователь в момент сбоя. Например, нажатие кнопки «Уменьшить» в счётчике. Этого достаточно, чтобы воспроизвести сбой, эта информация просто логируется и прикрепляется к отчету о сбое. Без однонаправленного потока данных было трудно узнать, например, что кто-то неправильно использовал EventBus и запустил нашу CounterModel непонятно куда. Без неизменяемости мы не могли бы вычислить, кто и где именно изменил нашу Модель.

Тестируемость

«Традиционное» использование MVP или MVVM улучшает тестируемость приложения. MVC тоже можно тестировать: не обязательно размещать всю бизнес-логику в Activity. С моделью, которая отражает состояние, мы можем упростить наши юнит-тесты, просто написав assertEquals(expectedModel, model).
Это избавит нас от создания множества «Mock» объектов.

Мы избавимся от тестов, подтверждающих, что конкретный метод был вызван, т.е.
Mockito.verify(view, times(1)).showFoo()). Нам будет проще читать, понимать и поддерживать код юнит-тестов — не придется работать с деталями реализации настоящего кода.

Заключение

В первой части из серии статей о MVI мы больше говорили о теоретических вещах. Зачем нужна отдельная статья про модель?

Модель помогает избежать ряда проблем:

  1. Проблемы состояния.
  2. Изменений ориентации экрана.
  3. Навигации по бэкстеку.
  4. Смерти процесса.
  5. Неизменяемости и однонаправленного потока данных.
  6. Отлаживания и воспроизведения состояний.
  7. Тестируемости.

В своих проектах разработчики по-разному называют бизнес-логику — Interactor, Usecase, Repository. Модель — это не бизнес-логика. Бизнес-логика производит модель.

В следующей статье мы посмотрим на всю эту теоретическую часть о Модели в работе. Для примера создадим реактивное приложение, где используем Model-View-Intent. Мы собираемся создать демо-приложение для вымышленного онлайн-магазина.