Подробный гайд по разработке Android-приложений с помощью Clean Architecture

Данный туториал поможет вам разобраться в очень полезном подходе по разработке приложений Clean Architecture.

С того времени, как я начал разработку Android-приложений, у меня сложилось впечатление, что разработку приложений можно было сделать лучше. За свою карьеру я сталкивался с множеством плохих решений, включая и свои собственные. Однако важно учиться на своих ошибках, чтобы не совершать их в дальнейшем. Я долго искал оптимальный подход к разработке и наконец наткнулся на Clean Architecture. После того, как я применил данный подход к Android-приложениям я решил, что это заслуживает внимания.

Целью статьи является предоставление пошаговой инструкции разработки Android-приложений с применением подхода Clean Architecture. Суть моего подхода заключается в том, что я на довольно успешных примерах покажу вам все достоинства Clean Architecture.

Что такое Clean Architecture?

Я не собираюсь вдаваться в подробности, потому что есть статьи, в которых это объясняется лучше, чем смогу сделать я. Однако в следующем абзаце рассматривается ключевой вопрос, который вам необходимо знать, чтобы понять, как устроен подход Clean Architecture.

Как правило, в Clean Architecture код разделен на несколько уровней, по структуре схожей со структурой обычного лука, с одним правилом зависимости: внутренний уровень не должен зависеть от каких-либо внешних уровней. Это означает, что зависимости должны указываться внутри каждого уровня, чтобы не было зависимостей между уровнями (слоями).

Далее приведена визуализация вышесказанного:

Clean Architecture, делает ваш код:
  • Независящим от фреймворков;
  • Тестируемым;
  • Независящим от UI;
  • Независящим от Базы данных;
  • Независимым от какого-либо внешнего воздействия.

Я надеюсь, что вам станет понятно, как каждый из этих пунктов достигается, за счет приведенных ниже примеров. Для более детального объяснения данного подхода я настоятельно рекомендую ознакомиться с этой статьей и данным видео.

Что это значит для Android?

Как правило, ваше приложение имеет произвольное количество уровней (слоев), однако если вам не нужна бизнес-логика Enterprise, то скорее всего у вас будет только 3 уровня:

  • Внешний: Уровень реализации;
  • Средний: Уровень интерфейса;
  • Внутренний: Уровень бизнес-логики.

Уровень реализации – это место где описывается основная структура приложения. Сюда входит любое содержимое Android такое, как: создание операций и фрагментов, отправка намерений, и другой структурный код наподобие сетевого кода и кода базы данных.

Целью уровня интерфейса является обеспечение взаимодействия/коммуникации между уровнем реализации и уровнем бизнес-логики.

Самым важным уровнем считается уровень бизнес логики. Данный уровень — это то, где вы фактически решаете поставленную задачу, собственно ради которой и создавалось приложение. Уровень бизнес-логики не содержит какого-либо структурного кода, и вы должны уметь запускать его без эмулятора. Таким образом, если вы будете придерживаться подобного подхода при построении бизнес-логики, то получится уровень легко тестируемый, разрабатываемый и его будет легко поддерживать. Пожалуй, это самая большая выгода при использовании Clean Architecture.

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

Почему преобразование является обязательным?

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

Еще одним примером может быть следующее: давайте скажем, что объект Cursor, принадлежит ContentProvider во внешнем уровне базы данных. Значит что внешний уровень, в первую очередь преобразует его в бизнес-модель внутреннего уровня, а затем уже отдаст его на обработку соответствующему уровню бизнес-логики.

Внизу статьи я добавлю больше ресурсов для изучения данного вопроса. Сейчас же мы уже знаем об основных принципах подхода Clean Architecture, а значит давайте «замараем» руки кодом. Далее я покажу вам как создать рабочий функционал с использованием Clean Architecture.

Как начать создание Чистых приложений?

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

Вы можете найти стартовый набор здесь: Шаблонный проект для создания Чистых приложений на Android.

Первые шаги по написанию новых прецедентов

