Наше путешествие от стандартных Activity и AsyncTask'ов к современной MVP архитектуре с применением RxJava.



Код проекта должен быть разделён на независимые модули, работающие друг с другом как хорошо смазанный механизм — фото Честера Альвареза.

Экосистема средств разработки под Android развивается очень быстро. Каждую неделю кто-то создаёт новые инструменты, обновляет существующие библиотеки, пишет новые статьи, или выступает с докладами. Если вы уедете в отпуск на месяц, то к моменту вашего возвращения уже будет опубликована свежая версия Support Library и/или Google Play Services.

Я занимаюсь разработкой Android-приложений в компании ribot в течение последних трёх лет, и всё это время и архитектура наших приложений, и используемые нами технологии, постоянно развивались и улучшались. Эта статья проведёт вас путём, пройденным нами, показав вынесенные нами уроки, совершенные нами ошибки, и рассуждения, которые привели ко всем этим архитектурным изменениям.

Старые добрые времена


В далёком 2012-м структура наших проектов выглядела очень просто. У нас не было никаких библиотек для работы с сетью, и AsyncTask всё ещё был нашим другом. Приведённая ниже диаграмма показывает примерную архитектуру тех решений:


Код был разделён на два уровня: уровень данных (data layer), который отвечал за получение/сохранение данных, получаемых как через REST API, так и через различные локальные хранилища, и уровень представления (view layer), отвечающий за обработку и отображение данных.

APIProvider предоставляет методы, позволяющие активити и фрагментам взаимодействовать с REST API. Эти методы используют URLConnection и AsyncTask, чтобы выполнить запрос в фоновом потоке, а потом доставляют результаты в активити через функции обратного вызова. Аналогично работает и CacheProvider: есть методы, которые достают данные из SharedPreferences или SQLite, и есть функции обратного вызова, которые возвращают результаты.

Проблемы


Главная проблема такого подхода состоит в том, что уровень представления имеет слишком много ответственности. Давайте представим простой сценарий, в котором приложение должно загрузить список постов из блога, закешировать их в SQLite, а потом отобразить в ListView. Activity должна сделать следующее:

  1. Вызвать метод APIProvider#loadPosts(Callback).
  2. Подождать вызова метода onSuccess() в переданном Callback'е, и потом вызвать CacheProvider#savePosts(Callback).
  3. Подождать вызова метода onSuccess() в переданном Callback'е, и потом отобразить данные в ListView.
  4. Отдельно обработать две возможные ошибки, которые могут возникнуть как в APIProvider, так и в CacheProvider.

И это ещё простой пример. В реальной жизни может случиться так, что API вернёт данные не в том виде, в котором их ожидает наш уровень представления, а значит Activity должна будет как-то трансформировать и/или отфильтровать данные прежде, чем сможет с ними работать. Или, например, loadPosts() будет принимать аргумент, который нужно откуда-то получить (например, адрес электронной почты, который мы запросим через Play Services SDK). Наверняка SDK будет возвращать адрес асинхронно, через функцию обратного вызова, а значит у нас теперь есть три уровня вложенности функций обратного вызова. Если мы продолжим наворачивать всё больше и больше сложности, то в итоге получим то, что называется callback hell.

Просуммируем:

  • Активити и фрагменты становятся слишком здоровенными и трудно поддерживаемыми
  • Слишком много уровней вложенности приводят к тому, что код становится уродливым и недоступным для понимания, что приводит к усложнению добавления нового фун��ционала или внесения изменений.
  • Юнит-тестирование затрудняется (если не становится вообще невозможным), так как много логики находится в активити или фрагментах, которые не очень-то располагают к юнит-тестированию.

Новая архитектура с применением RxJava


Мы использовали описанный выше подход на протяжении двух лет. В течение этого времени мы внесли несколько изменений, смягчивших боль и страдания от описанных проблем. Например, мы добавили несколько вспомогательных классов, и вынесли в них часть логики, чтобы разгрузить активити и фрагменты, а также мы начали использовать Volley в APIProvider. Несмотря на эти изменения, код всё так же был трудно тестируемым, и callback-hell периодически прорывался то тут, то там.

