Flutter, BLoC и DI-ные трубы, или как контролировать зависимости в коде
Разработка

Flutter, BLoC и DI-ные трубы, или как контролировать зависимости в коде

Ярослав Магин
Разработчик

Оглавление

Создаём сервисы в сферах HR-Tech, образования, девелопмента.

Посмотреть кейсы Посмотреть кейсы

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


Замечали ли вы, что дублируете код, связанный с созданием объектов? Приходилось ли вам пересматривать десятки строк кода, чтобы узнать, какие зависимости нужны тому или иному классу? Случалось ли ловить краши приложения, виной которых являлся неправильно инициализированный объект? В этой статье мы расскажем, как писать код так, чтобы никогда не сталкиваться с этими проблемами и органично упорядочивать зависимости ваших BloC-классов (далее просто — «блоков»), и при чем тут аббревиатура DI.

 Что такое DI и зачем он нужен

DI, или Dependency Injection (внедрение зависимостей) — это набор практик и паттернов в разработке ПО, позволяющий писать слабо связанный код, который будет легче расширять и поддерживать. Согласно ему объект, нуждающийся в некоторых зависимостях (то есть других объектах, которые он использует в своей работе), должен получать их извне вместо того, чтобы создавать самостоятельно. Иногда говорят, что объект делегирует обязанности по созданию зависимостей. Это уменьшает сложность класса, поскольку ему больше не нужно заниматься созданием и настройкой своих составляющих. Правила эти вытекают из последнего (по порядку, но не по значимости) принципа SOLID. Если вы еще не познакомились с этим акронимом, то сейчас самое время. D означает Dependency Inversion, или «инверсия зависимостей». Обратите внимание: несмотря на созвучие с Dependency Injection, под аббревиатурой DI подразумевают все же именно Injection, а не Inversion. Этот принцип говорит о том, что модули более высоких уровней не должны зависеть напрямую от модулей низких уровней, но при этом оба слоя должны полагаться на абстракции.

Так выглядит пример нарушения принципа Dependency Inversion, когда модуль более высокого уровня — MainBloc — полагается напрямую на модуль более низкого уровня — SomeService:

class MainBloc extends IMainBloc {
 SomeService _service;

 MainBloc() {
   _service = SomeService(key: "some string", amount: 42);

   _service.doSomething();
 }
}

А такой код принцип Dependency Inversion не нарушает:

class MainBloc extends IMainBloc {
 ISomeService _service;

 MainBloc(ISomeService service): _service = service {
   _service.doSomething();
 }
}

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

Кроме того, создавая объекты конкретного класса, вы тем самым завязываетесь на этот класс и увеличиваете связанность кода. Почему это плохо? У вас гарантированно возникнут проблемы, если потребуется заменить используемый класс. Например, вы используете в качестве хранилища информации локальную базу данных и хорошо с этим живете, но неожиданно требования меняются, и теперь необходимо загружать данные с сервера. Как результат, вам нужно написать новый класс и внедрить его везде, где использовался старый. Чем больше таких мест в коде, тем сложнее и, как следствие, дороже обойдется реализация. А если с самого начала везде использовать не конкретный тип, а абстрактный интерфейс, который реализуют оба хранилища данных (локальная база и удаленный сервер), то замена пройдет куда проще. Основной код даже «не узнает», что какой-то из составляющих его компонентов изменился, вам просто нужно будет в одном или нескольких местах приложения вызвать другой конструктор.

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

 Контейнер для управления зависимостями

Возможно, кто-то из читателей уже слышал о таком инструменте, как DI-контейнер. Это программный инструмент, который помогает реализовывать DI. Можно обойтись и без него, однако он существенно упростит и минимизирует объем кода, который вам предстоит писать. К сожалению, для Flutter нет каких-то официально рекомендованных к использованию контейнеров.

