IoC: Locator и DI, скоупы

Ранее мы рассмотрели основы IoC (Inversion of Control) — принципа программирования, при котором управление определёнными аспектами программы передаётся внешним компонентам, что позволяет разделить ответственность, уменьшить связность и повысить гибкость системы.

В этом параграфе мы сфокусируемся на примерах и смежных паттернах проектирования архитектуры, основанных на IoC:

  • Dependency Injection (DI).

  • Service Locator.

  • Decoupling.

Плюс рассмотрим понятие скоупа (scope) и жизненного цикла приложения.

Но для начала давайте вспомним, что представляют собой паттерны выше и в чём их плюсы и минусы.

Разбираемся в терминах

Service Locator

Service Locator — это паттерн программирования, описанный Мартином Фаулером в 2004 году.

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

На схеме ниже локатор — синий контейнер.

диаграмма классов для демонстрации service locator

Плюсы применения паттерна

  • Простой паттерн, низкий порог входа для новых разработчиков.

Минусы применения паттерна

  • Нельзя по конструктору класса понять, какие зависимости он использует.

  • Дополнительное время, которое придется потратить на unit-тестирование.

  • API может быть настолько открыто, что любую зависимость можно получить в любом месте.

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

Dependency Injection

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

  • Инъекция через конструктор — зависимости передаются в конструкторе класса в момент его создания.

  • Инъекция через установщик (setter) — зависимости передаются после создания класса через специальный метод setter, который заполняет ранее пустые поля класса.

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

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

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

Untitled-2025-03-14-1615.png

Также важно заметить, почему используется слово injection (инъекция). В отличие от «активного» запроса зависимости в случае с паттерном локатора (в момент использования пишем locator.get<T>), DI подразумевает «пассивный» запрос зависимости (например, через конструктор). Таким образом, только сторона, передающая зависимость, играет активную роль, поэтому и используется слово «инъекция».

Плюсы применения паттерна

  • Уменьшение связности зависимостей, упрощение тестирования (за счёт mock зависимостей).

  • Более простое внедрение модальности, как за счёт прозрачной диаграммы зависимостей, так и за счёт использования интерфейсов.

Минусы применения паттерна

  • Необходимость в инициализации, использовании DI-контейнеров.

  • Риск усложнения архитектуры за счёт внедрения множества DI-контейнеров, сеттеров, интерфейсов.

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

Decoupling

Ниже рассмотрим один из примеров IoC, косвенно связанный с DI, а именно паттерн интерфейсов — замена явной имплементации на интерфейс, decoupling.

У телефона на фото вместо прямого соединения использован интерфейс — USB Type C. Можно подключить любой кабель, соответствующий этому интерфейсу.

У устройства на фото кабель присоединён напрямую — не использован паттерн IoC, его нельзя заменить на какой-то ещё.

фото телефона

фото колонки

Плюсы применения паттерна

  • Код стал более гибким:

    • его проще тестировать;

    • над кодом проще работать нескольким людям одновременно.

Минусы применения паттерна

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

Имея знания о том, как создавать дерево зависимостей в системе, стоит задуматься, как управлять им, как отделять одни зависимости от других и как встроить тот или иной модуль в приложение.

Скоупы

Скоуп — самый гибкий термин из представленных.

Наиболее близкая аналогия к скоупам — это область видимости переменной. Мы можем «видеть» ту информацию, которую предоставляет скоуп в определённой области программы (приложения).

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

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


Отлично, паттерны вспомнили, с терминами определились. Теперь рассмотрим, как они применяются на практике.

Примеры паттернов

Пример №1: применение Service Locator

На примере известного пакета get_it мы рассмотрим применение паттерна Service Locator. Для этого на одном экране создаём ValueNotifier, кладём его в локатор, затем достаём — и используем.

