В первой части мы обсудили что такое модель, ее связь с состоянием и то, как правильно спроектированная модель помогает решить некоторые проблемы в разработке под Android. В этой статье мы продолжим наш путь к созданию реактивных приложений с помощью паттерна Model-View-Intent.
Прежде, чем начать, вкратце обсудим основную идею MVI.
Model-View-Intent (MVI)
Этот паттерн был описан Андре Штальтцем (André Staltz) для JavaScript-фреймворка cycle.js. С теоретической и математической точки зрения MVI можно описать следующим образом:
- intent(): Функция, которая принимает входные данные от пользователя (например, события пользовательского интерфейса, такие как события click) и переводит в то, что будет передано как параметр функции model(). Это может быть простая строка для установки значения модели или более сложная структура данных, например, объект.
- model(): Функция, которая использует выходные данные из функции intent() в качестве входных данных для работы с моделью. Результат работы этой функции – новая модель (с измененным состоянием). При этом нужно, чтобы данные были неизменяемыми. В первой части я приводил пример с приложением-счетчиком: мы не меняем уже существующий экземпляр модели, а создаем новую модель согласно изменениям, описанным в интенте. Функция model() – лишь часть кода, ответственная за создание нового объекта модели. По сути, функция model() осуществляет вызов бизнес-логики приложения (будь-то Interactor, UseCase, Repository) и в результате возвращает новый объект модели.
- view(): Функция, которая получает на входе модель от model() и просто отображает ее. Обычно функция view() выглядит как view.render(model).
Вернемся к нашей задаче. Мы хотим создать реактивное приложение. Но реактивен ли MVI? Что же в действительности означает реактивность в этом контексте?
Под реактивностью мы понимаем приложение с UI, которое реагирует на изменение состояния. Поскольку состояние отражает модель, то необходимо, чтобы наша бизнес-логика реагировала на события (интенты), а на выходе создавала модель, которую можно было бы отобразить во View, вызвав метод render(model).
Соединяем точки с помощью RxJava
Нам нужно, чтобы поток данных был однонаправленным. Здесь в игру вступает RxJava. При создании реактивных приложений с однонаправленным потоком данных не обязательно использовать именно эту библиотеку. Однако RxJava хорошо подходит для событийного программирования. И поскольку UI основан на событиях, имеет смысл использовать именно ее.
В этой статье я опишу создание простого приложения для вымышленного интернет-магазина. В приложение можно будет искать продукты и добавлять их в корзину.
Готовое приложение будет выглядеть так:
Исходный код можно найти на GitHub.
Начнем с реализации экрана поиска. Первым делом я определяю модель, которая будет отображена с помощью View, как описано в первой части этого цикла статей. Все классы моделей я буду писать с суффиксом ViewState, поскольку модель отражает состояние.
public interface SearchViewState {
final class SearchNotStartedYet implements SearchViewState {}
final class Loading implements SearchViewState {}
final class EmptyResult implements SearchViewState {
private final String searchQueryText;
public EmptyResult(String searchQueryText) {
this.searchQueryText = searchQueryText;
}
public String getSearchQueryText() {
return searchQueryText;
}
}
final class SearchResult implements SearchViewState {
private final String searchQueryText;
private final List<Product> result;
public SearchResult(String searchQueryText, List<Product> result) {
this.searchQueryText = searchQueryText;
this.result = result;
}
public String getSearchQueryText() {
return searchQueryText;
}
public List<Product> getResult() {
return result;
}
}
final class Error implements SearchViewState {
private final String searchQueryText;
private final Throwable error;
public Error(String searchQueryText, Throwable error) {
this.searchQueryText = searchQueryText;
this.error = error;
}
public String getSearchQueryText() {
return searchQueryText;
}
public Throwable getError() {
return error;
}
}
Java – строго типизированный язык, поэтому я выбрал типобезопасный подход к созданию модели, разделив каждое подсостояние внутри класса. Бизнес-логика будет возвращать объект типа SearchViewState, который может быть экземпляром SearchViewState.Error и т.д. Это мое личное предпочтение, вы можете проектировать модель по-своему.
Сосредоточимся на бизнес–логике. Создадим SearchInteractor, который будет отвечать за поиск. Результатом выполнения будет объект SearchViewState.
public class SearchInteractor {
final SearchEngine searchEngine;
public Observable<SearchViewState> search(String searchString) {
if (searchString.isEmpty()) {
return Observable.just(new SearchViewState.SearchNotStartedYet());
}
return searchEngine.searchFor(searchString)
.map(products -> {
if (products.isEmpty()) {
return new SearchViewState.EmptyResult(searchString);
} else {
return new SearchViewState.SearchResult(searchString, products);
}
})
.startWith(new SearchViewState.Loading())
.onErrorReturn(error -> new SearchViewState.Error(searchString, error));
}
}
Посмотрим на сигнатуру метода SearchInteractor.search(): есть входной параметр searchString и выходной параметр Observable<SearchViewState>. Это говорит о том, что на наблюдаемом потоке мы ожидаем произвольное количество экземпляров SearchViewState. Метод startWith() нужен для того, чтобы заэмитить SearchViewState.Loading перед тем, как начать поисковый запрос. Тогда View сможет показать progressBar во время выполнения поиска.
Метод onErrorReturn() ловит любые исключения, которые могут возникнуть во время выполнения поиска, и эмитит SearchViewState.Error. Мы не можем просто использовать колбэк onError() при подписке на Observable. Это распространенное заблуждение в RxJava: колбэк onError() нужно использовать тогда, когда весь наблюдаемый поток наталкивается на неустранимые ошибки и весь поток завершается.
В нашем случае ошибка отсутствия подключения к интернету не попадает под определение неустранимых ошибок – это просто одно из состояний нашей модели. Кроме того, мы сможем переключиться на другое состояние – SearchViewState.Loading, – после того, как подключение к интернету снова будет доступно.
Таким образом мы создаем наблюдаемый поток из бизнес–логики во View, который эмитит новую модель каждый раз, когда изменяется состояние. Нам не нужно, чтобы наблюдаемый поток завершался при ошибке подключения к интернету, поэтому такие ошибки обрабатываются как состояние. Обычно в MVI наблюдаемый поток никогда не завершается (не вызываются методы onComplete или onError()).
Подводя итог: SearchInteractor предоставляет наблюдаемый поток Observable<SearchViewState> и эмитит новый SearchViewState каждый раз при изменении состояния.
Рассмотрим, как выглядит слой View, который должен отобразить модель. Ранее я предложил, чтобы View имела функцию render(model). Кроме того, View должна предоставлять возможность другим слоям реагировать на события UI. В MVI эти события называются интенты. В нашем случае есть только один интент: пользователь ищет продукт путем ввода поискового запроса в поле поиска. В MVP есть хорошая практика создавать интерфейс для слоя View, поступим также и для MVI.
public interface SearchView {
Observable<String> searchIntent();
void render(SearchViewState viewState);
}
В нашем случае View предоставляет только один интент, но в зависимости от задачи их может быть несколько.
В первой части мы обсудили, почему использование единственного метода render() является хорошим решением. Перед созданием конкретной реализации слоя View посмотрим на то, как будет выглядеть окончательный вариант:
public class SearchFragment extends Fragment implements SearchView {
@BindView(R.id.searchView) android.widget.SearchView searchView;
@BindView(R.id.container) ViewGroup container;
@BindView(R.id.loadingView) View loadingView;
@BindView(R.id.errorView) TextView errorView;
@BindView(R.id.recyclerView) RecyclerView recyclerView;
@BindView(R.id.emptyView) View emptyView;
private SearchAdapter adapter;
@Override
public Observable<String> searchIntent() {
return RxSearchView.queryTextChanges(searchView)
.filter(queryString -> queryString.length() > 3 || queryString.length() == 0)
.debounce(500, TimeUnit.MILLISECONDS);
}
@Override
public void render(SearchViewState viewState) {
if (viewState instanceof SearchViewState.SearchNotStartedYet) {
renderSearchNotStarted();
} else if (viewState instanceof SearchViewState.Loading) {
renderLoading();
} else if (viewState instanceof SearchViewState.SearchResult) {
renderResult(((SearchViewState.SearchResult) viewState).getResult());
} else if (viewState instanceof SearchViewState.EmptyResult) {
renderEmptyResult();
} else if (viewState instanceof SearchViewState.Error) {
renderError();
} else {
throw new IllegalArgumentException("Don't know how to render viewState " + viewState);
}
}
private void renderResult(List<Product> result) {
TransitionManager.beginDelayedTransition(container);
recyclerView.setVisibility(View.VISIBLE);
loadingView.setVisibility(View.GONE);
emptyView.setVisibility(View.GONE);
errorView.setVisibility(View.GONE);
adapter.setProducts(result);
adapter.notifyDataSetChanged();
}
private void renderSearchNotStarted() {
recyclerView.setVisibility(View.GONE);
loadingView.setVisibility(View.GONE);
errorView.setVisibility(View.GONE);
emptyView.setVisibility(View.GONE);
}
private void renderLoading() {
recyclerView.setVisibility(View.GONE);
loadingView.setVisibility(View.VISIBLE);
errorView.setVisibility(View.GONE);
emptyView.setVisibility(View.GONE);
}
private void renderError() {
recyclerView.setVisibility(View.GONE);
loadingView.setVisibility(View.GONE);
errorView.setVisibility(View.VISIBLE);
emptyView.setVisibility(View.GONE);
}
private void renderEmptyResult() {
recyclerView.setVisibility(View.GONE);
loadingView.setVisibility(View.GONE);
errorView.setVisibility(View.GONE);
emptyView.setVisibility(View.VISIBLE);
}
}
Метод render(SearchViewState) должен выглядеть лаконично. В searchIntent() я использую библиотеку RxBindings. RxSearchView.queryText() создает Observable, который эмитит строку каждый раз, когда пользователь что-то вводит в виджет EditText. Я использую filter(), чтобы поисковый запрос начинался после ввода трех символов и более. Нам не нужно, чтобы поисковый запрос отправлялся на сервер каждый раз, когда пользователь вводит новый символ, поэтому я добавил оператор debounce().
Мы знаем, что входной поток данных для данного экрана – метод searchIntent(), а выходной поток данных – метод render().
Следующее видео наглядно демонстрирует, как происходит взаимодействие между двумя этими потоками.
Остался вопрос, как связать интент и бизнес–логику? Если вы внимательно посмотрите видео, то увидите оператор flatMap() посередине. Это указывает на наличие дополнительного компонента, о котором я не говорил – Presenter, – отвечающего за соединения слоев.
public class SearchPresenter extends MviBasePresenter<SearchView, SearchViewState> {
private final SearchInteractor searchInteractor;
@Override protected void bindIntents() {
Observable<SearchViewState> search =
intent(SearchView::searchIntent)
.switchMap(searchInteractor::search) // на видео я использовал flatMap(), но здесь имеет смысл использовать switchMap()
.observeOn(AndroidSchedulers.mainThread());
subscribeViewState(search, SearchView::render);
}
}
Что такое MviBasePresenter, методы intent() и subscribeViewState(). Этот класс – часть библиотеки Mosby. Стоит сказать несколько слов о Mosby и о том, как работает MviBasePresenter. Начнем с жизненного цикла: у MviBasePresenter его нет. Метод bindIntent() связывает интент из View с бизнес–логикой. Как правило для пересылки интента в бизнес-логику используется flatMap() или switchMap(). Этот метод вызывается единожды, когда View присоединяется к Presenter, но не вызывается после того, как View вновь присоединится к Presenter, например, после изменение ориентации экрана.
Может возникнуть вопрос, действительно ли MviBasePresenter может пережить изменение ориентации экрана, и если да, то каким образом Mosby гарантирует, что наблюдаемый поток не “утекает”? Для этого и предназначены методы intent() и subscribeViewState().
intent() создает PublishSubject внутри Presenter и использует его как “шлюз” для бизнес–логики. PublishSubject подписывается на интент View. Вызов интента (О1) на самом деле возвращает PublishSubject, который подписан на О1.
После изменения ориентации экрана Mosby отсоединяет View от Presenter, но только временно отписывает внутренний PublishSubject из View и повторно переподпиcывает PublishSubject на интент View, когда View вновь присоединяется к Presenter.
subscribeViewState() делает то же самое в обратную сторону. Он создает внутри Presenter BehaviorSubject в качестве “шлюза” от бизнес–логики ко View. Так как это BehaviorSubject, мы можем получить обновленную модель из бизнес–логики даже когда View отсоединена от Presenter. BehaviorSubject всегда хранит последнее значение, которое он получил, и повторяет его, когда View вновь присоеденится к Presenter.
Простое правило: используйте метод intent() чтобы обернуть любой интент. Используйте subscribeViewState() вместо Observable.subscribe(…).
Как насчет других событий жизненного цикла, например, onPause() или onResume()? Я по–прежнему считаю, что презентеру не нужны события жизненного цикла. Однако если вы действительно думаете, что они вам нужны, то можете создать их как интент. В вашем View появится pauseIntent(), запуск которого инициирует жизненный цикл Android, а не действие пользователя.
Заключение
В этой части мы поговорили об основах MVI и реализовали простой экран. Вероятно, этот пример слишком прост, чтобы понять все преимущества MVI. Нет ничего плохого в MVP или MVVM, и я не говорю, что MVI лучше, чем другие архитектурные шаблоны. Тем не менее, я считаю, что MVI помогает нам писать более элегантный код для сложных проблем, как мы увидим в следующей части, в которой поговорим о state reducer.
Автор: valpostnov