CLEAN Architecture — example

Итак, вчера мы рассмотрели в общем и целом что такое чистая архитектура (настоятельно рекомендую к прочтению). Теперь же, перейдем от общих слов к конкретному коду (доступен по ссылке).

О чем проект? Начнем с того, что у спейсХ есть открытое апи и выглядит оно так — https://api.spacexdata.com/v2/launches?launch_year=2019 Можете перейти и посмотреть на джейсон ответа. Апи отдает всю информацию по запускам ракет по годам. Т.е. наше приложение предельно простое, есть поле ввода для года, получаем информацию по запускам ракет и отображаем в списке. Попутно кладем в кеш, но об этом подробнее ниже. Вообще проект писался исходя из примера Фернандо Сехаса — пример чистой архитектуры (ссылка).

Итак, как и вчера, начнем мы с того, что рассмотрим дата слой. В нем у нас 4 пакета — cache, entity, net, repository. Entity это обычный класс, который хранит в себе модель данных от сервера. Да-да, те самые сырые данные, которые мы получаем от сервера и они непригодны ни для чего, кроме как мапинга к удобочитаемым классам доменного слоя. Собственно поэтому и в пакете entity есть мапер. В чем конкретно неудобство сырых данных — в том, что их структура очень некрасивая. Если посмотреть на класс ентити, то можно обнаружить например лишнюю вложенность классов, или же одну кашу для ссылок разного типа — ссылки на википедию, ютуб, картинки и пдф.

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

В пакете net у нас лежат такие простые вещи как ConnectionMangaer, который просто проверяет наличие сети и сам сервис для получения данных от апи спейсХ. В нашем случае мы используем ретрофит, чтобы было все проще.

Далее нас интересует пакет кеша. В нем мы просто кешируем данные, которые получили от сервера в сыром виде. Так как у нас ключевым понятием является год запуска, то будем просто (на время, позже перепишем на Room возможно) класть в кеш SharedPreferences наши данные. Прошу заметить, что можно будет легко подменить реализацию кеша, так как у нас есть интерфейс и есть первая реализация на префах.

И самое интересное у нас в пакете repository. Так как у нас данные могут идти из 2 разных источников, то мы создаем некий интерфейс DataSource, у которого 2 имплементации — получение свежих данных от сервера и из кеша. Для того, чтобы выбрать нужный датасорс, мы написали фабрику источников данных. В которой заложена простая логика — если нам нужно отдать в следующий слой данные запусков ракет текущего года, то мы по возможности будем брать свежие данные от сервера, для всех других годов — по возможности из кеша, если ранее юзер вводил этот год и мы закешировали. Здесь еще нужно подметить, что на уровне дата слоя мы оперируем таким понятием как приоритет — от сервера или из кеша. На следующих уровнях это понятие недопустимо, так как человек, не связанный с кодингом напрямую не должен знать вообще такие вещи как сервер или кеш. Для него более простым понятием является флаг — свежие данные (об этом подробнее в домейн слое).

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

В следующей статье мы рассмотрим юнит тесты на все классы.

Идем дальше, следующий слой доменный, в котором лежат человеку понятные классые и удобочитаемый код. Структура здесь понятна — exception, interactor, repository, validator, data. Как мы и говорили, данные доменного слоя это данные которые удобны для оперирования ими, ровно так же как и исключения. Давайте посмотрим на них. 2 человеку понятных имени исключения — нет соединения с сервером и сервер недоступен. Все. В пакете репозитория лежит только интерфейс репозитория, ведь имплементацию мы оставили в дата слое.

Валидатор в нашем случае просто проверяет входные данные от пользователя. Некоторые спросят, почему он возвращает 3 состояния — true false null. Я заложил такую логику, что если символов недостаточно для проверки на то, является ли строка годом — 4значным числом, то мы и не начинаем ничего валидировать. Ведь согласитесь, зачем проверять строку меньше 4 символов если этого можно не делать. Здесь может возникнуть спорный вопрос. А должен ли лежать этот валидатор года в доменном слое, ведь он по сути проверяет ввод пользователя? Согласен, хороший вопрос. Но давайте пока посмотрим остальной код и решим, надо ли его из доменного слоя убирать или нет.