Ситуация начала меняться в 2014-м году, когда мы прочли несколько статей по RxJava. Мы попробовали её на нескольких пробных проектах, и осознали, что решение проблемы вложенных функций обратного вызова, похоже, найдено. Если вы не знакомы с реактивным программированием, то рекомендуем прочесть вот это введение. Если коротко, RxJava позволяет вам управлять вашими данными через асинхронные потоки (прим. переводчика: в данном случае имеются в виду потоки как streams, не путать с threads — потоками выполнения), и предоставляет множество операторов, которые можно применять к потокам, чтобы трансформировать, фильтровать, или же комбинировать данные так, как вам нужно.

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


Код всё так же разделён на два уровня: уровень данных содержит DataManager и набор классов-помощников, уровень представления состоит из классов Android SDK, таких как Activity, Fragment, ViewGroup, и так далее.

Классы-помощники (третья колонка в диаграмме) имеют очень ограниченные области ответственности, и реализуют их в последовательной манере. Например, большинство проектов имеют классы для доступа к REST API, чтения данных из бд или взаимодействия с SDK от сторонних производителей. У разных приложений будет разный набор классов-помощников, но наиболее часто используемыми будут следующие:

  • PreferencesHelper: работает с данными в SharedPreferences.
  • DatabaseHelper: работает с SQLite.
  • Сервисы Retrofit, выполняющие обращения к REST API. Мы начали использовать Retrofit вместо Volley, потому что он поддерживает работу с RxJava. Да и API у него поприятнее.

Многие публичные методы классов-помощников возвращают RxJava Observables.

DataManager является центральной частью новой архитектуры. Он широко использует операторы RxJava для того, чтобы комбинировать, фильтровать и трансформировать данные, полученные от помощников. Задача DataManager состоит в том, чтобы освободить активити и фрагменты от работы по «причёсыванию» данных — он будет производить все нужные трансформации внутри себя и отдавать наружу данные, готовые к отображению.

Приведённый ниже код показывает, как может выглядеть какой-нибудь метод из DataManager. Работает он следующим образом:

  1. Загружает список постов через Retrofit.
  2. Кеширует данные в локальной базе данных через DatabaseHelper.
  3. Фильтрует посты, отбирая те, что были опубликованы сегодня, так как уровень представления должен отобразить лишь их.

public Observable<Post> loadTodayPosts() {
        return mRetrofitService.loadPosts()
                .concatMap(new Func1<List<Post>, Observable<Post>>() {
                    @Override
                    public Observable<Post> call(List<Post> apiPosts) {
                        return mDatabaseHelper.savePosts(apiPosts);
                    }
                })
                .filter(new Func1<Post, Boolean>() {
                    @Override
                    public Boolean call(Post post) {
                        return isToday(post.date);
                    }
                });
}

Компоненты уровня представления будут просто вызывать этот метод и подписываться на возвращенный им Observable. Как только подписка завершится, посты, возвращённые полученным Observable могут быть добавлены в Adapter, чтобы отобразить их в RecyclerView или чём-то подобном.

Последний элемент этой архитектуры это event bus. Event bus позволяет нам запускать сообщения о неких событиях, происходящих на уровне данных, а компоненты, находящиеся на уровне представления, могут подписываться на эти сообщения. Например, метод signOut() в DataManager может запустить сообщение, оповещающее о том, что соответствующий Observable завершил свою работу, и тогда активити, подписанные на это событие, могут перерисовать свой интерфейс, чтобы показать, что пользователь вышел из системы.

Чем этот подход лучше?


  • Observables и операторы из RxJava избавляют нас от вложенных функций обратного вызова.


  • DataManager берёт на себя работу, которая ранее выполнялась на уровне представления, разгружая таким образом активити и фрагменты.
  • Перемещение части кода в DataManager и классы-помощники делает юнит-тестирование активити и фрагментов более простым.
  • Ясное разделение ответственности и выделение DataManager как единственной точки взаимодействия с уровнем данных делает всю архитектуру более дружественной к тестированию. Классы-помощники, или DataManager, могут быть легко подменены на специальные заглушки.

А как��е проблемы остались?


  • В больших и сложных проектах DataManager может стать слишком раздутым, и поддержка его существенно затруднится.
  • Хоть мы и сделали компоненты уровня представления (такие, как активити и фрагменты) более легковесными, они всё ещё содержат заметное количество логики, крутящейся около управления подписками RxJava, анализа ошибок, и прочего.


Пробуем Model View Presenter


