Данный материал был переведён не ради прений непримиримых оппонентов (последователей и противников Clean Architecture). Предлагаю в конструктивном русле обсудить удачные и не очень паттерны проектирования, предложенные автором.


Architect your Flutter app the clean way with BLoC
Architect your Flutter app the clean way with BLoC

Содержание

  1. Введение

  2. Взаимодействие слоев ????

  3. Структура папок ????

  4. Уровень представления ????

  5. Доменный уровень ⚙️

  6. Уровень данных ????

  7. Заключение ????

Введение

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

Удобство сопровождения и читаемость кода значительно облегчают совместную работу в разработке программного обеспечения. В принципе, если код написан чисто и системно, то, к счастью, вы можете добавлять новые функции поверх него без страданий. Для front-end приложений нам нужно обрабатывать в основном три вещи:

  • то, с чем взаимодействует пользователь

  • логика, выполняемая приложением

  • получение данных, используемых в приложении.

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

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

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

Взаимодействие слоев ????

Red -> Presentation Layer, Blue -> Domain Layer, Green -> Data Layer
Red -> Presentation Layer, Blue -> Domain Layer, Green -> Data Layer

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

BLoC (компонент бизнес-логики) является краеугольным камнем этого архитектурного стиля, он в основном принимает События (Events) от пользователя в UI, затем выполняет некоторую логику, и на основе результата выполненной логики выдает новое Состояние (State). После этого мы собираемся отобразить пользовательский интерфейс на основе нового значения Состояния.

Простая блок-схема авторизации
Простая блок-схема авторизации

Прелесть решений по управлению состоянием на основе BLoC в том, что мы можем отслеживать историю состояний, что дает большое преимущество при отладке приложения. Также, применяя BLoC, мы можем описать нашу систему как Event-Driven (cобытийно-ориентированной) и, в результате, легко получить высокоуровневый дизайн функции, поскольку это просто взаимодействие Событий и испускание новых Состояний.

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

Вначале событие будет запускаться из пользовательского интерфейса пользователем, например, когда тот нажимает кнопку входа в систему. Это Событие будет отправлено в BLoC, и я хочу, чтобы вы думали о BLoC как о Центре операций.

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

Достигнув этой точки, мы можем иметь два случая:

  • Если нам не нужно получать данные ни удаленно и ни локально, то логика будет выполнена в usecase, а значение будет возвращено в BLoC. Когда BLoC получит значение из usecase, он выдаст новое Состояние, и в результате пользовательский интерфейс будет перестроен. Это тот случай, когда нам не нужно получать данные. Однако большинство функциональных возможностей требует получения данных. За это отвечает уровень данных (data layer). Возвращаясь к потоку действий, usecase будет вызывать репозиторий (repository) для получения данных и возвращать их в виде Сущности (enitity). Репозиторий также сообщит об источниках данных для их извлечения.

  • Если необходимые данные хранятся локально, то будет вызываться только локальный источник данных (local data source). В противном случае ему необходимо установить связь с удаленным источником данных (remote data source). Имейте ввиду, что иногда мы можем использовать как локальные, так и удаленные источники данных в одном репозитории для целей кэширования.

После получения данных из data sources они будут отправлены обратно в репозиторий в виде DTO (Объект передачи данных). Репозиторий будет отвечать за преобразование DTO в Сущность и отправку ее обратно в usecase. После этого usecase может применить всю логику, необходимую на этом этапе. Далее мы будем использовать возвращаемое значение из usecase внутри BLoC, чтобы определить, какое новое Состояние нужно выдать.

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

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

Структура папок ????

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

Например, когда вы создаете приложение с нуля, то в основном у вас будут общие кнопки, которые будут использоваться повсюду, текстовые поля и многие другие многоразовые виджеты. Это пример общих (shared или common) файлов, которые будут использоваться в проекте.

Кнопка используется по всему приложению
Кнопка используется по всему приложению

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

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

Выбор пола используется только на экране аутентификации
Выбор пола используется только на экране аутентификации

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

  • классы utils, цвета, тематизация считаются общими;

  • общий презентационный (presentation) слой, где находятся общие виджеты, диалоги и всплывающие окна;

  • общий уровень домена (domain), где у нас будут общие сущности и usecase'ы;

  • общий слой данных (data) для получения данных, часто используемых в приложении. Например, получить время, когда пользователь в последний раз открывал приложение.

И все эти Общие (common) файлы будут сохранены в папке под названием «core».

структура папки  «core»
структура папки «core»

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

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

структура папки «features»
структура папки «features»

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

Например, вы хотите что-то изменить в пользовательском интерфейсе главной страницы. Затем вы перейдете прямо в папку «features», а поскольку мы говорим о домашней странице, вы перейдете в папку «home». И, наконец, вы откроете папку «presentation», так как вы хотите изменить пользовательский интерфейс.

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

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

