Ранее мы рассмотрели основы IoC
(Inversion of Control) — принципа программирования, при котором управление определёнными аспектами программы передаётся внешним компонентам, что позволяет разделить ответственность, уменьшить связность и повысить гибкость системы.
В этом параграфе мы сфокусируемся на примерах и смежных паттернах проектирования архитектуры, основанных на IoC
:
-
Dependency Injection
(DI). -
Service Locator
. -
Decoupling
.
Плюс рассмотрим понятие скоупа (scope) и жизненного цикла приложения.
Но для начала давайте вспомним, что представляют собой паттерны выше и в чём их плюсы и минусы.
Разбираемся в терминах
Service Locator
Service Locator
— это паттерн программирования, описанный Мартином Фаулером в 2004 году.
Концепция локатора максимально проста: существует некий контейнер, в который можно в любой момент «поместить» зависимость, чтобы получить к ней доступ в другом месте приложения.
На схеме ниже локатор — синий контейнер.
Плюсы применения паттерна
- Простой паттерн, низкий порог входа для новых разработчиков.
Минусы применения паттерна
-
Нельзя по конструктору класса понять, какие зависимости он использует.
-
Дополнительное время, которое придется потратить на unit-тестирование.
-
API может быть настолько открыто, что любую зависимость можно получить в любом месте.
Для больших проектов, над которыми работают целые команды разработчиков, применение локатора несёт риск появления неявного графа зависимостей и даже циклических зависимостей.
Dependency Injection
Паттерн DI
ещё проще — отдельный объект заполняет поля классов реализациями нужных зависимостей, формируя диаграмму зависимостей с чёткой структурой. Паттерн включает в себя три типа:
-
Инъекция через конструктор — зависимости передаются в конструкторе класса в момент его создания.
-
Инъекция через установщик (setter) — зависимости передаются после создания класса через специальный метод setter, который заполняет ранее пустые поля класса.
-
Инъекция через интерфейс — зависимости внедряются через специальный метод, определённый в интерфейсе, который класс реализует для явного получения необходимого сервиса.
Подход с конструктором не только самый простой в реализации, но также самый безопасный с точки зрения циклических зависимостей и runtime-ошибок. Далее в статье мы будем говорить в основном о нём.
В отличие от локатора, DI
через конструктор упрощает тестирование и позволяет уже на этапе создания класса знать его зависимости. Ниже приведена диаграмма классов, где у MobileApp
есть три поля для сервисов, которые могут быть переданы в него с помощью DI
.
Также важно заметить, почему используется слово 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-зависимостей), как ни странно, становится проще проектировать системы. Убедимся на примерах.
|
|
Так выглядит пример из 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 из-за множественных вызовов классами методов друг друга. Этот риск легко заметить, даже не заглядывая в код классов, только по конструкторам.
Можно заметить цикл в дереве зависимостей, так делать не стоит. Вот как можно решить этот вопрос:
То есть создать третий класс, который будет получать данные о скорости и управлять загрузкой.
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}
В проекте используется несколько таких контейнеров, разные для разных разделов. Каждый контейнер относится к конкретной функции, область
каждого из них равна области
этой функции.
Также существует контейнер с общими зависимостями, в нём будут логгер, репортер и подобные классы. Такой контейнер относится ко всему проекту, так что его область
максимальна.
Наконец, в почти каждом проекте есть система авторизации, и для связанных с ней классов необходима ещё одна область — область аккаунта.
Слово «скоуп» означает зону, на которую распространяется сам контейнер и его ЖЦ (жизненный цикл).
Детали расположения этих скоупов можно рассмотреть на схеме выше.
А ниже — пример использования скоупов во 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
Обсудим каждый скоуп из примера выше подробнее.
-
AppScope
- Отвечает за инициализацию зависимостей приложения.
- Функция
init
создаёт контейнер зависимостей. initialization
иinitialized
управляют состояниями: пока идёт загрузка —SplashScreen
, после — основное приложение.- Гарантирует доступность зависимостей для виджетов ниже в дереве.
-
Auth
- Управляет авторизацией пользователя.
- Делит приложение на состояния:
notAuthorized
(показываетLoginScreen
) иauthorized
(создаёт ветку сUser
). - Обеспечивает уверенность в наличии авторизованного пользователя для виджетов в ветке.
-
User
- Упрощает доступ к данным пользователя через
User.of
. - Существует только в состоянии
authorized
, возвращаяUser
. - Использует
key: ValueKey(user)
для перестроения дерева в случае смены моделиuser
.
- Упрощает доступ к данным пользователя через
-
UserScope
- Предоставляет ресурсы, зависящие от авторизованного пользователя (например, http-клиент с токеном).
- При выходе пользователя автоматически удаляет конфиденциальные зависимости.
- Показывает заглушку (
HomeSplashScreen
) на время инициализации.
-
Динамическая организация скоупов
- Скоупы могут быть созданы для отдельных экранов или их частей (экран фичи регистрации, раздел уведомлений в настройках).
Мы несколько раз упоминали инициализацию того или иного скоупа (его контейнера) выше. Теперь рассмотрим наглядно, как выглядит жизненный цикл всех скоупов сразу.
Жизненным циклом можно управлять двумя способами: в виджетах и вне виджетов.
В случае когда скоуп находится во 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
— паттерн для проектов со сложной структурой и множеством контекстов. Позволяет декларативно контролировать жизненный цикл, ограничивать зоны видимости зависимостей и гибко разделять проект на модули и фичи.
Благодаря этим паттернам вы сможете создавать безопасные и устойчивые компоненты, которые будет просто тестировать и модифицировать.