Есть одна библиотека в открытом доступе от Google, но репозиторий давно не поддерживается и библиотека даже не собирается. Мы сегодня рассмотрим другую библиотеку, небольшую, но эффективную, flutter_simple_dependency_injection. Чтобы подключить ее к своему проекту, найдите в вашем файле pubspec.yaml раздел dependencies и добавьте туда следующую строку:

flutter_simple_dependency_injection: ^1.0.3

Принцип работы контейнера, предоставляемого библиотекой, заключается в том, что внутри него выполняется отображение некоторого типа в реальный объект. Вы описываете свод правил вида: «если у контейнера запрашивают тип „A“, то нужно вернуть объект класса „A“, либо его наследника, либо объект класса, реализующего интерфейс „А“». В данном случае контейнером является объект класса Injector. Вот так выглядит типичный пример описания такого правила:

injector.map<ISomeService>(
   (i) => SomeService(key: "some string", amount: 42),
   isSingleton: true);

Здесь происходит следующее. Мы говорим контейнеру: «Если у тебя попросят тип ISomeService, ты должен вернуть объект класса SomeService, инициализируемый определенными параметрами, при этом объект должен быть вида singleton (англ. одиночка)». Параметр isSingleton можно убрать, тогда каждый раз, когда у контейнера попросят тип ISomeService, будет создаваться новый объект. А вот так у него можно этот тип запросить:

injector.get<ISomeService>();

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

 Реализация DI в BLoC-архитектуре

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

Начнем развивать эту идею. Создадим класс ApplicationAssembly и опишем в нем такой метод:

class ApplicationAssembly {
 Widget getCompositionRoot() {
   return BlocProvider(
     child: MainScreen(),
     bloc: MainBloc(SomeService()),
     blocContext: MainBlocContext(),
   );
 }
}

Этот класс и послужит местом, где мы настроим все зависимости в приложении. Самое время вспомнить про DI-контейнер, в котором можно зарегистрировать MainBloc. Для этого подключим библиотеку и объявим в ApplicationAssembly поле типа Injector, которое и является DI-контейнером:

static final Injector injector = Injector.getInjector();

Теперь зарегистрируем в DI-контейнере MainBloc и его сервис:

class ApplicationAssembly {
 static final Injector injector = Injector.getInjector();

 static void initialize() {
   injector.map<ISomeService>(
       (i) => SomeService(key: "some string", amount: 42),
       isSingleton: true);

   injector.map<IMainBloc>((i) => MainBloc(i.get<ISomeService>()));
 }

 static Widget getCompositionRoot() {
   return BlocProvider(
     child: MainScreen(),
     bloc: injector.get<IMainBloc>(),
     blocContext: MainBlocContext(),
   );
 }
}

Обратите внимание, что в качестве типа для регистрации мы указываем все также абстрактный интерфейс. Это нужно в первую очередь при описании объектов бизнес-логики («блоков» и их сервисов). Покрывать абстрактными интерфейсами UI (то есть экраны и объекты BlocContext) имеет смысл только в том случае, если необходимо протестировать все вплоть до деталей интерфейса (имитируя взаимодействие с другими экранами, формами и т. д.), что требуется далеко не на всех проектах.

Мы можем пойти дальше и передать в MainBlocContext другой экран, который нужно показать позже (например, при нажатии на кнопку). Получится вот такая «матрешка», один BlocProvider внутри другого:

class ApplicationAssembly {
 static final Injector injector = Injector.getInjector();

 static void initialize() {
   injector.map<ISomeService>(
       (i) => SomeService(key: "some string", amount: 42),
       isSingleton: true);

   injector.map<IMainBloc>((i) => MainBloc(i.get<ISomeService>()));
   injector.map<ISecondBloc>((i) => SecondBloc());
 }

