Архитектура Android. Понятно и подробно

Понятно

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

6e1f767b854b26c6e083bd9bc167fc0b.png

  • Рассматриваем как подсистему. Android приложение — часть функциональности бизнеса.

  • Проектируем как систему. Android приложение — функциональность представления.

  • Разработку начинаем с центра. Выносим по краям только необходимый минимум и фиксируем контрактами.

  • Любые горизонтальные связи запрещены.

  • Запрещены сквозные зависимости.
    Screen → Data-Api или Data-Impl → Feature-Api

  • Модели контрактов (интерфейсы FeatureCase и Repository) всегда должны быть разными моделями. Выносить в общий my-common модуль запрещено.

  • UseCase является необязательной сущностью, как и модуль usecase.

    • Модуль usecase может проксировать зависимость одного feature-api.

    • feature модули могут поглотить в себя UseCase, если это не нарушит других

  • Модули usecase,  feature-api,  feature-impl и data-api строго являются котлин модулями.

  • ViewModel на одно действие пользователя может использовать не больше одного FeatureCase/UseCase.

  • Трансформация между внешней структурой данных (DTO) и внутренней для нашей системы (из интерфейса Repository) происходит в DataSource.

Чтобы стало Понятно, прочитайте блок Подробно. И только после вернитесь к Понятно.

Предисловие

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

Главная стратегия архитектуры в том, чтобы как можно дольше иметь как можно больше вариантов. (Роберт Мартин. Чистая архитектура с. 145)

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

Качество системы можно определить только после продолжительной эксплуатации.

Важно

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

Колесо

Итак вопрос: Как спроектировать качественную архитектуру системы?
Ответ: Начать с нуля и по этапам.

Дело в том, что построить «как-то», посмотреть что получилось и потом пытаться решить появившиеся проблемы, это хорошая тактика. Но не очень продуктивная, если переодически не делать переосмысление, чтобы учесть все полученные знания за это время.

— Может не стоит изобретать колесо?

Вот вам история:
Чемодан был придуман в середине 19 века (примерно 1850).
Колеса к чемодану прикрутили только в 1972 году. Это были 4 колеса внизу вертикально стоящего чемодана, который тянули за веревку. Из-за этого чемодан постоянно норовил уехать в сторону.
Но использовать это изобретение стали только через десятки лет после этого.
И только в 1989 создали привычный нам чемодан на 2 колесах с выдвижной ручкой.

Более 120 лет, на то, чтобы придумать колесо.

Подробно

Этапы проектирования системы:

  1. Определить этапы проектирования

  2. Определить терминологию и цели

  3. Высокоуровневая схема всей системы

  4. Определение проектируемого масштаба

  5. Установка границ проектируемой области

  6. Описание зон ответственности

  7. Определение направления зависимостей

  8. Формирование сущностей

  9. Выделение структур данных

  10. Разделение на модули

  11. Закрепление правил

1. Определить терминологию и цели

Цель

Код функциональностей должен не зависеть от платформы

Терминология

Функциональность — это конкретное действие для выполнения запроса пользователя.
Это не экран, не кнопка, не что-то на ui и не способ получения данных.

Примеры функциональности: заблокировать карту, получить информацию, записаться к врачу, отправить заявку на кредит, купить билет.

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

Android SDK это только часть платформы, а сама платформа, это «Мобильное устройство с операционной системой Android».
Такое определение, приводит к выводу, что не только экраны, но и навигация по ним, это то, что зависит от платформы.
Пользователь, для блокировки карты, может воспользоваться звонком в поддержку, и функциональность (действие для выполнения запроса пользователя) не должна отличаться.

Запрос пользователя — любое действие пользователя, которое приводит к взаимодействию с нашей системой.

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

2. Высокоуровневая схема всей системы

Пользователь инициирует запрос (оранжевая стрелка) и он может быть обработан на любой из частей системы

Пользователь инициирует запрос (оранжевая стрелка)
и он может быть обработан на любой из частей системы

Это стандартная схема Клиент — Сервер — База данных
Каждая часть может быть реализована как угодно, и на масштабе ниже, должна проектироваться как самостоятельная система, а рассматриваться как часть (подсистема) общей системы.
Клиентом (Client) в такой схеме может быть что угодно: Android, Ios, Web, банкомат, оператор поддержки вместе с ПО для сотрудников поддержки. Все, с чем может взаимодействовать пользователь.

3. Определение проектируемого масштаба

Мы будем вести разговор только про часть Client и ее конкретную реализацию в виде Android приложения.

Android приложение — это совокупность экранов и компонентов, которые предоставляют доступ к функциональностям бизнеса.