1import 'package:flutter/material.dart';
2import 'package:get_it/get_it.dart';
3
4void main() {
5  runApp(const App());
6}
7
8class App extends StatelessWidget {
9  const App({super.key});
10
11  @override
12  Widget build(BuildContext context) => const MaterialApp(
13        home: Screen1(),
14      );
15}
16
17class Screen1 extends StatefulWidget {
18  const Screen1({super.key});
19
20  @override
21  State<Screen1> createState() => _Screen1State();
22}
23
24class _Screen1State extends State<Screen1> {
25  late final ValueNotifier<int> counter;
26  @override
27  void initState() {
28    super.initState();
29
30    counter = ValueNotifier(0);
31    // кладём в локатор свой объект
32    GetIt.I.registerSingleton(counter);
33  }
34
35  @override
36  Widget build(BuildContext context) {
37    return Scaffold(
38      body: Center(
39        child: TextButton(
40          onPressed: () => Navigator.of(context).push(
41            MaterialPageRoute(
42              builder: (_) => const Screen2(),
43            ),
44          ),
45          child: const Text('Screen 2'),
46        ),
47      ),
48      floatingActionButton: FloatingActionButton(
49        onPressed: () => counter.value++,
50        child: const Icon(Icons.plus_one),
51      ),
52    );
53  }
54}
55
56class Screen2 extends StatefulWidget {
57  const Screen2({super.key});
58
59  @override
60  State<Screen2> createState() => _Screen2State();
61}
62
63class _Screen2State extends State<Screen2> {
64  late final ValueNotifier<int> counter;
65
66  @override
67  void initState() {
68    super.initState();
69    // достаём объект из локатора
70    counter = GetIt.I.get<ValueNotifier<int>>();
71  }
72
73  @override
74  Widget build(BuildContext context) {
75    return Scaffold(
76      body: Center(
77        child: ValueListenableBuilder(
78          valueListenable: counter,
79          builder: (context, value, _) => Text('$value'),
80        ),
81      ),
82    );
83  }
84}

Стоит отметить несколько важных деталей:

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

  • В месте вызова GetIt.I.get вполне мог произойти runtime exception в случае, если в локаторе такой зависимости нет. Таким образом, паттерн Service Locator не подразумевает compile-time safety (то есть ошибки, которые ранее были незаметны и случались во время работы приложения, будут мешать компиляции).

  • Чтобы удалить ValueNotifier из локатора и освободить ресурсы, нужно явно вызвать метод unregister. Таким образом, нельзя полагаться на систему сборки мусора (garbage collection) языка и нужно помнить о необходимости освободить используемые ресурсы.

  • Перечисленные выше риски возникают в том числе из-за того, что локатор — это некий god object. То есть у него слишком много контроля над программой и он может получить доступ в любые места приложения. Такой неограниченный доступ не соответствует паттерну «принцип минимального знания» (Principle of Least Knowledge) или принципу инкапсуляции («скрывай лишнее»).

Пример №2: применение DI

Передавая зависимости с помощью конструктора, мы можем упростить сразу несколько аспектов разработки. Помимо простого unit-тестирования (за счёт mock-зависимостей), как ни странно, становится проще проектировать системы. Убедимся на примерах.

1class Worker {
2  final Driver _driver;
3  final Runner _runner;
4
5  Worker({
6    required Driver driver,
7    required Runner runner,
8  })  : _driver = driver,
9        _runner = runner;
10
11  void main() {
12    _driver.drive();
13    _runner.run();
14  }
15}
16
17abstract class Runner {
18  void run();
19}
20
21class RunnerImpl implements Runner {
22  void run() {
23    putOnShoes();
24    /* some code */
25  }
26
27  void putOnShoes() {
28  /* cool shoes are being put on */
29  }
30}
31
32abstract class Driver {
33  void drive();
34}

image.png

Так выглядит пример из IoC с использованием DI. Заметим, что runner и driver никак друг с другом не связаны, но могут одновременно контролироваться классом worker.