Этот раздел будет объяснять весь необходимый вам код, для создания своих прецедентов с помощью подхода Clean Architecture, так скажем поверх шаблона, приведенного в предыдущем разделе. Прецедент (или Use Case) – это просто некоторый изолированный функционал приложения. Прецедент может быть запущен или не может быть запущен пользователем (например, по нажатию пользователя).

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

Структура

Общая структура Android-приложения выглядит, как показано ниже:

  • Пакеты внешнего уровня: Интерфейс пользователя, хранилище, сеть и т.д.;
  • Пакеты среднего уровня: Представители, конвертеры;
  • Пакеты внутреннего уровня: Interactors, модели, репозитории, исполнители.

Внешний уровень

Как уже было сказано, это то, где описываются детали структуры.

Интерфейс пользователя (UI) – Это то, куда вы помещаете все ваши Операции, Фрагменты, Адаптеры и любой другой Android-код, связанный с интерфейсом пользователя.

Хранилище – Отдельный код для базы данных, который реализует интерфейс наших Интеракторов, используемых для доступа к базе данных и для хранения данных. Например, сюда включается Поставщик контента или ORM-ы такие, как DBFlow.

Сеть – Вещи подобные Retrofit отправляются сюда.

Средний уровень

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

Представители (presenter) – представители обрабатывают события от UI (например, клик пользователя), и чаще всего работают как callback-и из внутренних уровней (Interactors).

Конвертеры – Преобразуют объекты, которые ответственны за конвертацию моделей внутреннего уровня в модели внешнего уровня и в обратном порядке.

Внутренний уровень

Основной уровень, который содержит самый высокоуровневый код. Все классы здесь являются POJO, то есть это простые Java-объекты, не унаследованные от какого-то специфического объекта и не реализующие никаких служебных интерфейсов сверх тех, которые нужны для бизнес-модели.

Классы и объекты данного уровня никак не оповещаются, что будут запущены именно в Android-приложении, поэтому их легко можно перенести на любую JVM.

Interactors – классы, которые фактически содержат код вашей бизнес-логики. Они запускаются в фоновом режиме и передают события верхнему уровню с помощью callback-ов. Их также называют Прецедентами (UseCases) в некоторых проектах, возможно, такое называние им больше подходит. Наличие множества небольших Interactor-классов в ваших проектах для решения определенных задач считается нормой. Это полностью соответствует принципу единственной ответственности и, как мне кажется, проще для восприятия и понимания.

Модели – это ваши бизнес-модели, которыми вы управляете в своей бизнес-логике.

Репозитории (repositories ) –  данный пакет включает в себя только те интерфейсы, которые реализованы с помощью базы данных или каких-либо других внешних уровней. Эти интерфейсы используются Interactor-классы для доступа и хранения данных. Это также называется паттерн Repository.

Исполнитель (executor) – данный пакет содержит код для запуска Interactor-классов в фоновом режиме с помощью рабочего потока-исполнителя. Чаще всего вам не придется изменять этот пакет.

Простой пример

В этом примере, нашим прецедентом будет: «Приветствие пользователя сообщением, когда приложение запущено и данное сообщение помещено в базу данных.» Данный пример будет наглядной демонстрацией того, как создать следующие пакеты, необходимые для корректной работы нашего прецедента:

  • Пакет представления;
  • Пакет хранилища;
  • Пакет домена.

Первые два пункта относятся к внешнему уровню, в то время как последний относится к внутреннему/основному уровню.

Пакет представления ответственен за все, что связано с отображением вещей на экране, он содержит весь стек шаблона проектирования MVP. Это означает, что он содержит в себе как UI, так и Presenter-пакеты, даже если они относятся к разным уровням.

Отлично – меньше слов, больше кода!

Создание нового Interactor-а (внутренний/основной уровень)

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

Итак, давайте начнем создание Interactor-а. Interactor – это то место, где располагается основная логика работы нашего прецедента. Все Interactor-ы запускаются в фоновом потоке, поэтому не должно быть никакого воздействия на производительность интерфейса пользователя. Давайте создадим новый Interactor с приятным названием «WelcomingInteractor».