Наше приложение может иметь:

  • Внешние вспомогательные части, предоставляемые другими разработчиками.
    Library level.

  • Внутренние вспомогательные части, такие как конфиги, тоглы, аналитика, утилиты.
    Common level.

  • Функциональности, которые наша система предоставляет пользователю.
    Feature level.

  • То, что сможет собрать все вместе в одну систему и запустить ее.
    Application level.

Client - это набор функциональностей, собранный в продукт, предоставляемый пользователю.Пунктирной стрелкой, отображается направление зависимостей.

Client — это набор функциональностей, собранный в продукт, предоставляемый пользователю.
Пунктирной стрелкой, отображается направление зависимостей.

4. Установка границ проектируемой области

Для пользователя, совершенно не важно какие вспомогательные системы мы используем или как собираем и запускаем всю систему.

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

Поэтому сконцентрируемся именно на этом уровне: Feature level.

5. Описание зон ответственности

Зон ответственности, у любой системы, всегда будет ровно 3:

  • Предоставление пользователю способа взаимодействия — Presentation

  • Функциональности системы или логика предметной области — Domain

  • Предоставление нашим функциональностям доступа к внешним системам — Data

Мы можем приравнять каждую часть системы, к конкретной зоне ответственности. И сказать, что:

  • DataBase — отвечает за доступ к системе хранения данных (Data),

  • Server — отвечает за логику нашей предметной области (Domain),

  • Client — отвечает за представление пользователю функциональностей (Presentation).

Здесь важно сделать оговорку, что система не обязательно будет делиться именно на такие части.

6. Определение направления зависимостей

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

И любую такую систему можно разложить на эти 3 зоны ответственности (слоя).

Причем «главной» для нас, является Domain, потому что именно она определяет суть нашей системы.
Заменив domain слой, мы станем другой системой, тогда как заменив другие слои, мы остаемся для пользователя все еще той же самой системой.

Вопреки документации по Android, я утверждаю, что Domain слой является обязательным и не может быть proxy слоем.

Вопреки документации по Android, я утверждаю, что Domain слой является обязательным
и не может быть proxy слоем.

Каждой зоне ответственности (слою) соответствует свой цвет.
Кругами обозначены внешние системы. User — вызывающая система, API — вызываемая система.
Пунктирные стрелки, обозначают направление зависимости.
Оранжевая стрелка отображает путь запроса пользователя.

Например, система «автомобиль»

руль, педали и панель приборов — presentation;
(можно заменить на автопилот)

двигатель, трансмиссия, привода и колеса — domain;
(нельзя заменить на крылья, весла или рельсы)

системы воздухозабора и подачи топлива — data;
(можно заменить на электричество или пар)

дорога и воздух — api.

или система и точка

Любое кафе или ресторан.

официант/стойка/терминал/доставка — presentation;
кухня — domain;
склад/холодильник — data;
поставщики — api.

7. Формирование сущностей

Каждая зона ответственности (слой), по своей сути является отдельной системой.
А соседние зоны, это внешние, вызывающая или вызываемая, системы.
То есть каждая такая зона, содержит в себе еще 3 зоны.
Это ни что иное, как фрактальное представление системы.

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

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

Пояснение

Мы можем весь наш Android Client написать в одной Activity. То есть выделить в сущность всю System-Presentation зону (это весь наш Client, относительно всей системы целиком).

Или выделить для зоны Client-Data одну сущность, которая будет самостоятельно и в сеть ходить и в локальную базу.

А можем разделить этот слой на подзоны, выделив RepositoryImpl как Domain,
а различные DataSource, как Data.

Важно! В последнем случае, RepositoryImpl, будет отвечать за Domain зону, нашей Client-Data зоны, общей System-Presentation зоны.

Это может путать по началу, но пока сильно не акцентируйте на этом внимания.

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

Для начала, вспомним нашу цель.
Код функциональностей должен не зависеть от платформы

Отсюда мы определяем, что для Client, нам точно нужно выделить как минимум 3 зоны, чтобы Client-Domain не был зависим от платформы Android. Это масштаб привычных и понятных нам слоев из Clean Architecture.
KMP уже доказал нам, что мы сможем использовать такую Domain зону на других платформах.

Далее.
Экраны в Android это набор функциональностей, которые мы предоставляем пользователю и между этими наборами мы можем переключаться (навигироваться).
Платформа Android, также задает жизненный цикл экранов и их элементов.
А мы хотим, чтобы работа наших функциональностей, не зависела от платформы. Поэтому разделим нашу Presentation зону, еще на 3 зоны.
Главной задачей (Domain) здесь будет переживание жц и управление состоянием отображения.
В мире Android, для зоны Presentation существуют готовые шаблоны проектирования, такие как MVP, MVVM, MVI и прочие MV*. На текущий момент, стандартом принят MVVM, поэтому его мы и возьмем для нашей схемы. В этом шаблоне, ViewModel как раз отвечает за переживание жц и управлением состоянием отображения, то есть это и есть Client-Presentation-Domain ответственность.
View (Activity/Fragment/Compose) это Client-Presentation-Presentation ответственность.