Также важно, что с помощью паттерна Decoupling мы передаём в worker не реализации классов, а их интерфейсы. То есть класс worker имеет доступ лишь к основным функциям реализаций этих классов и не сможет вызывать какие-то дополнительные методы (например, putOnShoes() для runner) на уровне архитектуры.

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

1import 'dart:async';
2import 'dart:math';
3
4class NetworkManager {
5  final DownloadManager _downloadManager;
6  Timer? _timer;
7  final StreamController<double> _speedController = StreamController<double>();
8
9  NetworkManager(this._downloadManager);
10
11  void init() {
12    _timer = Timer.periodic(
13      const Duration(seconds: 1),
14      (_) {
15        final speed = Random().nextDouble() * 10;
16        if (speed < 5.0) {
17          _downloadManager.reportSlowSpeed();
18        }
19        _speedController.add(speed);
20      },
21    );
22  }
23
24  Stream<double> get speedStream => _speedController.stream;
25}
26
27class DownloadManager {
28  final NetworkManager _networkManager;
29  final StreamController<bool> _slowDownloadController =
30      StreamController<bool>();
31
32  DownloadManager(this._networkManager);
33
34  void init() {
35    _networkManager.speedStream.listen((speed) {
36      if (speed > 3.0) startDownload();
37    });
38  }
39
40  Stream<bool> get slowDownloadStream => _slowDownloadController.stream;
41
42  void startDownload() {}
43
44  void reportSlowSpeed() {
45    _slowDownloadController.add(true);
46  }
47}

Такой код может изначально показаться логичным, ведь нет явного вызова функций друг другом. Однако такой код приведёт к StackOverflow из-за множественных вызовов классами методов друг друга. Этот риск легко заметить, даже не заглядывая в код классов, только по конструкторам.

image-2.png

Можно заметить цикл в дереве зависимостей, так делать не стоит. Вот как можно решить этот вопрос:

image-3.png

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

1import 'dart:async';
2import 'dart:math';
3
4class NetworkInteractor {
5  final DownloadManager _downloadManager;
6  final NetworkManager _networkManager;
7
8  Coordinator({
9    required DownloadManager downloadManager,
10    required NetworkManager networkManager,
11  })  : _downloadManager = downloadManager,
12        _networkManager = networkManager;
13
14  void init() {
15    _networkManager.speedStream.listen((speed) {
16      if (speed < 5.0) {
17        _downloadManager.reportSlowSpeed();
18      }
19
20      if (speed > 3.0) {
21        _downloadManager.startDownload();
22      }
23    });
24  }
25}
26
27class NetworkManager {
28  late Timer? _timer;
29  late final StreamController<double> _speedController;
30
31  void init() {
32    _speedController = StreamController();
33    _timer = Timer.periodic(
34      const Duration(seconds: 1),
35      (_) {
36        final speed = calcSpeed();
37
38        _speedController.add(speed);
39      },
40    );
41  }
42
43  Stream<double> get speedStream => _speedController.stream;
44}
45
46class DownloadManager {
47  final StreamController<bool> _slowDownloadController =
48      StreamController<bool>();
49
50  Stream<bool> get slowDownloadStream => _slowDownloadController.stream;
51
52  void startDownload() {}
53
54  void reportSlowSpeed() {
55    _slowDownloadController.add(true);
56  }
57}

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

Пример №3: применение Scope

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

Для того чтобы объединить много зависимостей смежных частей проекта, их можно класть в DI-контейнер — класс примерно с таким API:

1abstract interface class DiContainer {
2  Future<void> init();
3  Future<void> dispose();
4}
5
6class MyFeatureDiContainer implements DiContainer {
7  final DepA a;
8  final DepB b;
9
10  MyFeatureDiContainer({
11    required this.a,
12    required this.b,
13  });
14
15  factory MyFeatureDiContainer.create({
16    required SomeExternalDep externalDep,
17  }) {
18    final a = DepA(externalDep);
19    final b = DepB();
20  
21    return MyFeatureDiContainer(a: a, b: b);
22  }
23
24  @override
25  Future<void> init() async {
26    await a.init();
27  }
28
29  @override
30  Future<void> dispose() async {
31    await a.dispose();
32  }
33}

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

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

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