public interface WelcomingInteractor extends Interactor { 
 
    interface Callback { 
 
        void onMessageRetrieved(String message);
 
        void onRetrievalFailed(String error);
    } 
}

Callback отвечает за общение с интерфейсом пользователя (UI) в основном потоке, мы помещаем его в интерфейс Interactor-а, поэтому нет необходимости в подобном названии «WelcomingInteractorCallback», чтобы отличать его от других callback-ов. Теперь реализуем логику получения сообщения. Давайте скажем, что у нас есть интерфейс MessageRepository, в котором будет наше сообщение приветствия.

public interface MessageRepository { 
    String getWelcomeMessage();
}

Далее реализуем этот интерфейс вместе с нашей бизнес-логикой. Важно, чтобы реализация наследовала AbstractInteractor, который отвечает за запуск нашего интерфейса в фоновом потоке.

public class WelcomingInteractorImpl extends AbstractInteractor implements WelcomingInteractor {	
    ...
    private void notifyError() {
        mMainThread.post(new Runnable() {
            @Override
            public void run() {
                mCallback.onRetrievalFailed("Nothing to welcome you with :(");
            }
        });
    }

    private void postMessage(final String msg) {
        mMainThread.post(new Runnable() {
            @Override
            public void run() {
                mCallback.onMessageRetrieved(msg);
            }
        });
    }
	
    @Override
    public void run() {
        // получение сообщения
        final String message = mMessageRepository.getWelcomeMessage();
        // проверяем, получили ли мы сообщение
        if (message == null || message.length() == 0) {
            // уведомляем об ошибке основной поток
            notifyError();
            return;
        }
       // мы получили наше сообщение, уведомляем об этом UI в основном потоке
        postMessage(message);
    }

Что же, взглянем на зависимости, создаваемые нашим Interactor:Этот фрагмент кода, пытается получить сообщение, затем переслать его или же отправить сообщение об ошибке интерфейсу пользователя, чтобы он отобразил сообщение или ошибку. Для этого мы уведомляем интерфейс пользователя с помощью нашего callback-а, который по факту и будет Presenter-ом. Собственно, в этом и заключается суть всей нашей бизнес-логики. Все что нам остается – это построить структурные зависимости.

import com.kodelabs.boilerplate.domain.executor.Executor; 	
import com.kodelabs.boilerplate.domain.executor.MainThread; 	
import com.kodelabs.boilerplate.domain.interactors.WelcomingInteractor; 	
import com.kodelabs.boilerplate.domain.interactors.base.AbstractInteractor; 	
import com.kodelabs.boilerplate.domain.repository.MessageRepository;

Как вы можете заметить, здесь нет ни одного упоминания о каком-либо Android-коде. Это и есть главное преимущество данного подхода. Также вы можете увидеть, что пункт: «Независимость от фреймворков» все также соблюдается. Кроме того, нам не нужно отдельно определять интерфейс пользователя или базу данных, мы просто вызываем методы интерфейса, которые кто-то, где-то на внешнем уровне реализует. Следовательно, мы независим от UI и независим от Базы данных.

Тестирование нашего Interactor-а

На данный момент мы можем запустить и начать тестирование нашего Interactor-а без запуска эмулятора. Поэтому давайте напишем простой Junit-тест, чтобы убедиться, что все работает:

...
    @Test
    public void testWelcomeMessageFound() throws Exception {

        String msg = "Welcome, friend!";
        when(mMessageRepository.getWelcomeMessage())
                .thenReturn(msg);
	
        WelcomingInteractorImpl interactor = new WelcomingInteractorImpl(
            mExecutor,
            mMainThread,
            mMockedCallback,
            mMessageRepository
        );
        interactor.run();
        Mockito.verify(mMessageRepository).getWelcomeMessage();
        Mockito.verifyNoMoreInteractions(mMessageRepository);
        Mockito.verify(mMockedCallback).onMessageRetrieved(msg);
    }

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

Создание уровня представления

Код представления относится ко внешнему уровню подхода Clean Architecture. Уровень представления состоит из структурно зависимого кода, который отвечает за отображение интерфейса пользователя, собственно, пользователю. Мы будем использовать класс MainActivity для отображения нашего приветствующего сообщения пользователю, когда приложение возобновляет свою работу.

Давайте начнем с создания интерфейса нашего Presenter и Отображения (View). Единственное, что должно делать наше отображение – это отображать приветствующее сообщение:

public interface MainPresenter extends BasePresenter { 
 
