5.6. IoC Provider и его использование во Flutter

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

В этом параграфе мы разберём работу с пакетом provider, рассмотрим его основные сущности и поработаем с ним на практике.

Знакомство с пакетом provider

В первую очередь важно запомнить, что пакет provider — это надстройка над InheritedWidget. Для начала разберёмся, зачем нам могут потребоваться такие надстройки.

Вспомним пример из предыдущего параграфа
1/// InheritedWidget, позволяющий передать вниз по дереву информацию о контроллере.
2class _MainWidgetInheritedWidget extends InheritedWidget {
3  final _MainWidgetState state;
4
5  _MainWidgetInheritedWidget({
6    required this.state,
7    required super.child,
8  });
9
10  @override
11  bool updateShouldNotify(_MainWidgetInheritedWidget oldWidget) => true;
12}
13
14/// MainWidget и MainWidgetState являются обёрткой, позволяющей хранить состояние counter.
15class MainWidget extends StatefulWidget {
16  ...
17}
18
19class MainWidgetState extends State<MainWidget> {
20  int get counter => _counter;
21  var _counter = 0;
22
23  void incrementCounter(){
24	setState(() {
25		_counter++;
26	})
27  }
28
29
30@override
31  Widget build(BuildContext context) => _MainWidgetInheritedWidget(
32        state: this,
33        child: const Center(
34          child: CounterView(),
35        ),
36      );
37}
38
39class CounterView extends StatelessWidget {
40  const CounterView({super.key});
41
42  @override
43  Widget build(BuildContext context) {
44    final mainState = context
45        .dependOnInheritedWidgetOfExactType<_MainWidgetInheritedWidget>()!
46        .state;
47
48    return Text('${mainState.counter}');
49  }
50}

А теперь посмотрим, как мы можем реализовать этот же пример с использованием пакета provider.

Пример с использованием provider
1// Создаём ChangeNotifier для управления состоянием счётчика.
2class MainModel with ChangeNotifier {
3  int _counter = 0;
4
5  int get counter => _counter;
6
7  void increment() {
8    _counter++;
9    notifyListeners();
10  }
11}
12
13/// Передаём MainModel вниз по дереву с помощью ChangeNotifierProvider.
14class MainWidget extends StatelessWidget {
15  
16  @override
17  Widget build(BuildContext context) {
18    return ChangeNotifierProvider(
19      create: (context) => MainModel(),
20      child: const Center(
21        child: CounterView(),
22      ),
23    );
24  }
25}
26
27class CounterView extends StatelessWidget {
28  const CounterView({super.key});
29
30  @override
31  Widget build(BuildContext context) {
32    // Используем Consumer для получения состояния и автоматического обновления виджета.
33    return Consumer<MainModel>(
34      builder: (context, model, child) => Text('${model.counter}'),
35    );
36  }
37}

Таким образом, с помощью пакета provider мы вынесли всю логику работы с счётчиком в виджет MainModel, встроили нашу модель в дерево благодаря ChangeNotifierProvider и организовали прослушивание нашей модели вместе с Consumer. Далее рассмотрим другие типы провайдеров и способы получения значений из них.

Основные концепции

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

Компонент упрощает управление жизненным циклом таких объектов: автоматически инициализирует их необходимыми данными и выполняет очистку после удаления из дерева виджетов. Пакет provider поставляется с набором классов для гибкой работы с различными типами данных:

Рассмотрим эти классы подробнее.

Provider

Это базовый класс пакета provider, который принимает объект и предоставляет потомкам доступ к нему. Если вы хотите воспользоваться одним из провайдеров, то в первую очередь обратите внимание на класс Provider. В примере ниже мы передаём объект конфигурации AppConfig вглубь по структуре, чтобы отобразить appName и apiEndpoint в виджете AppInfo.

ListenableProvider

Этот тип провайдера подходит для работы с Listenable. ListenableProvider прослушивает объект и автоматически уведомляет подписанные на провайдер виджеты о необходимости перестроиться.

ChangeNotifierProvider

Это модификация ListenableProvider для работы с ChangeNotifier. Он даёт доступ к методу notifyListeners и автоматически вызовет метод ChangeNotifier.dispose.

StreamProvider

