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

Комментарии (12)


  1. Dimezis
    15.03.2016 11:55
    +2

    Есть ли какая-то причина почему вы используете Otto Bus, вместо шины на Rx?

    а "mMvp..." это шикарно :)
    Почему все решили, что надо везде фигачить эти префиксы m? Это паттерн для разработки Android SDK. И антипаттерн для всего остального. Даже Гугл об этом говорит.


    1. artemgapchenko
      15.03.2016 12:12

      Есть ли какая-то причина почему вы используете Otto Bus, вместо шины на Rx?

      Вопрос лучше адресовать автору оригинальной статьи.

      Почему все решили, что надо везде фигачить эти префиксы m?

      Насчет того, что "Гугл об этом говорит", я не уверен (если и говорит, то делает это крайне непоследовательно). Если посмотреть примеры от Гугла, то там венгерская нотация используется и в хвост и гриву. Официальная документация от Гугла тоже использует эту нотацию. В многих учебниках по Android от сторонних авторов, не связанных с Google, она тоже используется. В общем в начале изучения разработки под Android эту привычку проще подхватить, чем не заметить.


      1. Dimezis
        15.03.2016 12:25

        Пардон, не заметил, что перевод


      1. Yoto
        15.03.2016 12:41
        +4

        На самом деле, Гугл против венгерской нотации. (с) Jake Wharton


        1. artemgapchenko
          15.03.2016 12:47
          +2

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


        1. orcy
          15.03.2016 21:02

          Прочитал, но не понял в чем прикол. Автор объясняет почему можно не использовать венгерскую нотацию даже несмотря на то что AOSP ее использует, однако не объясняет что в ней плохого среди возможных стилей оформления кода.


          1. ookami_kb
            16.03.2016 13:54
            +2

            Что в ней плохого:

            1. Она в большинстве случаев бесполезна.
            2. При рефакторинге легко забыть обновить эту информацию, и тогда она становится еще и вредной.

            Он вроде половину статьи именно об этом и говорит.


            1. orcy
              17.03.2016 20:15

              Нотация из AOSP вроде mFieldName, sFieldName по моим ощущением помогает при взгляд не код, что переменная является членом класса, а не локальной переменной. Занимает одну букву, сложности рефакторинга по моему надуманы.

              Я не то чтобы категорично за, но какого-то категоричного научного вреда от нее тоже не вижу.


              1. ookami_kb
                17.03.2016 20:25

                Сложности рефакторинга совсем даже не надуманы. В больших проектах с несколькими людьми разного уровня подготовки эти сложности возникают чуть реже чем всегда. Люди забывают об этом, и это нормально, просто это надо учитывать.

                Нормальные IDE и так выделяют цветом/начертанием локальные/статичные и т.д. переменные, так что тоже хватает одного взгляда; поэтому лично я тут за нормализацию – раз той же степени визуализации можно добиться, не прибегая к избыточности данных, то это стоит делать.


                1. orcy
                  17.03.2016 23:21

                  Например у меня vim и crucible не отличают контекст определения полей, так что AOSP style помогает понять откуда берется. Насколько я знаю в тулзах вроде gerrit и github pull request тоже подсветка не такая продвинутая, а code review делать надо.

                  Проблем рефакторинга я все же не вижу, если где-то и s,m установленные неправильно, исправление будет тривиальным и чаще всего безопасным. Поля это обычно приватные поля класса (кроме публичных констант), так что контекст исправления будет чаще всего небольшим (один файл).


                  1. ookami_kb
                    17.03.2016 23:42

                    Например у меня vim и crucible не отличают контекст определения полей, так что AOSP style помогает понять откуда берется.

                    Это если префиксы установлены правильно.

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


                    1. orcy
                      18.03.2016 14:22

                      Да, со всем согласен