И самый важный пакет доменного слоя это интеракторы. Здесь тоже много холиваров по поводу наименования, одни решают называть их интеракторами, другие юзкейсами. Третьи считают, что интерактор это имплементация многих юзкейсов. Здесь вопрос стоит так — или в одном презентере/вьюмодели 1 интерактор, в котором множество юзкейсов, или же в одном презентере/вьюмодели несколько интеракторов (интерактор как интерфейс и как имплементация).
Сейчас рассмотрим LaunchesInteractor, потому что остальные интеракторы предельно просты — один показывает все результаты, другой детали запуска. Итак, наш интерактор должен принимать на вход строку года и что же он отдаст следующему слою? Так как слой домена это код, который можно понять любому человеку — статус операции — наше апи говорит нам, что случай когда не было ни одного запуска ракет в каком-то году вполне себе нормальный. Поэтому мы сделаем енам со статусами в котором 4 статуса — нет результатов, успех, сервер недоступен и нет соединения.

Теперь взглянем на реализацию интерфейса — в интеракторе мы говорим, что если текущий год совпал с введенным, то передаем этот флаг в репозиторий и пробуем получить данные по этому году 3 раза. Здесь красиво на мой взгляд зашла рекурсия — наш репозиторий может отдать нам ответ — нет данных или успех, или же нет соединения, сервер пал. Так вот — если наша операция закончилась ошибкой — мы попробуем еще 2 раза. На крайний случай, если у нас нет соединения на третий раз, то будут взяты данные из кеша, если они есть. Но на уровне домена это неважно, нам об этом знать не нужно. Мы только говорим, что нам нужны свежие данные в случае запроса текущего года. Остальное — не интересно.

Заметим, что в проекте используются корутины котлина и скажем спасибо разработчикам за то, что у нас нет колбеков как в джава. Так же не пришлось ради запрос ответа и повторного при случае ошибки пихать огромную либу РХ в наш грубо говоря хеловорлд. Хотя масштаб проекта не важен в нашем случае. На мой взгляд в 2019 году можно обойтись без либы РХ. И данный метод с рекурсией тому доказательство.

Итого, мы отдаем тому классу, который вызовет метод интерактора 4 статуса. Они простые и понятные любому человеку. Так что взлгянем на слой презентации.

Здесь у нас все просто — класс диай (который по сути сервис локатор, да знаю. холивар) и класс Апликейшна, где инициализируем наш диай обьект (аля синглтон в джава). Также мы будем запускать периодическую задачу которая каждые 2 часа будет проверять — есть ли соединение с интернетом и стоит ли девайс на зарядке — тогда она будет пытаться обновить данные по текущему году. Зачем это нужно спросите вы, если в нашем коде и так в первую очередь берутся данные из сети. Вот вам ответ — например вы не заходили месяц в приложение, а когда зашли — не было интернет соединения. Наш воркманагер от гугла за этот месяц пытался обновить данные и если у него это получилось, то вы узнаете о самых новых запусках ракет не имея возможности подключиться к мировой сети.

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

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

Если же произошла ошибка сервера, то мы имеем кнопку повторить попытку. В случае если нет явного соедиения с интернетом — мы запускаем ресивер, который проинформирует наш фрагмент о появлении интернета и автоматически повторит запрос.

Весь ввод пользователя происходит в едином тулбаре с помощью вью поиска, который из коробки схлопывается и расхлопывается. Посмотрим детально на класс вьюмодели мейн активити — MainScreenViewModel. Здесь у нас по сути 1 метод, получение данных. Если мы повторили запрос после неудачи, то повторим его имея заранее сохраненный ввод юзера. А для того, чтобы не отправлять на сервер много запросов, мы заюзаем нечто похожее на рх-овый дебаунс. Спасибо еще раз корутинам за это. Просто стартуем блок кода через 300мс, если пришел новый запрос, то отменяем старый. И когда я говорю отменяем, здесь мы его реально отменяем, а не отписываемся от результата как в случае с рх или другой любой асинхронщиной.

Далее происходит банальная вещь, проверяем с помощью интерактора введенную строку на валидность и после этого уже смотрим на результат нашей операции и в соответствии с этим подменяем нужный фрагмент или отправляем на юай сообщение об ошибке ввода. Все предельно просто и понятно. Еще раз поблагодарим гугл за компонент навигации — мы не писали здесь типичный код с фрагментМанагером. А гугловые вьюмодели дают нам такую замечательную вещь как viewModelScope, которая автоматически отменит все jobs корутин в методе onCleared.