Прослушивает Stream и предоставляет доступ к последнему добавленному значению.

FutureProvider

Прослушивает Future и обновляет потомков, когда Future выполнится.

MultiProvider

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

1Provider<Something>(
2  create: (context) => Something(),
3  child: Provider<SomethingElse>(
4    create: (context) => SomethingElse(),
5    child: Provider<AnotherThing>(
6      create: (context) => AnotherThing(),
7      child: someWidget,
8    ),
9  ),
10),

Такой код называют «адом вложенности» англ. nesting hell за огромное количество вложенных виджетов. Избежать этой проблемы при работе с provider можно с помощью MultiProvider:

1MultiProvider(
2  providers: [
3    Provider<Something>(create: (context) => Something()),
4    Provider<SomethingElse>(create: (context) => SomethingElse()),
5    Provider<AnotherThing>(create: (context) => AnotherThing()),
6  ],
7  child: someWidget,
8)

Однако MultiProvider меняет только внешний вид кода — структура виджетов будет одинакова в обоих случаях.

Конструкторы

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

Для создания нового провайдера используйте базовый конструктор, передав объект в функцию create():

1Provider(
2  create: (context) => MyModel(),
3  child: ...
4)

В этом случае provider будет управлять жизненным циклом MyModel.

Если требуется только встроить объект в дерево виджетов без управления жизненным циклом (например, при передаче провайдера, созданного на предыдущей странице базовым конструктором), воспользуйтесь именованным конструктором Provider.value:

1MyModel model;
2
3Provider.value(
4  value: model,
5  child: ...
6)