ViewModel — отвязанная от жизненного цикла сущность, которая отвечает за реакцию на запрос пользователя.

Для зоны Data, мы применим ту же логику, отвязки от платформы. Только здесь, платформой Android, определяется реализация источников данных, таких как SQLite, REST, SharedPrefs и другие. Поэтому они у нас будут выделяться в отдельные сущности DataSourceImpl, которые будут отражать ответсвенность Client-Data-Data, которая максимально завязана на платформу. А вот в Client-Data-Domain, будет входить RepositoryImpl и интерфейс DataSource, чтобы сделать инверсию зависимости для этих зон ответственности. Потому что мы помним, что направление зависимостей, должно быть направленно на самую «главную» зону, внутрь системы.

Тем же принципом инверсии зависимостей мы определяем, что интерфейс Repository принадлежит Domain зоне, в качестве Client-Domain-Data ответственности.

И таким образом у нас осталась неопределенной только Domain зона. А она как раз и является сутью нашей системы. В ней содержатся конкретные функциональности. Или по другому кейсы функциональностей. Поэтому мы так и назовем эту сущность. FeatureCase.

FeatureCase — это конкретный независимый кейс функциональности.

В этом месте, настало время поговорить о такой вещи, как горизонтальные зависимости. Это когда FeatureCase вызывает другой FeatureCase, или вообще любая другая сущность, которая вызывает сущность того же типа. Можно долго спорить о том, можно так делать или нет, и для каждой уже работающей системы будет свой ответ, чаще всего потому, что «так исторически сложилось». Я хочу четко определить это для проектируемой нами системы, с помощью чего-то отдаленно похожего на доказательство.

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

Горизонтальная связь, подразумевает под собой связь class A на class B.

Мы можем упростить это до связи fun a на fun b.

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

Что означает, что мы можем законно получить связь a > b > c > a

А это, ни что иное, как циклическая зависимость.

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

Поэтому циклическая зависимость — запрещена, а горизонтальные связи могут к ним приводить.

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

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

Такую ответственность можно смело назвать «кейс использования нескольких функциональностей», что отлично укладывается в название UseCase.
Не соотносите это с известным вам UseCase, который часто делают proxy.

UseCase — опциональная сущность, описывающая кейс использования нескольких функциональностей.

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

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

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

Мы определились с сущностями. Теперь давайте зафиксируем правила этих сущностей, чтобы вы точно понимали, как их реализовывать.

8. Выделение структур данных

В подходе DDD, проектирование начинается с определения структуры данных Domain части.

В книге Clean Architecture, в центре системы стоит Entity, что является описанием данных и их функций.

Однако:
Главная стратегия архитектуры в том, чтобы как можно дольше иметь как можно больше вариантов.

Мы начали проектирование схемы, для какой-то неопределенной системы, оставляя тем самым бесконечное количество вариантов реализации.

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

Как я уже говорил ранее, каждую зону ответственности стоит проектировать как отдельную систему. Каждая система, должна выполнять некую трансформацию данных.

Примеры

Автомобиль трансформирует топливо в кинетическую энергию

Тормоза трансформируют кинетическую энергию в тепловую

Инжектор трансформирует воздух и топливо в горючую смесь

Кухня кафе трансформирует ингредиенты в блюда

Мойщик продуктов трансформирует воду и ингредиент в чистый ингредиент

Виды трансформации данных:

  • Прямая — из A получить B

  • Объединение — из A и A получить A

  • Разделение — из A получить A и A

  • Комбинация — из A и B получить С

Таким образом, мы можем уверенно говорить о том, что:

  • Presentation зона занимается трансформированием результата работы функциональности в данные пригодные для отображения;

  • Data зона, трансформирует данные из источников в данные пригодные для системы;

  • Domain зона занимается трансформацией данных пригодных для системы, в результат работы функциональности.

Итого, у нас получается как минимум 4 типа структур данных:

  1. Получаемые из других систем (DTO)

  2. Пригодные для работы в нашей системе

  3. Результат работы функциональности

  4. Данные отображения, которые увидит пользователь

А каждая сущность отвечает за конкретный тип трансформации:

  • DataSource — прямая

  • Repository — объединение

  • FeatureCase — комбинация

  • UseCase — комбинация

  • ViewModel — прямая

Каждая структура по итогу будет соответствовать интерфейсу какой-то сущности

Каждая структура по итогу будет соответствовать интерфейсу какой-то сущности