И посмотрим на самое интересное в слое отображения — это мапинг данных от домейн слоя к презентационному. Мы от сервера получали огромный джейсон, который более менее исходя из бизнес логики мапили к таким обьектам как картинка, пдф, ссылка и т.д. Теперь же у нас стоит задача это все дело отобразить. Так как многие вещи могут быть нул, то и наш юай будет динамическим. Спасибо на этот раз андроид сдк за ресайклер с его вьютайпами. Смотрим в пакет presentation/details. Там у нас — о боже! — данные юай слоя. Да, это классы, которые хранят информацию именно в том виде, в котором нужно юай слою. т.е. конкретные строки и т.д.

Дальше мы мапим наш один класс от домейн слоя к списку из элементов. Элементом списка может быть отдельная информация по деталям запуска конкретной ракеты — начиная с информации о названии миссии, времени запуска, флага успешного запуска заканчивая медиа данными — картинкам, ссылками на википедию, ютуб, реддит и куда угодно плюс ссылка на пдф (в первом приближении откроем пдф файлик в стороннем приложении, но если у вас есть желание, можно воспользоваться интрументами андроид и отрендерить его для устройст от 5 версии).

Теперь, заключительная цепочка событий для полного понимания что, где и как происходит. На старте приложения инициализируются все необходимые классы для работы приложения, плюс стартует воркманагер.
Юзер вводит год запуска ракет, из searchView который в toolbar эта строка прокидывается в MainScreenViewModel. Если после ввода прошло 300мс и больше, то эта строка идет на валидацию в YearValidator через LaunchesInteractor. Если символов меньше 4, то ничего не происходит, если от 4 и более — валидатор вернет или true или false, где от viewModel стригерится LiveData errorState для ошибок в случае невалидного ввода. Если же введен валидный год, то мы тригерим LiveData progressState и отображаем прогресс. Далее запускаем главный метод у интерактора — fetch. Он в свою очередь проверяет год — если равен текущему, то пытается взять данные с сервера, иначе из кеша при наличии. Все это дело уходит в репозиторий, где в фабрике источников данных LaunchesDataStoreFactory осуществляется проверка наличия данных в кеше или приоритет запроса — из кеша или из облака. После чего вызывается метод получения данных у источника данных — getLaunchEntityList. При удачном получении данных из облака — мы положим данные в кеш и вернем их в репозиторий LaunchesRepositoryImpl, где смапим их к обьектам доменного слоя и вернем их в LaunchesInteractorImpl, где просто проверим на пустоту или получим ошибку. Далее, наша вьюмодель перенаправит нас на фрагмент с результатами поиска SearchResultsFragment. Где своя вьюмодель, с помощью своего уже интерактора получит из кеша данные, которые смапит сначала из слоя данных к слою домейн и потом уже получит просто имя миссии и отобразит в простом списке. Далее, если юзер тапает на любой элемент списка, мы передаем позицию в фрагмент детального отображения LaunchDetailsFragment, где получаем всю детальную информацию по запуску, сначала из кеша берем данные дата слоя, мапим их к домейн слою и после мапим к айтемам ресайклервью LaunchDetailsAdapter. Далее уже каждый вьюхолдер имеет всю нужную информацию юай слоя чтобы отобразить всю информацию.

Давайте посмотрим на скриншоты, чтобы было понятнее.

Стартовый фрагмент приложения
Ввод года
Список с результатами — названия миссий.
Детальная информация запуска ракеты.

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

Информация об удачном запуске, ссылки и картинки.

Я думал над тем, каким образом отобразить флаг об удачном запуске и остановился на чекбоксе. Для ссылок оставил типичную для веба underlined текст.

Еще немного картинок запуска ракеты.

Так как картинок может быть много, то я решил их как-то разграничить — простым отступом, почему бы и нет.

И теперь, подведем итоги. Все что я написал за условную неделю можно было бы сделать за 2 дня старым добрым говнокодом — god objects activity. Но я попробую доказать в следующей статье, что оно того стоило. Хотя мне кажется и сейчас довольно-таки понятно, что писать на чистой архитектуре стоит этого. Во-первых у вас весь код структуирован. Каждый слой занимается своим делом. Почти все классы содержат максимум 70 строк кода (исключение — маперы, там много преобразований из-за апи). Так же все классы (почти все) я покрыл тестами и в следующей статье мы поговорим о том, почему их нужно писать и как они помогают реально в работе разработчика.

А на этом у меня пока что все. Следите за серией статей. Они будут выходить часто и мне есть что рассказать. Если у вас возникли вопросы или у вас есть пожелания/предложения — прошу в телеграм @JohnnySC. Или же в чат андроид архитектуры t.me/Android_Architecture.

Запись опубликована в рубрике Программирование Java. Добавьте в закладки постоянную ссылку.