Получение значения

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

  • Статический метод Provider.of<T>(BuildContext.
  • Расширения контекста:
    • context.read<T> — одноразовое получение данных типа T (эквивалентно Provider.of<T>(context, listen: false));
    • context.watch<T> — подписка на изменения T с перестроением виджета при обновлении значения (эквивалентно Provider.of<T>(BuildContext));
    • context.select<T, R>(R cb(T value)) — выбор конкретного параметра из T с последующей подпиской на его изменения.

Эти методы возвращают значение ближайшего провайдера типа T, расположенного выше в структуре виджетов. Если нужный провайдер не найден, возникает ошибка. Операции, как и в случае с InheritedWidget, выполняются за O(1), так как не требуют обхода дерева виджетов.

Вы также можете получить значение провайдера, используя виджеты Consumer и Selector. Их функционал аналогичен context.watch<T> и context.select<T, R>(R cb(T value)) соответственно. Подробнее работу с этими виджетами мы рассмотрим в этом параграфе позже.

Пример

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

Рассмотрим пример приложения — список задач. Наш прототип будет включать модели состояния на основе ChangeNotifier, инициализацию провайдеров через MultiProvider, использование Consumer и Selector для оптимизации перестроек, а также тесты для проверки взаимодействия с интерфейсом.

Управление состоянием

Важно понимать, что провайдер не занимается непосредственным управлением состоянием. Эта ответственность возложена на другие компоненты системы — в нашем примере используется ChangeNotifier. Аналогичные функции могут выполнять ValueListenable или BLoC.

Сам провайдер выступает инструментом для связывания этих зависимостей между собой и с UI. Он позволяет:

  • подписаться на изменения стейта;
  • получать отдельные сущности из дерева.

Для начала создадим модели данных.

Реализуем модель задачи со следующей структурой:

Сущность

Название

Тип данных

Заголовок задачи

title

строка

Уникальный идентификатор

id

строка

Статус

completed

булевая переменная

Также добавим метод copyWithCompleted для упрощения изменения статуса модели Todo.

1class Todo {
2  final String id;
3  final String title;
4  final bool completed;
5
6  Todo({required this.id, required this.title, this.completed = false});
7
8  Todo copyWithCompleted({required bool completed}) => Todo(id: this.id, title: this.title, completed: completed);
9}

Реализуем модель для управления состоянием списка задач, наследуя ChangeNotifier.

ChangeNotifier — это класс, предоставляющий механизм уведомления слушателей об изменениях состояния. Наследование ChangeNotifier даёт доступ к методу notifyListeners(). Этот метод служит связующим звеном между источником данных (классом ChangeNotifier) и его потребителями (слушателями), обеспечивая синхронизацию состояний и актуальность отображаемой информации.

1import 'package:flutter/foundation.dart';
2
3class TodoListModel extends ChangeNotifier {
4  List<Todo> _todos = [];
5
6  List<Todo> get todos => _todos;
7
8  void addTodo(Todo todo) {
9    _todos.add(todo);
10    notifyListeners();
11  }
12
13  void removeTodo(String id) {
14    _todos.removeWhere((todo) => todo.id == id);
15    notifyListeners();
16  }
17}

Модель TodoListModel реализует ChangeNotifier, что позволяет автоматически обновлять интерфейс при изменении состояния списка задач.

Внедрение зависимостей с provider

Вспомним, что provider — это надстройка над InheritedWidget, упрощающая передачу данных вниз по структуре виджетов. Благодаря механизму передачи данных вниз provider становится эффективным инструментом для внедрения зависимостей в UI вашего приложения. Реализация работает по следующему принципу: в дерево помещается виджет типа provider, в поле create которого передаётся метод для создания модели данных, требуемой на более низких уровнях структуры.

Для того чтобы использовать TodoListModel в нашем приложении, необходимо инициализировать ChangeNotifierProvider в функции main(). Это позволит предоставлять доступ к модели состояния из любой части приложения.

Внутри функции main() создаём экземпляр ChangeNotifierProvider и передаём ему экземпляр TodoListModel в поле create:

1void main() {
2  runApp(
3    ChangeNotifierProvider(
4      create: (context) => TodoListModel(),
5      child: MyApp(),
6    ),
7  );
8}

Реализация виджета MyApp может выглядеть следующим образом:

1class MyApp extends StatelessWidget {
2  @override
3  Widget build(BuildContext context) {
4    return MaterialApp(
5      title: 'Todo List App',
6      home: TodoListScreen(),
7    );
8  }
9}

Теперь, когда у нас есть инициализированный ChangeNotifierProvider в main.dart, мы можем использовать его для получения доступа к TodoListModel в наших виджетах. Это позволит нам отображать список задач и управлять им.

Создадим виджет TodoListScreen, который будет отображать список задач. Этот виджет будет использовать Provider.of<T> для получения доступа к модели TodoListModel.

Реализация TodoListScreen
1import 'package:flutter/material.dart';
2import 'package:provider/provider.dart';
3
4class TodoListScreen extends StatelessWidget {
5  @override
6  Widget build(BuildContext context) {
7    final todoListModel = Provider.of<TodoListModel>(context);
8
9    return Scaffold(
10      appBar: AppBar(
11        title: Text('Todo List'),
12      ),
13      body: ListView.builder(
14        itemCount: todoListModel.todos.length,
15        itemBuilder: (context, index) {
16          final todo = todoListModel.todos[index];
17          return ListTile(
18            title: Text(todo.title),
19            subtitle: Text('Completed: ${todo.completed}'),
20          );
21        },
22      ),
23      floatingActionButton: FloatingActionButton(
24        onPressed: () {
25          todoListModel.addTodo(Todo(id: DateTime.now().toString(), title: 'New Todo'));
26        },
27        child: Icon(Icons.add),
28      ),
29    );
30  }
31}

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

В модели TodoListModel у нас уже есть методы для добавления и удаления задач. Давайте добавим метод для обновления статуса выполнения задачи.

1class TodoListModel extends ChangeNotifier {
2  // Написанный ранее код.
3
4  void updateCompleted({required String id, required bool completed}) {
5    final index = _todos.indexWhere((t) => t.id == id);
6    if (index != -1) {
7      _todos[index] = _todos[index].copyWithCompleted(completed: completed);
8      notifyListeners();
9    }
10  }
11}

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

Обновлённая реализация TodoListScreen
1class TodoListScreen extends StatelessWidget {
2  @override
3  Widget build(BuildContext context) {
4    final todoListModel = Provider.of<TodoListModel>(context);
5
6    return Scaffold(
7      appBar: AppBar(
8        title: Text('Todo List'),
9      ),
10      body: ListView.builder(
11        itemCount: todoListModel.todos.length,
12        itemBuilder: (context, index) {
13          final todo = todoListModel.todos[index];
14          return ListTile(
15            title: Text(todo.title),
16            subtitle: Text('Completed: ${todo.completed}'),
17            trailing: Checkbox(
18              value: todo.completed,
19              onChanged: (bool? newValue) {
20                todoListModel.updateCompleted(
21                  id: todo.id,
22                  completed: newValue ?? false,
23                );
24              },
25            ),
26          );
27        },
28      ),
29      floatingActionButton: FloatingActionButton(
30        onPressed: () {
31          todoListModel.addTodo(Todo(id: DateTime.now().toString(), title: 'New Todo'));
32        },
33        child: Icon(Icons.add),
34      ),
35    );
36  }
37}

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

Использование MultiProvider

Теперь мы добавим ещё одну модель состояния, например UserModel, которая будет управлять данными о текущем пользователе. Для этого воспользуемся MultiProvider, чтобы инициализировать обе модели состояния в main.dart.

Создадим класс UserModel, который будет управлять данными о пользователе и наследоваться от ChangeNotifier.

1class UserModel extends ChangeNotifier {
2  String _username = '';
3
4  String get username => _username;
5
6  void setUsername(String username) {
7    _username = username;
8    notifyListeners();
9  }
10}

В функции main используем MultiProvider для инициализации TodoListModel и UserModel.

1void main() {
2  runApp(
3    MultiProvider(
4      providers: [
5        ChangeNotifierProvider(create: (context) => TodoListModel()),
6        ChangeNotifierProvider(create: (context) => UserModel()),
7      ],
8      child: MyApp(),
9    ),
10  );
11}

Теперь мы можем использовать UserModel в наших виджетах для получения данных о пользователе.

1class ProfileScreen extends StatelessWidget {
2  @override
3  Widget build(BuildContext context) {
4    final userModel = Provider.of<UserModel>(context);
5
6    return Scaffold(
7      appBar: AppBar(
8        title: Text('Profile'),
9      ),
10      body: Center(
11        child: Column(
12          mainAxisAlignment: MainAxisAlignment.center,
13          children: [
14            Text('Username: ${userModel.username}'),
15            ElevatedButton(
16              onPressed: () {
17                userModel.setUsername('NewUsername');
18              },
19              child: Text('Update Username'),
20            ),
21          ],
22        ),
23      ),
24    );
25  }
26}

Теперь у нас есть возможность управлять данными о пользователе отдельно от списка задач. На следующих этапах мы рассмотрим использование Consumer и Selector для оптимизации перестроек виджетов.

Использование Consumer и Selector

Для оптимизации перестроек виджетов и улучшения производительности приложения можно использовать Consumer и Selector из пакета provider.

Consumer позволяет получить значение от Provider, когда у нас нет контекста, который является потомком указанного Provider, и поэтому мы не можем использовать Provider.of<T>. Такая проблема обычно возникает, когда создающий Provider виджет одновременно один из его потребителей.

Пример:

1@override
2Widget build(BuildContext context) {
3  return ChangeNotifierProvider(
4    create: (context) => Foo(),
5    child: Text(Provider.of<Foo>(context).value),
6  );
7}

В этом примере возникнет исключение ProviderNotFoundException, потому что Provider.of<T> вызывается с BuildContext, который является предком Provider. Вместо этого мы можем использовать виджет Consumer, который будет вызывать Provider.of<T> со своим собственным BuildContext.

Используем Consumer. Тогда предыдущий пример будет выглядеть следующим образом:

1@override
2Widget build(BuildContext context) {
3  return ChangeNotifierProvider(
4    create: (context) => Foo(),
5    child: Consumer<Foo>(
6      builder: (context, foo, child) => Text(foo.value),
7    },
8  );
9}

Другая проблема, которую решает Consumer, — иногда может перестраиваться больше виджетов, чем необходимо:

1@override
2 Widget build(BuildContext context) {
3   return FooWidget(
4     child: BarWidget(
5       bar: Provider.of<Bar>(context),
6     ),
7   );
8 }

В приведённом выше коде только BarWidget зависит от значения, возвращаемого Provider.of<T>. Но когда Bar изменяется, то и BarWidget, и FooWidget будут перестроены.

В идеале следует перестраивать только BarWidget . Один из способов добиться этого — использовать Consumer:

1 @override
2 Widget build(BuildContext context) {
3   return FooWidget(
4     child: Consumer<Bar>(
5       builder: (context, bar, child) => BarWidget(bar: bar),
6     ),
7   );
8 }

В этой ситуации, если бы Bar нужно было обновить, только BarWidget перестроили бы заново.

Мы также можем решить и обратный пример:

1 @override
2 Widget build(BuildContext context) {
3   return Consumer<Foo>(
4     builder: (context, foo, child) => FooWidget(foo: foo, child: child),
5     child: BarWidget(),
6   );
7 }

В этом примере BarWidget создается вне builder. Затем экземпляр BarWidget передается builder в качестве последнего параметра.

Это означает, что при повторном вызове builder с новыми значениями новый экземпляр BarWidget создаваться не будет. Это позволяет Flutter знать, что ему не нужно перестраивать BarWidget. Следовательно, в такой конфигурации только FooWidget будет перестраиваться при изменении Foo.

Selector же дополнительно позволяет выбирать поля объекта, на изменения которых он будет реагировать:

1Selector<Foo, Bar>(
2  selector: (context, foo) => foo.bar,
3  builder: (context, data, child) {
4    return Text('${data.item}');
5  }
6)

Пример использования Consumer в виджете TodoListScreen:

1class TodoListScreen extends StatelessWidget {
2  @override
3  Widget build(BuildContext context) {
4    return Consumer<TodoListModel>(
5      builder: (context, todoListModel, child) {
6        return Scaffold(
7          appBar: AppBar(
8            title: Text('Todo List'),
9          ),
10          body: ListView.builder(
11            itemCount: todoListModel.todos.length,
12            itemBuilder: (context, index) {
13              final todo = todoListModel.todos[index];
14              return ListTile(
15                title: Text(todo.title),
16                subtitle: Text('Completed: ${todo.completed}'),
17                trailing: Checkbox(
18                  value: todo.completed,
19                  onChanged: (bool? newValue) {
20                    todoListModel.updateCompleted(
21                   id: todo.id,
22                   completed: newValue ?? false,
23                 );
24                  },
25                ),
26              );
27            },
28          ),
29          floatingActionButton: FloatingActionButton(
30            onPressed: () {
31              todoListModel.addTodo(Todo(id: DateTime.now().toString(), title: 'New Todo'));
32            },
33            child: Icon(Icons.add),
34          ),
35        );
36      },
37    );
38  }
39}

Тестирование с provider

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

Для начала требуется настроить тестовый виджет:

1   await tester.pumpWidget(
2     ChangeNotifierProvider(
3       create: (context) => TodoListModel(),
4       child: MyApp(),
5     ),
6   );

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

1void main() {
2  testWidgets('TodoListScreen adds todo', (WidgetTester tester) async {
3    await tester.pumpWidget(
4      ChangeNotifierProvider(
5        create: (context) => TodoListModel(),
6        child: MyApp(),
7      ),
8    );
9
10    // Проверяем, что заголовок приложения отображается.
11    expect(find.text('Todo List'), findsOneWidget);
12
13    // Проверяем, что новой задачи ещё нет в списке.
14    expect(find.text('New Todo'), findsNothing);
15
16    // Нажимаем на кнопку добавления задачи.
17    await tester.tap(find.byIcon(Icons.add));
18    await tester.pump(); // Перестраиваем дерево виджетов.
19
20    // Проверяем, что новая задача появилась в списке.
21    expect(find.text('New Todo'), findsOneWidget);
22  });
23}

В этом параграфе мы изучили пакет provider и выяснили, какие преимущества он даёт по сравнению с использованием InheritedWidget. Рассмотрели различные типы провайдеров и сценарии их корректного применения. Также разобрали, как:

  • избежать «ада вложенности» с помощью MultiProvider;
  • оптимизировать перестроения UI через Consumer и Selector;
  • организовать тестирование приложений, использующих пакет provider.
Чтобы добавить в заметки выделенный текст, нажмите Ctrl + E

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

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

Здесь можно найти единомышленников, экспертов и просто интересных собеседников. А ещё — получить помощь или поделиться знаниями.
Вступить
Сообщить об ошибке
Предыдущий параграф5.5. Redux
Следующий параграф5.7. Navigation 2.0