[Перевод] Архитектура Android приложений

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


406847d4f66d455191e57d42b733e162.jpeg


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

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

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

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


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

07b39249d1cc46238784b89c752ed05d.png


Код был разделён на два уровня: уровень данных (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 — потоками выполнения), и предоставляет множество операторов, которые можно применять к потокам, чтобы трансформировать, фильтровать, или же комбинировать данные так, как вам нужно.

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

2936adc3328a41d7865eaa49832199b6.png


Код всё так же разделён на два уровня: уровень данных содержит 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 loadTodayPosts() {
        return mRetrofitService.loadPosts()
                .concatMap(new Func1, Observable>() {
                    @Override
                    public Observable call(List apiPosts) {
                        return mDatabaseHelper.savePosts(apiPosts);
                    }
                })
                .filter(new Func1() {
                    @Override
                    public Boolean call(Post post) {
                        return isToday(post.date);
                    }
                });
}


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

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

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


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

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


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


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

Пробуем Model View Presenter


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

62e4bb8a30134b178afffe7053b4f64d.png


Уровень данных остаётся неизменным, но теперь он называется моделью, чтобы соответствовать имени соответствующего уровня из 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>() {
                @Override
                public void onCompleted() {
                    mMvpView.showProgressIndicator(false);
                }

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

                @Override
                public void onNext(List 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 (прим. переводчика: перейдите на оригинальную статью, и нажмите на кнопку-сердечко в конце статьи). Также, я хотел бы выслушать ваши мысли по поводу нашего текущего подхода.

© Habrahabr.ru