    interface View extends BaseView { 
        void displayWelcomeMessage(String msg);
    } 
}

Итак, как и где мы запускаем наш Interactor, когда приложение возобновляет работу? Все, что не имеет строгой привязки к отображению, должно помещаться в класс Presenter. Это помогает достичь принципа Разделения ответственности и предотвратить классы Операций от чрезмерного увеличения размера кода. Сюда включается весь код, который работает с Interactor-ми.

В нашем классе MainActivity мы переопределяем метод onResume():
@Override
protected void onResume() {
    super.onResume();  // начнем возврат приветствующего сообщения, при возобновлении работы приложения 
    mPresenter.resume();
}

Все Presenter-объекты реализуют метод resume(), при наследовании BasePresenter.

Примечание: Самые внимательные читатели могли заметить, что я добавил Android-методы жизненного цикла в интерфейс BasePresenter в качестве вспомогательных методов, хотя сам Presenter находится на более низком уровне. Наш Presenter должен знать все на уровне UI, к примеру, что что-то на этом уровне имеет жизненный цикл. Тем не менее, здесь я не указываю конкретное событие, так как каждый UI для конкретного пользователя может отрабатывать разные события, в зависимости от действий пользователя. Представьте, я назвал его onUIShow() вместо onResume(). Теперь все хорошо, верно? :)

Мы запускаем Interactor внутри класса MainPresenter в методе resume():
@Override
public void resume() {    mView.showProgress();    // инициализируем interactor
    WelcomingInteractor interactor = new WelcomingInteractorImpl(
            mExecutor,
            mMainThread, 
            this, 
            mMessageRepository
    );    // запускаем interactor
    interactor.execute();
}

Метод execute() просто выполняет метод run() объекта WelcomingInteractorImpl в фоновом потоке. Метод run() вы можете увидеть в разделе Создание нового Interactor.

Вы также могли заметить, что поведение Interactor-а схоже с поведением класса AsyncTask. Так как вы предоставляете все необходимое для его запуска и выполнения. Тут вы можете спросить, а почему мы не используем AsyncTask? Да потому что это Android-код, и вам нужен будет эмулятор для его запуска и тестирования.

Мы предоставляем несколько вещей нашему Interactor-у:

  • Экземпляр ThreadExecutor, который отвечает за выполенение Interactor-а в фоновом потоке. Я чаще всего создаю его как singleton. Этот класс также располагается внутри domain-пакета и нет необходимости реализовывать его во внешнем уровне;
  • Экземпляр MainThreadImpl, который отвечает за отправку запущенных потоков Interactor-а в главный поток приложения. Основные потоки имеют доступ к использованию определённого структурного кода и поэтому мы должны реализовывать их во внешнем уровне;
  • Также вы могли обратить внимание на то, что мы предоставляем this нашему Interactor-у. MainPresenter– это callback-объект, который используется Interactor-ом для уведомления UI о каких-либо событиях;
  • Кроме того, мы предоставляем экземпляр WelcomeMessageRepository, который отвечает за реализацию интерфейса MessageRepository, который в свою очередь использует Interactor. WelcomeMessageRepository будет рассмотрен позже, в разделе Создание уровня хранения.

Примечание: Поскольку существует множество вещей, которые необходимо связывать каждый раз с Interactor-ом, то будет полезен следующий фреймворк для внедрения зависимостей: Dagger 2 (и подобные ему). Но я его использую здесь не для того чтобы что-то упростить. Свою структуру вы вольны сами выбирать, и то какие фреймворки использовать также ваше право.

