В предыдущих параграфах мы уже рассматривали понятие инверсии зависимостей и изучали реализацию этого паттерна с помощью 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. Он позволяет:
- подписаться на изменения стейта;
- получать отдельные сущности из дерева.
Для начала создадим модели данных.
Реализуем модель задачи со следующей структурой:
Сущность |
Название |
Тип данных |
Заголовок задачи |
|
строка |
Уникальный идентификатор |
|
строка |
Статус |
|
булевая переменная |
Также добавим метод 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
.