Уровень представления ????

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

Пример презентационного слоя для домашнего экрана
Пример презентационного слоя для домашнего экрана

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

bloc: внутри него мы создадим BLoC, который будет обрабатывать всю логику и изменения состояния внутри домашнего экрана.

widgets: эта папка будет содержать все виджеты, используемые специально для домашнего экрана; обратите внимание, что у нас есть файл под названием body.dart. Этот файл будет обрабатывать то, что будет отображаться на экране.

// body.dart

class Body extends StatelessWidget {
  const Body({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Center(
      child: Text("HomeScreen"),
    );
  }
}

home_screen.dart: этот файл будет заполнителем (placeholder) и каркасом (scaffold) для главного экрана, а для его тела мы будем использовать body.dart. В этом файле мы также передадим AppBar и BLoC для экрана.

// home_screen.dart

class HomeScreen extends StatelessWidget {
  const HomeScreen({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return BlocProvider<HomeBloc,HomeState>(
      child: Scaffold(
        appBar: HomeScreenAppBar(),
        body: Body(),
      ),
    );
  }
}

В итоге, диаграмма интерфейса домашнего экрана будет выглядеть так:

Доменный уровень ⚙️

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

Коммуникация BLoC и usecase
Коммуникация BLoC и usecase

Конечно, у нас все еще будет бизнес-логика, написанная в BLoC, но, как вы можете заметить, вся "тяжелая" логика будет выполняться в usecase.

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

Легко перейти на новое решение для управления состоянием
Легко перейти на новое решение для управления состоянием

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

Удачи в изменении решений по управлению состоянием :)
Удачи в изменении решений по управлению состоянием :)

Другим компонентом, который мы будем иметь в доменном слое, является Сущность (Entity). Сущность – это капсулирование любых данных, которые необходимо представить в пользовательском интерфейсе.

Например, если одной из функций приложения является отображение ToDo'шек, то мы создадим сущность для этого. С ней будет гораздо проще работать, чем с объектом json.

Кроме того,

  • обмен сущностями между виджетами будет проще, чем обмен json-объектами;

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

Позже, в разделе об уровне данных, мы обсудим разницу между DTO и сущностями.

Пример аутентификации для доменного уровня
Пример аутентификации для доменного уровня

Уровень данных ????

Теперь поговорим о последнем слое, который иногда называют "инфраструктурным". В этом слое у нас есть три основных компонента – это репозитории, источники данных и DTO.

Начнем с самого нижнего уровня – источника данных (data source). Основная задача источника данных заключается в получении данных из определенного места. Иногда нам нужно получить их извне через http-запросы, и в этом случае это будет удаленный источник данных (remote data source). А в некоторых других случаях может потребоваться получить данные из локального хранилища устройства. В этом сценарии вам придется использовать локальный источник данных (local data source).

Схема получения данных
Схема получения данных

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

Представьте, что вы по ошибке используете неправильный ключ внутри объекта json. Например, вместо того, чтобы обратиться к данным таким образом – json["name"], вы по ошибке написали json["namee"].

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

схема конвертации DTO в OrdersResponse Object
схема конвертации DTO в OrdersResponse Object

Ранее мы уже обсуждали сущности в доменном слое. Вы можете задаться вопросом, в чем разница между сущностью заказа OrderEntity и DTO заказа OrderDTO?

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

Отсюда ясно, что у сущности "Заказ" будет только три переменные. Это идентификатор, цена и дата. Но иногда мы не получаем данные в том виде, в котором хотим их получить из back-end'а. Возьмем для примера следующие:

{
  "order_id": 1,
  "order_price": 300,
  "order_date": "2022/1/1",
  "issued_by": "........",
  "list_of_items": [],
  "returnable": false,
  "extra_field1": ".....",
  "extra_field1": ".....",
  "extra_field1": ".....",
  "extra_field1": "....."
}

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

Теперь мы добрались до последнего компонента в слое данных, которым является репозиторий. Репозитории будут оберткой для источников данных, необходимых для реализации бизнес-логики. Применяя это, usecase будет зависеть только от одного файла, даже если данные будут получены из разных источников данных.

Пример репозитория с несколькими источниками данных
Пример репозитория с несколькими источниками данных

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

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

После получения данных, они будут преобразованы в любой формат, который хочет usecase (в данном случае в List<OrderEntity>. Хорошо то, что вся эта магия происходит за кулисами репозитория. Это означает, что BLoC и usecase не нужно знать в явном виде о том, как данные извлекаются.

Пример слоя данных для характеристики заказов
Пример слоя данных для характеристики заказов

Заключение ????

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

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

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

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

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

Не стесняйтесь связаться со мной через мой аккаунт в LinkedIn.


Материал переведён Ruble.

TODO: change after

Забавная группа с неадекватным автором ☜(゚ヮ゚☜)

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