Слово «скоуп» означает зону, на которую распространяется сам контейнер и его ЖЦ (жизненный цикл).

Untitled-2024-10-08-1752-2.png

Детали расположения этих скоупов можно рассмотреть на схеме выше.

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

1class _AppState extends State<App> {
2  @override
3  Widget build(BuildContext context) =>
4      ...
5      child: AppScope(
6        init: AppScopeDependenciesImpl.init,
7        initialization: (context) => const SplashScreen(),
8        initialized: (context) => AppSettings(
9          child: Auth(
10            notAuthorized: (context) => const LoginScreen(),
11            authorized: (context, user) => User(
12              key: ValueKey(user),
13              user: user,
14              child: UserScope(
15                init: UserScopeDependenciesImpl.init,
16                initialization: (context) => const HomeSplashScreen(),
17                initialized: (context) => ...
18              ),
19            ),
20          ),
21        ),
22      ),
23      ...
24}
25

Обсудим каждый скоуп из примера выше подробнее.

  1. AppScope

    • Отвечает за инициализацию зависимостей приложения.
    • Функция init создаёт контейнер зависимостей.
    • initialization и initialized управляют состояниями: пока идёт загрузка — SplashScreen, после — основное приложение.
    • Гарантирует доступность зависимостей для виджетов ниже в дереве.
  2. Auth

    • Управляет авторизацией пользователя.
    • Делит приложение на состояния: notAuthorized (показывает LoginScreen) и authorized (создаёт ветку с User).
    • Обеспечивает уверенность в наличии авторизованного пользователя для виджетов в ветке.
  3. User

    • Упрощает доступ к данным пользователя через User.of.
    • Существует только в состоянии authorized, возвращая User.
    • Использует key: ValueKey(user) для перестроения дерева в случае смены модели user.
  4. UserScope

    • Предоставляет ресурсы, зависящие от авторизованного пользователя (например, http-клиент с токеном).
    • При выходе пользователя автоматически удаляет конфиденциальные зависимости.
    • Показывает заглушку (HomeSplashScreen) на время инициализации.
  5. Динамическая организация скоупов

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

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

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

В случае когда скоуп находится во Flutter-приложении, можно привязать его ЖЦ к виджету, а именно его вхождению и выходу из дерева виджетов. Для этого можно использовать методы initState, dispose.

Для случаев когда дерево виджетов недоступно, например при запуске процесса в фоне или когда фича не привязана к UI, нужно управлять ЖЦ руками. В таком случае стоит в начале метода создавать скоуп, вызывать init, а в конце не забыть вызвать dispose. Вот пример такого случая:

1void backgroundWork() async {
2  final container = SomeContainer.create();
3  try {
4    final workResult = await container.dep.work();
5    await container.database.save(workResult);
6    Analytics.event(workResult);
7  } on Object catch (e, st) {
8    Analytics.error(e, st);
9  }
10
11  await container.dispose();
12}

Итак, давайте коротко вспомним, что узнали о паттернах Service Locator, Dependency Injection и Scope.

  • Service Locator — проверенный паттерн программирования, который подойдёт для проектов, где важна скорость разработки и где нечасто происходят изменения. Его легко понять и начать использовать, но стоит делать это осторожно, избегая рисков, которые мы обсудили.

  • Dependency Injection — менее гибкий, но более надёжный способ организовать передачу зависимостей в системе. Подходит для большинства проектов, упрощает тестирование, позволяет видеть понятное дерево зависимостей всей программы.

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

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

Отмечайте параграфы как прочитанные чтобы видеть свой прогресс обучения

Вступайте в сообщество хендбука

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