В этом параграфе мы попробуем создать систему контроля над зависимостями на основе паттерна IoC
во Flutter, не используя никаких сторонних пакетов, задействуя только то, что даёт нам сам фреймворк.
Это вовсе не означает, что вам не нужны сторонние решения. Скорее наша цель — показать вам пример, который поможет вам оценивать пакеты с pub.dev и выбирать те, что больше соответствуют и вашим задачам, и самой парадигме Flutter и Dart.
В тех же проектах, где это принципиально, вы сможете уменьшить и даже полностью избежать технического долга, связанного с поддержкой и обновлением сторонних решений. Кто работал на крупных проектах, знает, сколько может стоить проекту простой переход какого-нибудь пакета с версии N на версию N + 1 с бесконечными breaking changes.
Но сначала вспомним о проблемах, которые мы хотим решить с помощью IoC.
Чем хорош IoC
Если какой-то класс нуждается в работе другого класса, то самое простое, что мы можем сделать, — создать этот другой класс в конструкторе первого и пользоваться им.
1class B {
2 B();
3
4 ...
5}
6
7class A {
8 late final B b;
9
10 A() {
11 b = B();
12 }
13}
В будущем это очень сильно испортит жизнь проекту, потому что один класс будет зависеть от другого.
Теперь, если конструктор класса B
в какой-то момент изменится, нам придётся привести в соответствие все места, где создаётся класс B
, даже если работа класса B
при этом никак не изменилась и ни на что другое не повлияла.
И, возможно, теперь нам придётся изменить также конструктор класса A
:
1class B {
2 B(SomeClass someParam);
3
4 ...
5}
6
7class A {
8 late final B b;
9
10 A(SomeClass someParam) {
11 b = B(someParam);
12 }
13}
Если теперь представить, что есть ещё несколько классов, которые точно так же зависят от класса A
, а изменения придётся вносить и в них, и в их конструкторы, мы поймём, что такое ад зависимостей (dependency hell).
Если класс B
изменяется редко (как List
, String
, int
и т. д.), то хлопот не будет, но если часто, если это как раз один из тех классов, где сосредоточено большинство усилий разработчиков, то при каждом изменении в нём надо будет переписывать весь остальной код, которого эти изменения напрямую никак не касаются.
Избежать ада зависимостей можно разными способами, но так или иначе всё это будет сводиться к IoC.
Если объяснить суть IoC максимально кратко, то его задача в том, чтобы ослабить зависимость редко меняющихся классов от часто меняющихся.
1class A {
2 final B b;
3
4 A(this.b);
5}
В данном примере мы получаем ссылку на уже готовый инстанс класса B
. И нас не заботит ни его создание, ни его утилизация, ни его жизненный цикл, ни то, какие данные ему передавать в конструктор. Теперь класс B
может легко модифицироваться, класс A
при этом не будет зависеть от этих изменений.
И всё бы хорошо, но ведь где-то должен создаваться инстанс класса B
. И каким-то образом класс A
должен будет получить затем доступ к этому инстансу, или, как в нашем примере, кто-то другой должен создать класс A
, передав ему ещё и ссылку на созданный инстанс класса B
.
Собственно, в этом месте и начинаются различные пути построения архитектуры, зависящей от ответов на вопросы, где создавать инстансы классов, как передавать их друг другу, где и когда утилизировать классы, после того как отпала необходимость в них.
Посмотрим, что предлагает нам Flutter «из коробки» без использования внешних пакетов.
Контекст (context)
Если вы пишете приложение на чистом Dart, то дальше можно не читать.
На чистом Dart вам придётся или изобретать свой собственный велосипед, или пользоваться готовыми велосипедами сторонних разработчиков. Но у Flutter есть всё, что нам необходимо для построения IoC.
И прежде всего это вездесущее дерево виджетов, а точнее, дерево элементов. Также есть context
, передаваемый в каждый метод build
, который на деле просто ссылка на это дерево элементов. Дерево распространяется вниз: виджеты создают дочерние виджеты (виджет + элемент). И дерево же позволяет нам подняться вверх — от детей к родителям — и получить данные от этих родителей. Это как раз то, что нужно для IoC.
Давайте посмотрим на примере простого приложения-счётчика:
Код
1class MainWidget extends StatefulWidget {
2 const MainWidget({super.key});
3
4 @override
5 State<MainWidget> createState() => MainWidgetState();
6
7}
8
9class _MainWidgetState extends State<MainWidget> {
10 int get counter => _counter;
11 var _counter = 0;
12
13 @override
14 Widget build(BuildContext context) => Scaffold(
15 body: Center(
16 child: Column(
17 mainAxisAlignment: MainAxisAlignment.center,
18 children: [
19 const CounterView(),
20 ],
21 ),
22 ),
23 );
24}
25
26class CounterView extends StatefulWidget {
27 const CounterView({super.key});
28
29 @override
30 State<CounterView> createState() => _CounterViewState();
31}
32
33class _CounterViewState extends State<CounterView> {
34 late final _MainWidgetState mainWidgetState;
35
36 @override
37 void initState() {
38 super.initState();
39 mainWidgetState = context.findAncestorStateOfType<_MainWidgetState>()!;
40 }
41
42 @override
43 Widget build(BuildContext context) {
44 final counter = mainWidgetState.counter;
45 return Text('$counter');
46 }
47}
Виджет CounterView
, как и любой другой виджет, находящийся ниже по дереву от MainWidget
, может получить доступ к стейту нашего главного виджета _MainWidgetState
через метод findAncestorStateOfType
. Но если вы захотите разместить эти виджеты в отдельных файлах, надо будет убрать знак подчёркивания из названия стейта, то есть из приватного сделать его публичным.
Всё! Теперь все данные вашего экрана (или какого-то другого композитного виджета), которые могут понадобиться в разных внутренних виджетах, вы можете разместить внутри стейта _MainWidgetState
. И там же — всю необходимую логику работы с экраном.
Важно
Важно
Имеется в виду логика презентационного слоя. Бизнес-логику стоит выносить отдельно — подальше от BuildContext.
По сути, стейт главного виджета вашего экрана может стать контроллером всего вашего экрана. Можно даже и назвать его исходя не из его технической реализации (mainWidgetState
), а в соответствии с его внутренней логикой: controller
, screenController
, mainManager
и т. п.
В этом случае, если вы когда-нибудь захотите сделать отдельный класс контроллера/менеджера, вам не придётся менять название. Именно этот класс будет отвечать за предоставление данных вниз по дереву. И он же, с помощью метода dispose
, может отвечать за утилизацию всех созданных ресурсов.
Таким образом мы получаем из StatefulWidget
готовый скоуп. Все виджеты, находящиеся ниже по дереву, точно будут знать, что наверху есть нужный им «контроллер», и будут иметь доступ к его данным. Никакие виджеты на других экранах, то есть в других ветках дерева виджетов, не получат доступ к вашему «скоупу», если вы не передадите им вручную context
, но делать этого не стоит. И при завершении работы вашего главного виджета все данные, при грамотной реализации dispose
, будут утилизированы: лишние данные/ресурсы/классы не проживут дольше, чем это необходимо.
Именно так работает класс Navigator
. Его метод Navigator.of
под капотом использует метод findAncestorStateOfType
и возвращает стейт NavigatorState
.
InheritedWidget
Если в примере со счётчиком вы измените значение _counter
и даже выполните setState
, в CounterView
ничего не изменится, потому что он константный: с точки зрения процесса, который управляет перестроением дерева, в нём ничего не изменилось, и обновлять его не надо. Измените код и убедитесь в этом:
Код
1class _MainWidgetState extends State<MainWidget> {
2 int get counter => _counter;
3 var _counter = 0;
4
5 void _incrementCounter() {
6 setState(() {
7 _counter++;
8 });
9 }
10
11 @override
12 Widget build(BuildContext context) => Scaffold(
13 body: Center(
14 child: Column(
15 mainAxisAlignment: MainAxisAlignment.center,
16 children: [
17 const CounterView(),
18 ElevatedButton(
19 onPressed: _incrementCounter,
20 child: Text('increment counter'),
21 ),
22 ],
23 ),
24 ),
25 );
26}
Если мы заменим const CounterView()
на CounterView()
, всё заработает. Но это неправильно. Нам нужно, чтобы и константные виджеты могли перестраиваться. То есть каким-то образом нам нужно уведомить CounterView
о необходимости обновиться. И снова во Flutter всё уже есть для этого. Это InheridetWidget
, с которым мы познакомились в соответствующем параграфе.
Код
1class _MainWidgetInheritedWidget extends InheritedWidget {
2 final _MainWidgetState state; // Неудачное название, но об этом чуть позже.
3
4 const _MainWidgetInheritedWidget({
5 required this.state,
6 required super.child,
7 });
8
9 @override
10 bool updateShouldNotify(_MainWidgetInheritedWidget oldWidget) => true;
11}
12
13class _MainWidgetState extends State<MainWidget> {
14 ...
15
16 @override
17 Widget build(BuildContext context) => _MainWidgetInheritedWidget(
18 state: this,
19 child: ...;
20 );
21}
Обращение к InheritedWidget
будет отличаться от предыдущего варианта:
Код
1class CounterView extends StatelessWidget {
2 const CounterView({super.key});
3
4 @override
5 Widget build(BuildContext context) {
6 final mainController = context
7 .dependOnInheritedWidgetOfExactType<_MainWidgetInheritedWidget>()!
8 .state; // Повторю, state — неудачное название.
9
10 return Text('${mainController.counter}');
11 }
12}
Теперь нам не нужно сохранять ссылку на наш главный контроллер в стейте StatefulWidget
, и даже противопоказано это делать. Метод не зря начинается не с find
, а с depend
. Мы не ищем, а подписываемся на InheritedWidget
, то есть на его изменения. И делать это можно только в двух местах: в методе build
и методе didChangeDependencies
.
Про didChangeDependencies
нам надо знать сейчас только две вещи: первый раз он запускается в обязательном порядке после initState
, а затем каждый раз, когда любая зависимость, на которую мы в этом виджете подписались, уведомляет о своём изменении.
А если нам нужен доступ к контроллеру из initState
?
Можно воспользоваться методом context.getInheritedWidgetOfExactType
.
Он также предоставляет доступ к InheritedWidget
, но не подписывается на него. Поэтому его можно использовать везде, а не только в build
и didChangeDependencies
. getInheritedWidgetOfExactType
не равнозначен findAncestorStateOfType
.
findAncestorStateOfType
ищет, поднимаясь по дереву, и чем больше дерево, тем дольше поиск, то есть сложность поиска O(N), а getInheritedWidgetOfExactType
берёт уже готовое.
Для доступа к виджету ему требуется константное время O(1), так что InheritedWidget
предпочтительнее в большинстве случаев. Navigator
— одно из редких исключений, когда в быстром доступе нет необходимости.
Конкретно в нашем примере CounterView
будет обновляться на каждом пересоздании _MainWidgetInheritedWidget
(то есть при каждом вызове метода MainWidgetState.build
), так как в updateShouldNotify
мы указали true
.
Да, счётчик начнёт меняться. Но реализовано это будет далеко не самым оптимальным образом. Сделать оптимальный вариант несложно: важно указать условия, при которых мы должно уведомить дочерние виджеты о необходимости обновить своё состояние.
Но тут можно допустить ошибку. Сделайте паузу и попробуйте найти ошибку в коде ниже:
Код с ошибкой
1class _MainWidgetInheritedWidget extends InheritedWidget {
2 final _MainWidgetState state;
3
4 _MainWidgetInheritedWidget({
5 required this.state,
6 });
7
8 @override
9 bool updateShouldNotify(_MainWidgetInheritedWidget oldWidget) =>
10 oldWidget.state.counter != state.counter;
11}
Ответ и корректная реализация
Данная ошибка — одна из причин, почему не стоит давать стейту главного виджета наименование state
. С таким названием может возникнуть обманчивое впечатление, что в _MainWidgetInheritedWidget
мы сохраняем некое состояние виджета. И, соответственно, потом в updateShouldNotify
мы как будто можем сравнить прошлое состояние с нынешним.
Но это не так:
1@override
2bool updateShouldNotify(_MainWidgetInheritedWidget oldWidget) =>
3 oldWidget.state.counter != state.counter; // Всегда false!
Мы сохраняем не состояние, а ссылку на инстанс класса _MainWidgetState
, называемого состоянием. То есть это всегда будет один и тот же объект. Поэтому, чтобы узнать, изменилось ли значение в стейте/контроллере, мы должны сохранить это значение внутри _MainWidgetInheritedWidget
:
1class _MainWidgetInheritedWidget extends InheritedWidget {
2 final _MainWidgetState controller; // Даём название, отражающее логику, а не техническую реализацию!
3 final int counter;
4
5 _MainWidgetInheritedWidget({
6 required this.controller,
7 required super.child,
8 }) : counter = controller.counter;
9
10 @override
11 bool updateShouldNotify(_MainWidgetInheritedWidget oldWidget) =>
12 oldWidget.counter != counter;
13}
При пересоздании виджета в updateShouldNotify
мы можем сравнить старый InheritedWidget
с новым и увидеть, изменился ли счётчик.
Изложенная архитектура широко используется внутри SDK Flutter. Например, в классе Theme
. Загляните в его метод Theme.of
, и вы увидите там уже знакомый метод dependOnInheritedWidgetOfExactType
. Вы тоже можете добавить статический метод of
в свой главный виджет. Только не забудьте сделать стейт публичным, так как некорректно с помощью публичного метода отдавать ссылку на приватный класс.
Код
1class MainWidget extends StatefulWidget {
2 ...
3
4 static MainWidgetState of(BuildContext context) =>
5 context
6 .dependOnInheritedWidgetOfExactType<_MainWidgetInheritedWidget>()
7 ?.controller ??
8 (throw Exception('$MainWidget not found in the context.'));
9}
Вы получите очень удобную форму обращения к вашему контроллеру:
Код
1@override
2Widget build(BuildContext context) {
3 final counter = MainWidget.of(context).counter;
4 return Text('$counter');
5}
Теперь данные будут меняться реактивно. И обновление будет происходить только при изменении счётчика.
Можно немного доработать метод of
, чтобы при необходимости использовать и dependOnInheritedWidgetOfExactType
, и getInheritedWidgetOfExactType
:
Код
1static MainWidgetState of(BuildContext context, {bool listen = true}) {
2 final inheritedWidget =
3 listen
4 ? context.dependOnInheritedWidgetOfExactType<_MainWidgetInheritedWidget>()
5 : context.getInheritedWidgetOfExactType<_MainWidgetInheritedWidget>();
6
7 return inheritedWidget?.controller ??
8 (throw Exception('$MainWidget not found in the context.'));
9}
В конечном итоге это будет выглядеть примерно так:
Код
1class MainWidget extends StatefulWidget {
2 const MainWidget({super.key});
3
4 static MainWidgetState of(BuildContext context, {bool listen = true}) {
5 final inheritedWidget =
6 listen
7 ? context.dependOnInheritedWidgetOfExactType<_MainWidgetInheritedWidget>()
8 : context.getInheritedWidgetOfExactType<_MainWidgetInheritedWidget>();
9
10 return inheritedWidget?.controller ??
11 (throw Exception('$MainWidget not found in the context.'));
12 }
13
14 @override
15 State<MainWidget> createState() => MainWidgetState();
16}
17
18class MainWidgetState extends State<MainWidget> {
19 int get counter => _counter;
20 var _counter = 0;
21
22 void _incrementCounter() {
23 setState(() {
24 _counter++;
25 });
26 }
27
28 @override
29 Widget build(BuildContext context) => _MainWidgetInheritedWidget(
30 controller: this,
31 child: Scaffold(
32 body: Center(
33 child: Column(
34 mainAxisAlignment: MainAxisAlignment.center,
35 children: [
36 const CounterView(),
37 ElevatedButton(
38 onPressed: _incrementCounter,
39 child: Text('increment counter'),
40 ),
41 ],
42 ),
43 ),
44 ),
45 );
46}
47
48class _MainWidgetInheritedWidget extends InheritedWidget {
49 final MainWidgetState controller;
50 final int counter;
51
52 _MainWidgetInheritedWidget({
53 required this.controller,
54 required super.child,
55 }) : counter = controller.counter;
56
57 @override
58 bool updateShouldNotify(_MainWidgetInheritedWidget oldWidget) =>
59 oldWidget.counter != counter;
60}
61
62class CounterView extends StatefulWidget {
63 const CounterView({super.key});
64
65 @override
66 State<CounterView> createState() => _CounterViewState();
67}
68
69class _CounterViewState extends State<CounterView> {
70 @override
71 void initState() {
72 super.initState();
73
74 // При необходимости обратиться к `InheritedWidget` из `initState`
75 // используем `listen: false`. То есть берём данные, но не подписываемся.
76 final counter = MainWidget.of(context, listen: false).counter;
77 print(counter);
78 }
79
80 @override
81 Widget build(BuildContext context) {
82 // Подписываемся на InheritedWidget.
83 final counter = MainWidget.of(context).counter;
84 return Text('$counter');
85 }
86}
InheritedModel
Недостаток InheritedWidget
проявляется тогда, когда нужно контролировать несколько значений: подписчики будут обновляться при изменении каждого из контролируемых параметров, что в какой-то момент может стать очень дорогим.
Так, например, работает MediaQuery
и его метод of
: вам нужен размер экрана MediaQuery.of(context).size
, но, взяв его, вы подпишитесь автоматически на любые изменения в MediaQuery
, которые вам не нужны. После этого ваш виджет, к примеру, будет перестраиваться и при изменении ориентации экрана.
Расширим пример счётчика на два значения:
Код
1...
2
3class MainWidgetState extends State<MainWidget> {
4 int get counter1 => _counter1;
5 int _counter1 = 0;
6
7 int get counter2 => _counter2;
8 int _counter2 = 0;
9
10 void _incrementCounter1() {
11 setState(() {
12 _counter1++;
13 });
14 }
15
16 void _incrementCounter2() {
17 setState(() {
18 _counter2++;
19 });
20 }
21
22 @override
23 Widget build(BuildContext context) => _MainWidgetInheritedWidget(
24 controller: this,
25 child: Scaffold(
26 body: Center(
27 child: Column(
28 mainAxisAlignment: MainAxisAlignment.center,
29 children: [
30 const CounterView1(),
31 ElevatedButton(
32 onPressed: _incrementCounter1,
33 child: Text('increment counter 1'),
34 ),
35 const CounterView2(),
36 ElevatedButton(
37 onPressed: _incrementCounter2,
38 child: Text('increment counter 2'),
39 ),
40 ],
41 ),
42 ),
43 ),
44 );
45}
46
47class _MainWidgetInheritedWidget extends InheritedWidget {
48 final MainWidgetState controller;
49 final int counter1;
50 final int counter2;
51
52 _MainWidgetInheritedWidget({required this.controller, required super.child})
53 : counter1 = controller.counter1,
54 counter2 = controller.counter2;
55
56 @override
57 bool updateShouldNotify(_MainWidgetInheritedWidget oldWidget) =>
58 oldWidget.counter1 != counter1 || oldWidget.counter2 != counter2;
59}
60
61class CounterView1 extends StatefulWidget {
62 const CounterView1({super.key});
63
64 @override
65 State<CounterView1> createState() => _CounterView1State();
66}
67
68class _CounterView1State extends State<CounterView1> {
69 int _updateCounter = 0;
70
71 @override
72 Widget build(BuildContext context) {
73 final counter = MainWidget.of(context).counter1;
74 _updateCounter++;
75
76 return Text('$counter (updated: $_updateCounter)');
77 }
78}
79
80class CounterView2 extends StatefulWidget {
81 const CounterView2({super.key});
82
83 @override
84 State<CounterView2> createState() => _CounterView2State();
85}
86
87class _CounterView2State extends State<CounterView2> {
88 int _updateCounter = 0;
89
90 @override
91 Widget build(BuildContext context) {
92 final counter = MainWidget.of(context).counter2;
93 _updateCounter++;
94
95 return Text('$counter (updated: $_updateCounter)');
96 }
97}
При увеличении любого из счётчиков перестраивается каждый из виджетов, подписавшийся на InheritedWidget
.
Избавиться от этого недостатка помогает InheritedModel
с его аспектами (см. документацию по InheritedModel). С InheritedModel
мы можем рассматривать наш контроллер не как единое целое, а только с какой-то стороны, то есть рассматривать какой-то его аспект.
В нашем примере каждый аспект будет отвечать за один из параметров контроллера, хотя в реальности аспекты могут быть гораздо более сложными.
Код
1enum _Aspect { counter1, counter2 }
2
3class _MainWidgetInheritedModel extends InheritedModel<_Aspect> {
4 final MainWidgetState controller;
5 final int counter1;
6 final int counter2;
7
8 _MainWidgetInheritedModel({required this.controller, required super.child})
9 : counter1 = controller.counter1,
10 counter2 = controller.counter2;
11
12 static _MainWidgetInheritedModel of(
13 BuildContext context, {
14 _Aspect? aspect,
15 }) =>
16 InheritedModel.inheritFrom(context, aspect: aspect) ??
17 (throw Exception('$MainWidget not found in the context.'));
18
19 @override
20 bool updateShouldNotify(_MainWidgetInheritedModel oldWidget) =>
21 oldWidget.counter1 != counter1 || oldWidget.counter2 != counter2;
22
23 @override
24 bool updateShouldNotifyDependent(
25 _MainWidgetInheritedModel oldWidget,
26 Set<_Aspect> dependencies,
27 ) =>
28 dependencies.contains(_Aspect.counter1) && oldWidget.counter1 != counter1 ||
29 dependencies.contains(_Aspect.counter2) && oldWidget.counter2 != counter2;
30}
Для подписки на InheritedModel
есть специальный метод InheritedModel.inheritFrom
, в котором мы теперь указываем, какой именно аспект нас интересует. При перестроении для каждого подписавшегося виджета будет вызван метод updateShouldNotifyDependent
, куда будут переданы все аспекты, на которые этот виджет подписался с помощью InheritedModel.inheritFrom
. И дальше уже путём нехитрых сравнений в updateShouldNotifyDependent
мы вычисляем, нужно ли обновлять этот виджет.
Если в функции InheritedModel.inheritFrom
не указать аспект, то метод updateShouldNotifyDependent
вызван не будет, проверяться будет только метод updateShouldNotify
, то есть в таком случае сработает логика обычного InheritedWidget
.
Чаще всего аспекты — это дело внутренней реализации и для публичного использования готовят отдельные публичные методы:
Код
1class MainWidget extends StatefulWidget {
2 ...
3
4 // Подписка на _MainWidgetInheritedModel без указания аспекта,
5 // то есть как на обычный InheritedWidget.
6 static MainWidgetState of(BuildContext context) =>
7 _MainWidgetInheritedModel.of(context).controller;
8
9 // Подписка только на первый счётчик.
10 static int counter1Of(BuildContext context) =>
11 _MainWidgetInheritedModel.of(
12 context,
13 aspect: _Aspect.counter1,
14 ).controller.counter1;
15
16 // Подписка только на второй счётчик.
17 static int counter2Of(BuildContext context) =>
18 _MainWidgetInheritedModel.of(
19 context,
20 aspect: _Aspect.counter2,
21 ).controller.counter2;
22
23 ...
24}
MediaQuery
, о котором говорили выше, тоже так умеет. И если нам нужно узнать размер экрана, надо использовать MediaQuery.sizeOf(context)
вместо MediaQuery.of(context).size
. В этом случае наш виджет не будет обновляться при изменении ориентации устройства.
Посмотрим на наш итоговый пример:
Код
1class MainWidget extends StatefulWidget {
2 const MainWidget({super.key});
3
4 static MainWidgetState of(BuildContext context) =>
5 _MainWidgetInheritedModel.of(context).controller;
6
7 static int counter1Of(BuildContext context) =>
8 _MainWidgetInheritedModel.of(
9 context,
10 aspect: _Aspect.counter1,
11 ).controller.counter1;
12
13 static int counter2Of(BuildContext context) =>
14 _MainWidgetInheritedModel.of(
15 context,
16 aspect: _Aspect.counter2,
17 ).controller.counter2;
18
19 @override
20 State<MainWidget> createState() => MainWidgetState();
21}
22
23class MainWidgetState extends State<MainWidget> {
24 int get counter1 => _counter1;
25 int _counter1 = 0;
26
27 int get counter2 => _counter2;
28 int _counter2 = 0;
29
30 void _incrementCounter1() {
31 setState(() {
32 _counter1++;
33 });
34 }
35
36 void _incrementCounter2() {
37 setState(() {
38 _counter2++;
39 });
40 }
41
42 @override
43 Widget build(BuildContext context) => _MainWidgetInheritedModel(
44 controller: this,
45 child: Scaffold(
46 body: Center(
47 child: Column(
48 mainAxisAlignment: MainAxisAlignment.center,
49 children: [
50 const CounterView1(),
51 ElevatedButton(
52 onPressed: _incrementCounter1,
53 child: Text('increment counter 1'),
54 ),
55 const CounterView2(),
56 ElevatedButton(
57 onPressed: _incrementCounter2,
58 child: Text('increment counter 2'),
59 ),
60 ],
61 ),
62 ),
63 ),
64 );
65}
66
67enum _Aspect { counter1, counter2 }
68
69class _MainWidgetInheritedModel extends InheritedModel<_Aspect> {
70 final MainWidgetState controller;
71 final int counter1;
72 final int counter2;
73
74 _MainWidgetInheritedModel({required this.controller, required super.child})
75 : counter1 = controller.counter1,
76 counter2 = controller.counter2;
77
78 static _MainWidgetInheritedModel of(
79 BuildContext context, {
80 _Aspect? aspect,
81 }) =>
82 InheritedModel.inheritFrom(context, aspect: aspect) ??
83 (throw Exception('$MainWidget not found in the context.'));
84
85 @override
86 bool updateShouldNotify(_MainWidgetInheritedModel oldWidget) =>
87 oldWidget.counter1 != counter1 || oldWidget.counter2 != counter2;
88
89 @override
90 bool updateShouldNotifyDependent(
91 _MainWidgetInheritedModel oldWidget,
92 Set<_Aspect> dependencies,
93 ) =>
94 dependencies.contains(_Aspect.counter1) && oldWidget.counter1 != counter1 ||
95 dependencies.contains(_Aspect.counter2) && oldWidget.counter2 != counter2;
96}
97
98class CounterView1 extends StatefulWidget {
99 const CounterView1({super.key});
100
101 @override
102 State<CounterView1> createState() => _CounterView1State();
103}
104
105class _CounterView1State extends State<CounterView1> {
106 int _updateCounter = 0;
107
108 @override
109 Widget build(BuildContext context) {
110 final counter = MainWidget.counter1Of(context);
111 _updateCounter++;
112
113 return Text('$counter (updated: $_updateCounter)');
114 }
115}
116
117class CounterView2 extends StatefulWidget {
118 const CounterView2({super.key});
119
120 @override
121 State<CounterView2> createState() => _CounterView2State();
122}
123
124class _CounterView2State extends State<CounterView2> {
125 int _updateCounter = 0;
126
127 @override
128 Widget build(BuildContext context) {
129 final counter = MainWidget.counter2Of(context);
130 _updateCounter++;
131
132 return Text('$counter (updated: $_updateCounter)');
133 }
134}
Теперь счётчики меняются независимо, не затрагивая друг друга.
Кого-то может отпугнуть количество кода, которое необходимо написать, но:
-
Во-первых, это родной вариант самого Flutter. А независимость от чужого кода часто становится преимуществом на проектах с длинной дистанцией. Зависимость же от пакетов, которые сегодня на слуху, наоборот, может подпортить в долгосроке жизнь проекту, если эти пакеты перестанут поддерживаться или автор решит всё кардинальным образом переделать и тем заставит нас или работать со старой, не поддерживаемой версией, или переписывать весь наш проект под новую реальность.
-
Во-вторых, ускорить написание достаточно шаблонного кода можно через сниппеты VS Code или шаблоны Android Studio. Разбирать же такой код, отлаживать, дебажить очень просто, потому что он весь как на ладони.
-
Ну и, в-третьих, написать
MyWidget.of
кажется чуть более приятным, а главное, понятным, чемProvider.of<MyClass>
.
Скоупы
Как передавать и получать зависимости, мы теперь знаем. Следующий шаг — реализация скоупов.
Одна из серьёзных проблем, которая может сильно усложнить поддержку приложения в долгосрочной перспективе, — отсутствие ограничений для доступа к данным.
Например, такая проблема появляется при использовании глобальных переменных или общего контейнера данных. Доступ к ним можно получить из любой точки вашего кода.
С одной стороны, такой подход позволяет очень быстро написать рабочее приложение: здесь не нужен ни IoC, ни state management. С другой стороны, изменение и использование данных выходит из-под контроля разработчиков.
Помимо возможного нарушения безопасности данных, возникает и проблема с поддержкой. Например, вы помещаете в глобальный контейнер какие-то зависимости своего модуля, позже хотите изменить структуру, удалить ставшие ненужными зависимости, но узнаёте, что другая команда уже стала их использовать.
Хорошо ещё, если ваши зависимости хранятся в виде именованных переменных, тогда при удалении вы просто не сможете скомпилировать код. А если ваш стейт-менеджер хранит всё в виде «ключ-значение»? В этом случае ошибка обнаружится только в рантайме. А так как при внесении ваших изменений тестировщики будут тщательно тестировать только ваш модуль, ведь и с их, и с вашей точки зрения только в нём произошли изменения, такую ошибку легко пропустить в прод.
Чтобы этого не случилось, были придуманы скоупы. Скоупами пользуется каждый разработчик, пишущий код. Это области видимости внутри нашего кода. Находясь внутри одной области видимости, мы имеем доступ к параметрам (константам, переменным и методам), определённым в этой и во всех родительских областях, но не можем получить доступ к дочерним или соседним областям видимости. Также каждая область видимости может перекрыть параметры родительской области видимости, определив собственные параметры с таким же именем, тем самым закрыв к ним доступ из своей и дочерних областей.
В IoC под скоупами мы имеем в виду ровно то же самое, только уровнем выше. То есть скоупы дадут нам возможность создавать независимые друг от друга блоки бизнес-логики, ограничивая доступ к ним подконтрольных нам модулей извне.
Чтобы понять, как это может выглядеть во 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: AuthScope(
10 notAuthorized: (context) => const LoginScreen(),
11 authorized: (context, user) => UserScope(
12 key: ValueKey(user),
13 user: user,
14 init: UserScopeDependenciesImpl.init,
15 initialization: (context) => const HomeSplashScreen(),
16 initialized: (context) => ...
17 ),
18 ),
19 ),
20 ),
21 ...
22}
Это лишь один из возможных примеров реализации скоупов. Главная его суть — чёткая и ясная структура приложения. Важно сделать не только так, чтобы всё работало, но и так, чтобы любой разработчик в команде мог сразу понять, как устроено всё приложение, откуда берутся те или иные зависимости и т. п.
Технические детали спрятаны под капот. Логика и суть выставлены напоказ.
AppScope
AppScope
— скоуп нашего приложения, отвечающий за инициализацию зависимостей, необходимых всему приложению. init
— это функция инициализации зависимостей, которая вернёт нам контейнер с этими зависимостями. Что это за функция и почему она вынесена отдельно, поговорим ниже.
А дальше следуют два билдера: initialization
и initialized
. Это место ветвления нашего приложения. Пока идёт инициализация, будет открыта ветка, в которой будем показывать SplashScreen
. Но как только инициализация завершится, эта ветка будет заменена другой, в которую под капотом будет передан созданный и проинициализированный контейнер с зависимостями.
Суть скоупа в том, чтобы гарантировать, что, пока существует ветка
initialized
, существует в дереве и контейнер с зависимостями.
То есть любой виджет, находящийся ниже по дереву, может быть уверен, что, пока он существует сам, существуют и зависимости. И, в свою очередь, виджет, находящийся где-то по соседству, но не в ветке initialized
, не получит доступ к этому скоупу, чем обеспечивается безопасность данных.
Любой виджет ниже по дереву может получить доступ к данным скоупа через AppScope.of(context)
. Только важно при использовании асинхронного кода не забывать проверять с помощью mounted
, находится ли виджет сам в дереве.
Закрытие скоупа, то есть удаление виджета AppScope
из дерева, автоматически приведёт к закрытию всех виджетов, которые могут использовать зависимости. И всё это сделает за нас сам Flutter. Нам не придётся беспокоиться о синхронизации дерева виджетов и контейнера зависимостей, что надо делать всякий раз, когда зависимости находятся отдельно от дерева виджетов.
Последнее всегда было чревато ошибками из-за невнимательности разработчиков или ошибок на стороне пакетов, реализующих контейнеры вне дерева виджетов.
AuthScope
AuthScope
— скоуп, отвечающий за авторизацию пользователя. Сама авторизация должна быть реализована не в нём, а в одной из зависимостей в AppScope
(какой-нибудь контроллер, интерактор, репозиторий или менеджер авторизации), чтобы можно было всегда его замокать или безболезненно заменить новым.
Автоматическая авторизация последнего пользователя должна быть осуществлена там же вместе с инициализацией зависимостей. Виджет только предоставляет ветвление приложения, исходя из состояния инициализации:
-
notAuthorized
— пользователь не авторизован, и мы показываемLoginScreen
; -
authorized
— пользователь успешно авторизовался, и мы можем спокойно создавать ветку, передав ей значениеUserData user
.
И снова все виджеты в этой ветке могут быть уверены, что, пока они существуют, существует и авторизованный пользователь. Никогда не может произойти так, что пользователь вышел, а какой-нибудь виджет, которому нужен пользователь, продолжает работать.
И снова за нас почти всё делает Flutter. Исключения касаются только работы асинхронных операций. Но об этом ниже.
UserScope
UserScope
— скоуп, предоставляющий доступ к ресурсам, зависящим от авторизованного пользователя. Например, у нас могут быть два HTTP-клиента: один в AppScope
для загрузки данных, не зависящих от пользователя, и один в UserScope
, в который мы будем передавать токен авторизации.
Такой вариант безопаснее, чем с одним HTTP-клиентом на все случаи жизни. При выходе пользователя скоуп свернётся, все зависимости, работающие с конфиденциальными данными, будут закрыты, HTTP-клиент с токеном авторизации закроется.
Вероятность случайного сбоя, когда пользователь «вышел», а его токен продолжает передаваться в заголовках запроса, исключается. А на время инициализации зависимостей мы можем показать экран заглушки. Например, экран, имитирующий пустой HomeScreen
. Мы назвали его HomeSplashScreen
.
UserScope.of
вернёт данные о пользователе: UserData
.
Во многих случаях разработчикам полезнее видеть логику работы приложения сразу, не заглядывая во внутренности виджетов. И как пример того, о чём мы говорим, строчка key: ValueKey(user)
.
Убрав её, можно получить потерю конфиденциальности пользовательских данных, если вы реализуете не просто login/logout, а непосредственную смену одного пользователя на другого. То есть когда ветка authorized
будет перестроена без захода в ветку notAuthorized
.
Ветка authorized
не будет удалена, так как для Flutter ровным счётом ничего не изменилось и он никак не узнает о необходимости пересоздать все виджеты. И значит, где-то в глубине приложения останутся данные старого пользователя, в то время как UserScope.of
уже будет возвращать нового. Строчка key: ValueKey(user)
не позволит этому случиться, удалив старую ветку перед созданием новой с новым пользователем.
NScope
Разумеется, таких скоупов в приложении может быть множество. На любой экран или даже на часть экрана может быть создан свой собственный скоуп. Но сложность в том, как организовать скоуп на часть чуть большую, чем один экран, то есть включающую в себя несколько экранов, диалоговых окон, всплывающих снизу панелей и прочее, но меньшую, чем всё приложение!
Ведь все новые экраны и диалоговые окна при создании привязываются в дереве не к тому месту, откуда они были вызваны, а к MaterialApp
. Точнее, к Navigator
, который скрывается внутри MaterialApp
.
Соответственно, чтобы любой экран мог получить доступ к скоупу, скоуп должен находиться выше MaterialApp
. Но это сводит на нет все наши усилия, потому что тут, как и в случае с глобальным контейнером, скоупы станут доступны из любого места нашего приложения, чего мы так хотели избежать. Выход есть, но о нём чуть позже.
Инициализация зависимостей
В скоупах AppScope
и UserScope
мы вынесли функции инициализации зависимостей из виджета во внешнюю среду по одной простой, но очень важной причине.
Для того чтобы сделать лёгкой вариативность: и на случай сборки приложения в разных вариантах, и для тестирования и отладки приложения на моковых данных. init
должен нам вернуть ссылку на контейнер с зависимостями (очень важно!) абстрактного класса. Например, такого:
1abstract interface class AppDependencies {
2 SomeRepository get someRepository;
3 SomeApi get someApi;
4 SomeController get someController;
5
6 Future<void> dispose();
7}
Скоуп не должен знать ничего об имплементации этого интерфейса. Чем меньше «грязных» файлов, то есть файлов, знающих об имплементациях зависимостей, тем лучше архитектура нашего приложения. И в идеальном мире должен остаться только один файл, у которого в заголовке есть импорт на файлы с имплементациями зависимостей, или один файл на каждый независимый модуль.
Самый простой пример реализации контейнера и функции инициализации:
Код
1final class AppDependenciesImpl implements AppDependencies {
2 @override
3 final SomeRepositoryImpl someRepository;
4
5 @override
6 final SomeApiImpl someApi;
7
8 @override
9 final SomeControllerImpl someController;
10
11 AppDependenciesImpl._({
12 required this.someRepository,
13 required this.someApi,
14 required this.someController,
15 });
16
17 static Future<AppDependencies> init() async {
18 final someRepository = SomeRepositoryImpl();
19 final someApi = SomeApiImpl();
20
21 await [
22 someRepository.init(),
23 someApi.init(),
24 ].wait;
25
26 final someApiData = await someApi.load();
27
28 final someController = SomeControllerImpl(someRepository, someApiData);
29 await someController.init();
30
31 return AppDependenciesImpl._(
32 someRepository: ...,
33 someApi: ...,
34 someController: ...,
35 );
36 }
37
38 @override
39 Future<void> dispose() async {
40 await [
41 someRepository.dispose(),
42 someApi.dispose(),
43 someController.dispose(),
44 ].wait;
45 }
46}
Можно (и нужно) добавить больше функциональности. Например, может оказаться востребованным прогресс инициализации, если он занимает продолжительное время, чтобы пользователь видел, что происходит или как движется линия прогресс-бара.
Вместо Future<AppDependenciesImpl>
в этом случае удобно возвращать Stream<AppDependenciesState>
по образу того, как это делается обычно в BLoC. Соответственно, AppScope
должен будет уметь работать с этим. Пример такой реализации:
Код
1static Stream<AppDependenciesState> init() async* {
2 SomeRepositoryImpl? someRepository;
3 SomeApiImpl? someApi;
4 SomeControllerImpl? someController;
5
6 try {
7 yield const AppDependenciesInitialization('init someRepository');
8
9 someRepository = SomeRepositoryImpl();
10 await someRepository.init();
11
12 yield const AppDependenciesInitialization('init someApi');
13
14 someApi = SomeApiImpl(someRepository);
15 await someApi.init();
16
17 yield const AppDependenciesInitialization('load someApi data');
18 final someApiData = await someApi.load();
19
20 yield const AppDependenciesInitialization('init someController');
21
22 someController = SomeControllerImpl(someRepository, someApiData);
23 await someController.init();
24
25 yield AppDependenciesInitialized(
26 AppDependenciesImpl._(
27 someRepository: someRepository,
28 someApi: someApi,
29 someController: someController,
30 ),
31 );
32 } on Object {
33 await [
34 someController?.dispose(),
35 someApi?.dispose(),
36 someRepository?.dispose(),
37 ].nonNulls.wait;
38
39 rethrow;
40 }
41}
Следующий шаг, не менее важный, — корректная обработка ошибок, возникших в ходе инициализации. Мы просто вырубим приложение? Или что-то покажем пользователю? Или даже сделаем возможность повторить инициализацию, если это возможно и имеет смысл?
В приведённом выше примере мы просто остановимся. Наверное, это не лучший вариант, но пользователь хотя бы сможет сказать службе поддержки, на каком этапе произошёл сбой. Ошибка же должна быть отловлена где-то выше механизмом отлова необработанных исключений и отправлена в систему сбора и анализа ошибок (Firebase Crashlytics, Sentry и т. п.).
Утилизация зависимостей
Функциональность и дальше можно наращивать. Всё будет зависеть исключительно от вашей фантазии.
Но есть кое-что крайне важное, что не бросается в глаза сразу, но о чём очень часто не просто забывают, а даже не думают. В большинстве случаев для инициализации зависимостей нам нужны асинхронные методы. Это то, что мы постарались учесть в нашем примере выше.
Это понятно, и НЕ учесть это невозможно, поскольку, пока мы не дождёмся окончания инициализации, мы не сможем воспользоваться зависимостями. Другое дело — утилизация зависимостей (освобождение ресурсов). Зачастую она тоже должна быть асинхронной:
1Future<void> dispose() async
Но в этом случае очень легко забыть дождаться завершения. Легко в стейте StatefulWidget
, в методе dispose
, вызвать наш dispose
. Стандартные настройки линтера не подскажут вам о потенциальной проблеме в этом месте, если только вы сознательно не включили правило discarded_futures
.
Метод dispose
виджета не асинхронный: Flutter не будет и не имеет возможности ждать завершения асинхронных методов. А это значит, что при перестроении ветки какого-нибудь скоупа, например UserScope
при смене пользователя, может возникнуть ситуация, когда прошлый контейнер с зависимостями ещё не освободил ресурсы, а новый уже начал процесс инициализации.
Хорошо, если все ваши зависимости «чистые», то есть не зависящие от внешней среды, и поэтому их создание никак не повлияет на другие. Но часто ли так бывает?
Если ваш скоуп создаёт контроллер какого-нибудь присутствующего в одном экземпляре устройства на смартфоне, например камеры, вы не сможете безопасно создать второй контроллер, пока работает первый. И пусть плагины Dart и Flutter дают возможность создавать нам несколько инстансов такого контроллера, платформа просто не позволит им работать вместе. У таких девайсов одновременно может быть только один хозяин.
Что тогда будет, если мы попытаемся создать новый контроллер при ещё незакрытом старом? Хорошо, если это вызовет ошибку инициализации. Такой вариант мы быстро обнаружим. А ведь может пойти всё так, что повторная инициализация станет вполне успешной, но следом за ней «вдруг откуда-то и почему-то» будет вызван close
.
Вот простор для веры в сверхъестественное: успешно проинициализированная зависимость вдруг сама собой закрылась! Такую ошибку не то что обнаружить, её понять будет гораздо сложнее. Что ещё может произойти? Да всё что угодно: undefined behavior. Особенно если ваши зависимости используют глобальные или статические переменные, так как две сущности одновременно будут их менять.
В случае с UserScope
такое поведение будет встречаться крайне редко. Всё-таки смена пользователя не столь частое событие. Но если это скоуп какого-то экрана, с которого легко выйти и тут же заново зайти, вероятность резко увеличивается. Поэтому при написании своего собственного скоупа и контейнера зависимостей вам нужно будет подумать над решением этой проблемы.
Трудность здесь в том, что вы не можете её решить внутри удаляемого виджета и/или контейнера с зависимостями. Всё, что вы могли сделать на этом уровне, вы уже сделали: дали тип возвращаемому значению Future
. То есть явно сказали: «Меня надо дождаться». Поэтому нужен тот, кто дождётся.
Это может сделать новый виджет и/или новый контейнер, отложив инициализацию до завершения предыдущей. Но ему для этого нужно как-то узнать, что предыдущие инстансы ещё работают, и как-то получить доступ к Future
, возвращённый из dispose
. Это возможно реализовать через статические переменные внутри инстансов. Либо же отказаться от простоты реализации скоупов на Флаттере и придумать что-то своё, от него не зависящее, но в итоге более сложное.
Вариант реализации задержки инициализации через статическую переменную:
Код
1final class AppDependenciesImpl implements AppDependencies {
2 static Completer<void>? _locker;
3
4 static Future<AppDependencies> init() async {
5 // Дожидаемся завершения работы предыдущего контейнера.
6 // Через while, чтобы обезопасить себя на случай инициализации
7 // нескольких контейнеров одновременно: контейнеры "встанут"
8 // в очередь, каждый по очереди будет выходить и устанавливать
9 // блокировку.
10 while(_locker != null) {
11 await _locker!.future;
12 }
13 _locker = Completer();
14
15 try {
16 ... инициализация зависимостей
17 } on Object catch (error, stackTrace) {
18 ... обработка ошибки
19
20 // Освобождаем блокировку в случае ошибки.
21 _locker!.complete();
22 _locker = null;
23 }
24 }
25
26 @override
27 Future<void> dispose() async {
28 try {
29 ... утилизация зависимостей
30 } finally {
31 // Освобождаем блокировку после утилизации зависимостей.
32 _locker!.complete();
33 _locker = null;
34 }
35 }
36}
Теперь новая инициализация не запустится, пока предыдущий контейнер не завершит свою работу. Возможно, ваш виджет-скоуп должен как-то красиво отреагировать на эту ситуацию, оповестив пользователя об ожидании.
У этого варианта есть один недостаток, который встречается редко, но всё же встречается: что будет, если dispose
предыдущего контейнера зависнет? Может так получиться, что, добавив у себя ожидание dispose
, вы обнаружите, что в вашем случае зависание происходит регулярно, но только раньше вы об этом не знали, потому что никогда не ждали завершения.
Что вы будете делать? Заставите человека смотреть на бесконечный сплэшскрин? Или поставите таймаут на ожидание, сбросите _locker
и продолжите инициализацию?
Если установите таймаут, то что будет, когда во время инициализации или после неё прошлый dispose
проснётся? Мы никогда не должны забывать, что асинхронный код нельзя прервать извне. Код, зависший на await
, никуда не денется. Он будет ждать. И, быть может, дождётся.
И как поступить?
Это одна из ситуаций, из которых нет правильного выхода. Но в ней нет ничего страшного, если вы подумали о ней, продумали последствия и позаботились о пользователе.
Но как минимум технически перед сбросом _locker
вам нужно убедиться, что он всё ещё ваш, а не чей-то. Допустим, дополнительно сохраняя ссылку на свой локер внутри инстанса класса и позже сравнивая обе ссылки через identical
.
А что делать, если dispose
не был вызван вообще? Это маловероятно, если вы привязали свой скоуп к жизненному циклу дерева виджетов, но вполне вероятно, если вы привязали его к какому-нибудь стороннему DI, под капот которого вы не заглядывали.
Ясно, что прежде всего нужно искать и исправлять проблему. Но если ваше приложение разрабатывает большое количество команд, то вы не можете позволить себе испортить рейтинг приложения из-за ошибки одной из команд.
Надо отправить метрики о возникшей ошибке, но тут же постараться сделать всё, чтобы приложение продолжило свою работу без перезапуска. В этом случае предстоит, во-первых, убедиться, что dispose
действительно не был вызван, а не завис после вызова, и, во-вторых, вызвать dispose
старого инстанса самостоятельно. А для этого нужно как минимум где-то сохранить ссылку на предыдущий инстанс контейнера.
Да, всё это значительно усложнит разработку контейнера зависимостей, но для качественной работы приложения у вас просто нет других вариантов. И если вы не сделаете это сразу, то будете делать потом, и, скорее всего, срочно и сверхурочно.
В целом же становится понятно, что это должно быть какое-то единое решение, какая-то своя библиотечка или какой-то свой базовый класс для всех контейнеров. Нельзя такие вещи каждый раз писать в каждом контейнере. Реализацию такой библиотеки оставим за пределами этого параграфа: у вас уже достаточно навыков для этого.
Проблема ожидания асинхронной утилизации зависимостей касается не только «ванильного» IoC. Посмотрите на пакеты, которые вы используете сейчас.
Громкое имя пакета не гарантирует, что его автор задумался об этой проблеме и предложил решение.
Навигатор
Последний пункт, без решения которого всё, о чём мы говорили выше, потеряет смысл.
Посмотрим на пример использования скоупа для некоторого модуля внутри приложения. Вот получившееся дерево виджетов (это очень упрощённый пример, чтобы не отвлекать от главного):