Что же касается this, то MainPresenter класса MainActivity действительно реализует callback-интерфейс:

public class MainPresenterImpl extends AbstractPresenter implements MainPresenter, WelcomingInteractor.Callback {

И далее то, как мы слушаем события от Interactor-а. Следующий код взят из MainPresenter:

@Override 
public void onMessageRetrieved(String message) {
    mView.hideProgress(); 
    mView.displayWelcomeMessage(message);
} 
 
@Override 
public void onRetrievalFailed(String error) {
    mView.hideProgress(); 
    onError(error);
}

Небольшие фрагменты View в этом куске кода – это и есть наш класс MainActivity, который реализует данный интерфейс:

public class MainActivity extends AppCompatActivity implements MainPresenter.View {

Который, в свою очередь, отвечает за отображение приветствующего сообщения на экран, как это можно увидеть в следующем фрагменте:

@Override 
public void displayWelcomeMessage(String msg) {
    mWelcomeTextView.setText(msg);
}

И вот этого всего будет вполне достаточно для уровня представления.

Создание уровня хранения

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

Для сложных данных вы можете использовать ContentProviders или такие ORM-инструменты, как: DBFlow. Если вам необходимо получать данные из Интернета, то Retrofit поможет вам. Если же вам необходимо простое хранилище по принципу ключ-значение, то вы можете использовать SharedPreferences. Запомните, вы должны использовать верный инструмент для работы.

Наша база данных на самом деле не совсем база данных. Это будет очень простой класс с некоторой симуляцией задержки:

public class WelcomeMessageRepository implements MessageRepository { 
    @Override 
    public String getWelcomeMessage() {
        String msg = "Welcome, friend!"; // let's be friendly
 
        // давайте симулируем некоторые сетевые/БД лаги
        try { 
            Thread.sleep(2000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        } 
 
        return msg;
    } 
}

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

Краткий итог

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

MainActivity->MainPresenter->WelcomingInteractor ->WelcomeMessageRepository->WelcomingInteractor->MainPresenter-> MainActivity

Очень важно запомнить порядок контроля:

Внешний — Средний — Основной — Внешний — Основной — Средний — Внешний

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

Заключение

Для меня подход Clean Architecture является лучшим способом разработки приложений. Разделенный код помогает сфокусироваться на определенных задачах, без большого нагромождения кода, то есть вы не страдаете лишней функциональной избыточностью. В конце концов, я думаю, что этот подход соответствует принципам SOLID, однако к нему придется немного привыкнуть, чтобы использовать весь его потенциал. Именно по этой причине я и написал все это, чтобы помочь людям лучше понять Clean Architecture, за счет пошаговых примеров.

Также я создал и предоставил доступ к исходному коду приложения «Отслеживание затрат», используя подход Clean Architecture для того, чтобы показать, как будет выглядеть код в реальном приложении. Ничего инновационного в данном приложении нет, но я считаю, что этого будет достаточно, чтобы пояснить все то, о чем было написано в данной статье. Кроме того, данное приложение содержит более сложные примеры, с которыми вы можете самостоятельно ознакомиться. А найти его вы сможете здесь.

И снова, пример из этой статьи, который был построен используя базовые принципы подхода Clean Architecture, вы можете найти здесь.

Дополнительная информация

Этот гайд является расширением данной прекрасной статьи. Разница заключается в том, что я использовал обычную Java в своих примерах, и вовсе не для того, чтобы как-то усложнить примеры. Если вам приятнее наблюдать примеры подхода Clean Architecture на RxJava, то можете посмотреть их здесь.

Дополнительные материалы по теме

Программирование под Android: 50 лучших инструментов
Разработка под Андроид: советы, инструменты и трюки
6 простых советов по написанию чистого кода

Ссылка на оригинальную статью
Перевод: Александр Давыдов

ЛУЧШИЕ СТАТЬИ ПО ТЕМЕ