В течение прошлого года в Android-сообществе начали набирать популярность отдельные архитектурные шаблоны, так как MVP, или MVVM. После исследования этих шаблонов в тестовом проекте, а также отдельной статье, мы обнаружили, что MVP может привнести значимые изменения в архитектуру наших проектов. Так как мы уже разделили код на два уровня (данных и представления), введение MVP выглядело натурально. Нам просто нужно было добавить новый уровень presenter'ов, и перенести в него часть кода из представлений.


Уровень данных остаётся неизменным, но теперь он называется моделью, чтобы соответствовать имени соответствующего уровня из MVP.

Presenter'ы отвечают за загрузку данных из модели и вызов соответствующих методов на уровне представления, когда данные загружены. Presenter'ы подписываются на Observables, возвращаемые DataManager. Следовательно, они должны работать с такими сущностями как подписки и планировщики. Более того, они могут анализировать возникающие ошибки, или применять дополнительные операторы к потокам данных, если необходимо. Например, если нам нужно отфильтровать некоторые данные, и этот фильтр скорее всего нигде больше использоваться не будет, есть смысл вынести этот фильтр на уровень presenter'а, а не DataManager.

Ниже представлен один из методов, которые могут находиться на уровне presenter'а. Тут происходит подписка на Observable, возвращаемый методом dataManager.loadTodayPosts(), который мы определили в предыдущем разделе.

public void loadTodayPosts() {
    mMvpView.showProgressIndicator(true);
    mSubscription = mDataManager.loadTodayPosts().toList()
            .observeOn(AndroidSchedulers.mainThread())
            .subscribeOn(Schedulers.io())
            .subscribe(new Subscriber<List<Post>>() {
                @Override
                public void onCompleted() {
                    mMvpView.showProgressIndicator(false);
                }

                @Override
                public void onError(Throwable e) {
                    mMvpView.showProgressIndicator(false);
                    mMvpView.showError();
                }

                @Override
                public void onNext(List<Post> postsList) {
                    mMvpView.showPosts(postsList);
                }
            });
    }

mMvpView — это компонент уровня представления, с которым работает presenter. Обычно это будет Activity, Fragment или ViewGroup.

Как и в предыдущей архитектуре, уровень представления содержит стандартные компоненты из Android SDK. Разница в том, что теперь эти компоненты не подписываются напрямую на Observables. Вместо этого они имплементируют интерфейс MvpView, и предоставляют список внятных и понятных методов, таких как showError() или showProgressIndicator(). Компоненты уровня представления отвечают также за обработку взаимодействия с пользователем (например, события нажатия), и вызов соответствующих методов в presenter'е. Например, если у нас есть кнопка, которая загружает список постов, наша Activity должна будет вызвать в OnClickListener'е метод presenter.loadTodayPosts().

Если вы хотите взглянуть на работающий пример, то можно заглянуть в наш репозиторий на Github. Ну а если захотелось большего, то можете посмотреть наши рекомендации по построению архитектуры.

Чем этот подход лучше?


  • Активити и фрагменты становятся ещё более легковесными, так как их работа сводится теперь к отрисовке/обновлению пользовательского интерфейса и обработке событий взаимодействия с пользователем. Тем самым, их становится ещё проще поддерживать.
  • Писать юнит-тесты для presenter'ов очень просто — нужно просто замокировать уровень представления. Раньше этот код был частью уровня представления, и провести его юнит-тестирование не представлялось возможным. Архитектура становится ещё более тестируемой.
  • Если DataManager становится слишком раздутым, мы всегда можем перенести часть кода в presenter'ы.

А какие проблемы остались?


  • В случае большого количества кода DataManager всё так же может стать слишком раздутым. Пока что это не произошло, но мы не зарекаемся от подобного развития событий.



Важно упомянуть, что описанный мною ��одход не является идеалом. Вообще было бы наивно полагать, что есть где-то та самая уникальная и единственная архитектура, которая возьмёт да и решит все ваши проблемы раз и навсегда. Экосистема Android'а будет продолжать развиваться с высокой скоростью, а мы должны будем держаться в курсе событий, исследуя, читая и экспериментируя. Зачем? Чтобы продолжать делать отличные Android-приложения.

Я надеюсь, вам понравилась моя статья, и вы нашли её полезной. Если так, не забудьте нажать на кнопку Recommend (прим. переводчика: перейдите на оригинальную статью, и нажмите на кнопку-сердечко в конце статьи). Также, я хотел бы выслушать ваши мысли по поводу нашего текущего подхода.