Видно, что скоуп MyModuleScope
находится выше экрана MyModuleHomeScreen
. Следовательно, экран имеет доступ к данным, которые скоуп предоставляет. Но модуль — это, как правило, не один экран. MyModuleHomeScreen
может открыть дочерний экран MyModuleChildScreen
(Navigator.push
).
Посмотрим, что из этого получится:

Новый экран не связан с веткой MyModuleScope
, а берёт своё начало от MaterialApp
, точнее, от Navigator
, который в нём «спрятан». Следовательно, он не будет иметь прямого доступа к данным MyModuleScope
.
Конечно, мы можем при создании нового окна передать ему контекст (BuildContext
) ветки MyModuleScope
. Мы получим доступ к нужным данным, но теперь каждый виджет вынужден будет иметь два контекста: свой собственный и контекст MyModuleScope
.
В этом очень легко запутаться, особенно в асинхронных методах, где постоянно нужно контролировать, смонтирован ли (mounted
) наш context
.
Та же история с диалоговыми окнами. Они так же будут открываться из MaterialApp
:

В случае с диалогом под капотом реализуется как раз изложенный выше механизм двух контекстов: для некоторых данных из темы он использует родительский контекст. Не для всех. Только для некоторых.
Возникает ощущение, что в этом месте есть некая недоработка со стороны команды Flutter. И чтобы это не бросалось в глаза, выполнены частичные решения, закрывающие дыры в архитектуре.
Как было бы хорошо, если бы новый экран мог открываться из самой ветки MyModuleScope
! Неужели так легко будет загублена идея со скоупами средствами Flutter? На самом деле выход есть. Хотя и он не без недостатков. Как будто что-то очень хорошее делалось, но не было доведено до ума. Начнём по порядку.
У нас есть виджет, с помощью которого мы можем создать окно в нужной нам ветке. Это тот самый Navigator
. Нам необязательно довольствоваться навигатором, встроенным в MaterialApp
, мы смело можем вставить в дерево свой. Только у навигатора нет child
.
Ему мы должны передать список страниц pages
: <Page>[]
. Но ничто нам не мешает передать список, состоящий из одного MyModuleHomeScreen
, предварительно обернув его в MaterialPage
или во что-то аналогичное:
Код
1class MyModuleScopeState extends State<MyModuleScope> {
2 ...
3
4 @override
5 Widget build(BuildContext context) => _MyModuleScopeInheritedWidget(
6 ...
7 child: Navigator(
8 pages: const [
9 MaterialPage(
10 child: MyModuleHomeScreen(),
11 ),
12 ],
13 ),
14 );
15}
Вот теперь Navigator.push
будет создавать дочерние экраны там, где нам нужно. И все они будут иметь доступ к данным скоупа. А при закрытии скоупа будут красиво закрываться. Ровно то, что мы и хотели:

С диалогами чуть сложнее. Navigator.push
и Navigator.of
ищут первый попавшийся в дереве навигатор, когда мы поднимаемся от точки нашего контекста вверх, хотя в Navigator.of
с помощью rootNavigator: true
мы можем использовать корневой навигатор, минуя все промежуточные. В диалогах же корневой навигатор используется по умолчанию.
Поэтому, если нам нужно привязать диалог к ветке скоупа, придётся вручную указать useRootNavigator: false
:
1showDialog(
2 context: context,
3 useRootNavigator: false,
4 builder: (context) => ...,
5);
Получим то, что нам надо:

Всё ли гладко в таком подходе? Не совсем. Как раз то, о чём я сказал выше. Как будто этот механизм не доведён до ума. Дело в том, что Navigator
считает себя единственным и неповторимым.
Если для последней оставшейся в нём страницы — главной страницы модуля — вы вызовете Navigator.pop
, то получите совсем не то, что надо: навигатор удалит из стека страницу, но не удалит себя, так как «думает», что он остался один. Он ничего не знает (и не хочет знать!) о том, что за ним ещё может стоять целый мир, в чём он мог бы легко убедиться, если б команда Flutter об этом подумала.
Чтобы удалить последнюю страницу, нам нужно найти ближайший навигатор выше навигатора скоупа. И это необязательно будет корневой навигатор, доступ к которому легко получить с помощью Navigator.of(context, rootNavigator: true)
.
Между ними могут оказаться навигаторы других скоупов. Метода поиска предыдущего навигатора во фреймворке не существует. Его придётся писать самим:
Код
1extension PreviousNavigatorExtension on NavigatorState {
2 NavigatorState? get previous {
3 NavigatorState? prevNavigator;
4
5 if (context.mounted) {
6 context.visitAncestorElements(
7 (element) {
8 prevNavigator = Navigator.maybeOf(element);
9 return false;
10 },
11 );
12 }
13
14 return prevNavigator;
15 }
16}
Теперь удаляем весь наш скоуп так:
1Navigator.of(context).previous?.maybePop();
Это не все проблемы. Находясь на основном экране модуля, то есть с точки зрения навигатора последней страницы в стеке, AppBar
не покажет стрелку назад, Navigator.canPop(context)
вернёт false
, а аппаратная кнопка «Назад» закроет приложение.
Причина всё та же: навигатор уверен, что он единственный в дереве. И в этом случае логично, что с основного экрана приложения можно вернуться только обратно в ОС.
Я не буду вдаваться сейчас в подробности, но нам придётся немного переписать Navigator:
Код
1final class _NodeNavigator extends Navigator {
2 final _NavigationNodeState node;
3
4 const _NodeNavigator({
5 required this.node,
6 super.pages,
7 super.onPopPage,
8 });
9
10 @override
11 NavigatorState createState() => _NodeNavigatorState();
12}
13
14final class _NodeNavigatorState extends NavigatorState {
15 @override
16 _NodeNavigator get widget => super.widget as _NodeNavigator;
17
18 @override
19 void initState() {
20 super.initState();
21 widget.node._navigatorState = this;
22 }
23
24 @override
25 bool canPop() => super.canPop() || (previous?.canPop() ?? false);
26
27 @override
28 Future<bool> maybePop<T extends Object?>([T? result]) async =>
29 await super.maybePop(result) || (await previous?.maybePop(result) ?? false);
30
31 /// На случай, если нужен будет старый вариант.
32 Future<bool> maybePopInsideRoute<T extends Object?>([T? result]) =>
33 super.maybePop(result);
34}
Теперь наш новый навигатор знает о других навигаторах выше его: правильно работают AppBar
, canPop и аппаратная кнопка «Назад».
Но раз мы дошли до исправлений в самом Navigator
, что нам стоит написать немного сахара для его использования:
Код
1/// Navigation node.
2final class NavigationNode extends StatefulWidget {
3 const NavigationNode({
4 required this.child,
5 super.key,
6 });
7
8 final Widget child;
9
10 @override
11 State<NavigationNode> createState() => _NavigationNodeState();
12}
13
14final class _NavigationNodeState extends State<NavigationNode> {
15 late final _NodeNavigatorState _navigatorState;
16
17 @override
18 Widget build(BuildContext context) => PopScope(
19 canPop: false,
20 onPopInvoked: (didPop) async {
21 if (!didPop) {
22 final handled = await _navigatorState.maybePopInsideRoute();
23 if (!handled) {
24 Navigator.pop(context);
25 }
26 }
27 },
28 child: _NodeNavigator(
29 node: this,
30 pages: [
31 MaterialPage<void>(child: widget.child),
32 ],
33 // ignore: avoid_types_on_closure_parameters
34 onPopPage: (route, Object? result) {
35 route.didPop(result);
36 _navigatorState.previous?.pop(result);
37 return true;
38 },
39 ),
40 );
41}
Теперь вы просто вставляете узел навигации NavigationNode
в ваше дерево, и все новые окна будут создаваться от внутреннего навигатора и иметь доступ ко всему, что вы передаёте по дереву, расположив провайдеры, блоки, инхеритед-виджеты и прочее выше NavigationNode
.
Таким образом, скоупы становятся завершёнными.
Примечание
Примечание
Код выше используется в реальном коммерческом приложении.
Итак, мы получили рабочий вариант IoC на чистом Flutter и Dart (исключая некоторые вынужденные правки, связанные с навигатором). Его особенность: независимость от сторонних пакетов и полный контроль над происходящим. Да, такое решение требует хорошего понимания работы Flutter. Но IoC и не является задачей начинающего разработчика. Это ответственность того, кто отвечает за архитектуру приложения. Того, кто может позволить себе не пользоваться хайповыми решениями, а задействовать весь уже имеющийся во Flutter арсенал возможностей или брать пусть и готовое решение с pub.dev, но соответствующее вашему уровню представлений о том, как должен работать IoC на Flutter.
А в следующем параграфе разберём, как реализовать всё то, что мы сделали выше, с помощью библиотеки provider
.