Структура данных для UseCase, так же не обязательна, как и сам UseCase

Структура данных для UseCase, так же не обязательна, как и сам UseCase

9. Разделение на модули

На этом этапе, наша схема уже готова и ее можно применять.

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

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

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

Сложное объяснение

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

Экран — это всего лишь набор функциональностей. А любая функциональность может использоваться на разных экранах. ViewModel на FeatureCase имеет отношение многие-ко-многим.

Интерфейс FeatureCase и его реализация FeatureCaseImpl имеют отношение один-к-одному. В некоторых случаях, может быть отношение один-ко-многим.

То же самое справедливо для UseCase. А UseCaseImpl на FeatureCase, так же является отношением многие-ко-многим.

Мы уже говорили, что FeatureCaseImpl реализует трансформацию комбинации, то есть берет n структур данных из Repository и получает результат своей работы. Каждый Repository отвечает за одну конкретную структуру данных, и поэтому может использоваться в различных FeatureCaseImpl.

Отсюда делаем вывод, что FeatureCaseImpl и Repository имеют отношение многие-ко-многим.

Ровно теми же рассуждениями получаем связи, где отношение разных сущностей описывается как многие-ко-многим, а отношение интерфейса и реализации, как один-к-одному.

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

Из этих рассуждений, мы получаем правило выделения независимый частей.

7541fae1c6c464ee75415d2f2c0e828d.pngОбратите внимание на модуль usecase

Обратите внимание на модуль usecase

Сущность UseCase встречается редко, является маленькой и изменяется совместно с FeatureCase, из-за чего накладные расходы на разделение api/impl модулей, оказываются не оправданными.

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

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

Так же, не стоит путать горизонтальные стрелки на этой схеме с горизонтальными связями. Если рассматривать эту схему модулей в разрезе всего Client приложения, то мы получим вот такое представление:

Любые горизонтальные зависимости запрещены, как вправо/влево, так и в глубь схемы.Отдельно отмечу сквозные зависимости screen на data-api или data-impl на feature-api: они категорически запрещены.

Любые горизонтальные зависимости запрещены, как вправо/влево, так и в глубь схемы.
Отдельно отмечу сквозные зависимости screen на data-api или data-impl на feature-api:
они категорически запрещены.

Одиночная стрелка означает отношение один-к-одномуНесколько тонких стрелок означают отношение многие-ко-многим

Одиночная стрелка означает отношение один-к-одному
Несколько тонких стрелок означают отношение многие-ко-многим

Может показаться, что у нас получается слишком много модулей. По старой привычке, можно сказать, что «это же 6 модулей на фичу получается»! Однако стоит понимать, что количество сущностей разных типов не будут соответствовать. То есть модулей screen будет гораздо меньше, чем модулей feature.

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

10. Закрепление правил

  • Рассматриваем как подсистему. Android приложение — часть функциональности бизнеса.

  • Проектируем как систему. Android приложение — функциональность представления.

  • Разработку начинаем с центра. Выносим по краям только необходимый минимум и фиксируем контрактами.

  • Любые горизонтальные связи запрещены.

  • Запрещены сквозные зависимости.
    Screen → Data-Api или Data-Impl → Feature-Api

  • Модели контрактов (интерфейсы FeatureCase и Repository) всегда должны быть разными моделями. Выносить в общий my-common модуль запрещено.

  • UseCase является необязательной сущностью, как и модуль usecase.

    • Модуль usecase может проксировать зависимость одного feature-api.

    • feature модули могут поглотить в себя UseCase, если это не нарушит других

  • Модули usecase,  feature-api,  feature-impl и data-api строго являются котлин модулями.

  • ViewModel на одно действие пользователя может использовать не больше одного FeatureCase/UseCase.

  • Трансформация между внешней структурой данных (DTO) и внутренней для нашей системы (из интерфейса Repository) происходит в DataSource.

Примечание

Совершенно неважно какой паттерн выбрать для Presentation слоя. Это никак не отразится на Domain слое.

Мы можем беспрепятственно переписать Data слой и заставить его работать на websoket или graphql. Или заменить заглушкой для тестов.

При желании, центральная часть может быть без проблем извлечена и помещена в KMP проект. Что означает абсолютную ее независимость от других слоев и от платформы «Мобильного устройства с ОС Android».

В данном документе мы не рассматривали структуру Application и Common уровней.

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

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

  • Выбор di библиотеки, для нас совершенно не важен, потому что находится выше в уровне Application и любой вариант легко укладывается на схему.

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

Главная стратегия архитектуры в том, чтобы как можно дольше иметь как можно больше вариантов. (Роберт Мартин. Чистая архитектура с. 145)

Common level

А вот для проектирования уровня Common, есть важный совет.

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

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

© Habrahabr.ru