 static Widget getCompositionRoot() {
   return BlocProvider(
     child: MainScreen(),
     bloc: injector.get<IMainBloc>(),
     blocContext: MainBlocContext(
         secondScreen: BlocProvider(
       bloc: injector.get<ISecondBloc>(),
       blocContext: SecondBlocContext(),
       child: SecondScreen(),
     )),
   );
 }
}

А затем в main.dart передадим нашу полностью настроенную инфраструктуру приложения, поместив результат вызова getCompositionRoot в home:

class MyApp extends StatelessWidget {
 @override
 Widget build(BuildContext context) {
   return MaterialApp(
     title: 'Flutter DI Demo',
     theme: ThemeData(
       primarySwatch: Colors.blue,
       visualDensity: VisualDensity.adaptivePlatformDensity,
     ),
     home: ApplicationAssembly.getCompositionRoot(),
   );
 }
}

Конечно, такая реализация не оптимальна: приходится создавать все экраны с самого начала и держать их в памяти. Но можно передать не сами объекты класса BlocProvider, а функции, которые их собирают и возвращают. Для этого заведем отдельный файл builders.dart и определим через typedef сигнатуры функций, собирающих экраны (далее будем называть их screen builders).

typedef BlocProvider<IMainBloc> MainScreenBuilder();
typedef BlocProvider<ISecondBloc> SecondScreenBuilder();

Теперь давайте зарегистрируем их в нашем контейнере. Заодно разобьем регистрацию наших зависимостей на этапы: сначала сервисы, затем «блоки» и в конце screen builders:

class ApplicationAssembly {
 static final Injector injector = Injector.getInjector();

 static void initialize() {
   _registerServices();
   _registerBlocs();
   _registerScreenBuilders();
 }

 static void _registerServices() {
   injector.map<ISomeService>(
       (i) =< SomeService(key: "some string", amount: 42),
       isSingleton: true);
 }

 static void _registerBlocs() {
   injector.map<IMainBloc>((i) => MainBloc(i.get<ISomeService>()));
   injector.map<ISecondBloc>((i) => SecondBloc());
 }

 static void _registerScreenBuilders() {
   injector.map<SecondScreenBuilder>((i) => () {
     return BlocProvider(
       child: SecondScreen(),
       bloc: i.get<ISecondBloc>(),
       blocContext: SecondBlocContext(),
     );
   });

   injector.map<MainScreenBuilder>((i) => () {
         return BlocProvider(
           child: MainScreen(),
           bloc: i.get<IMainBloc>(),
           blocContext: MainBlocContext(
             secondScreenBuilder: i.get<SecondScreenBuilder>()
           ),
         );
       });
 }

 static Widget getCompositionRoot() {
 final MainScreenBuilder rootWidgetBuilder = injector.get<MainScreenBuilder>();
 return rootWidgetBuilder();
}

}
Теперь мы передаем в MainBlocContext не экран для показа, а функцию, извлекаемую из того же DI-контейнера. Точкой входа в приложение будет служить результат вызова MainScreenBuilder. Аналогичным образом мы можем определить в ApplicationAssembly абсолютно все зависимости в приложении, описать процесс создания всех объектов класса BlocProvider и их составляющих в одном месте. Наша цель достигнута.

 Создание модуля с аргументами

Часто бывает, что при открытии того или иного экрана вам нужно передать некий параметр. Например, вы хотите открыть страницу отображения деталей по некоторой сущности класса Person. Очевидно, что «блоку» (назовем его DetailsBloc) нужно будет передать аргументом эту сущность и сохранить ее, чтобы с ней можно было работать. Его код будет выглядеть так:

class DetailsBloc extends IDetailsBloc {
 final Person _person;

 DetailsBloc({@required Person person}) : _person = person;

 // ... остальной код

}
Правило отображения в DI-контейнере для такого «блока» описываем с использованием mapWithParams (не забываем про регистрацию через абстрактный интерфейс!). Параметры передаются через словарь со строковыми ключами:

injector.mapWithParams<IDetailsBloc>((i, params) {
 final Person person = params["person"];
 assert(person != null, "Обязателен параметр person!");
 return DetailsBloc(person: person);
});
FYI: если вы еще не пользуетесь assert, то очень рекомендуем. Эта функция проверяет переданное ей условие и выбрасывает ошибку в случае, если оно не выполняется. При ее вызове отдельным аргументом можно также передать сообщение об ошибке, которое будет выведено в консоли при срабатывании assert. Не переживайте за конечную производительность приложения: в релизную версию вызовы assert не попадают, они есть только в отладочных сборках. В данном случае с помощью assert вы можете убедиться, что не забыли передать все параметры, необходимые для открытия того или иного экрана.

Далее создаем новый typedef для соответствующего ScreenBuilder. В параметрах функции перечисляем все сущности, необходимые для создания экрана:

typedef BlocProvider<IDetailsBloc> DetailsScreenBuilder(Person person);
Регистрируем DetailsScreenBuilder:

injector.map<DetailsScreenBuilder>((i) => (Person person) {
     return BlocProvider(
         child: DetailsScreen(),
         bloc:
             i.get<IDetailsBloc>(additionalParameters: {"person": person}),
         blocContext: DetailsBlocContext());
   });
Здесь get выполняется с дополнительным аргументом additionalParameters — словарем, куда вы можете положить все значения, переданные в DetailsScreenBuilder. Обратите внимание, что словарь и его ключи, которые вы здесь указываете, должны в точности совпадать с тем, что задается в mapWithParams для соответствующего типа (в нашем случае для IDetailsBloc).

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

 «Нет!» хаосу в зависимостях в коде

Вот так, теперь вы знаете, как реализовать DI в проектах Flutter, использующих BLoC-архитектуру. Чек-лист для этого может быть следующим:

  • Подключите к проекту DI-контейнер.
  • Создайте класс, предоставляющий точку входа в приложение (в нашем случае это ApplicationAssembly).
  • В отдельном методе опишите правила отображения ваших зависимостей. Помните, что если во время работы приложения запросить из контейнера тип, на который ничего не зарегистрировано, то вылетит исключение (exception).
  • Убедитесь, что при регистрации зависимостей вы опираетесь на абстрактные интерфейсы везде, где это возможно.
  • Реализуйте метод, возвращающий точку входа в приложение.
  • Напишите вызов метода инициализации вашего DI-класса. Хорошим местом для этого является функция void main () в файле main.dart.
  • Поместите результат вызова метода, который вернет настроенное приложение, в нужное вам место (например, внутрь виджета Material/CupertinoApp).

Разумеется, вы можете переделать процесс по-своему. Например, разделить регистрацию не на registerServices/Blocs/ScreenBuilders, а registerMainModule/SecondModule, и так далее. Или использовать другую библиотеку. Главное — понять основную идею DI и научиться ее применять.

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

P.S. Не следует делать из статьи вывод, что нужно всегда отказываться от прямого создания объектов внутри конструкторов. Например, если вы пишете класс-обертку над сервисом Firebase, то последний бессмысленно передавать под видом абстракции. Ведь смысл создания класса как раз и заключается в том, чтобы работать с конкретной сущностью, и если вы уберете Firebase из проекта, то обертку придется убрать вместе с ним. А вот если вы захотите передать куда-то сам объект класса-обертки, то его обязательно нужно прятать за абстрактным интерфейсом. Используемая библиотека (и обертка к ней) может поменяться, а вот интерфейс останется тем же. Очень важно уметь отличать стабильные зависимости от нестабильных, но эти тонкости приходят только с опытом.

Оцените эту статью

2 4.5
Спасибо за оценку!

Оставьте заявку

Расскажите о проекте — мы его реализуем

Мы свяжемся с вами в течение 4 рабочих часов: обсудим цели проекта, требования к нему и составим план сотрудничества

* – поля обязательные для заполнения