5.7. IoC: библиотека Provider

Автор: Артём Ершов

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

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

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

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

Вспомним пример из предыдущего статьи
/// InheritedWidget, позволяющий передать вниз по дереву информацию о контроллере.
class _MainWidgetInheritedWidget extends InheritedWidget {
  final _MainWidgetState state;

  _MainWidgetInheritedWidget({
    required this.state,
    required super.child,
  });

  @override
  bool updateShouldNotify(_MainWidgetInheritedWidget oldWidget) => true;
}

/// MainWidget и MainWidgetState являются обёрткой, позволяющей хранить состояние counter.
class MainWidget extends StatefulWidget {
  ...
}

class MainWidgetState extends State<MainWidget> {
  int get counter => _counter;
  var _counter = 0;

  void incrementCounter(){
	setState(() {
		_counter++;
	})
  }


@override
  Widget build(BuildContext context) => _MainWidgetInheritedWidget(
        state: this,
        child: const Center(
          child: CounterView(),
        ),
      );
}

class CounterView extends StatelessWidget {
  const CounterView({super.key});

  @override
  Widget build(BuildContext context) {
    final mainState = context
        .dependOnInheritedWidgetOfExactType<_MainWidgetInheritedWidget>()!
        .state;

    return Text('${mainState.counter}');
  }
}

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

Пример с использованием provider
// Создаём ChangeNotifier для управления состоянием счётчика.
class MainModel with ChangeNotifier {
  int _counter = 0;

  int get counter => _counter;

  void increment() {
    _counter++;
    notifyListeners();
  }
}

/// Передаём MainModel вниз по дереву с помощью ChangeNotifierProvider.
class MainWidget extends StatelessWidget {
  
  @override
  Widget build(BuildContext context) {
    return ChangeNotifierProvider(
      create: (context) => MainModel(),
      child: const Center(
        child: CounterView(),
      ),
    );
  }
}

class CounterView extends StatelessWidget {
  const CounterView({super.key});

  @override
  Widget build(BuildContext context) {
    // Используем Consumer для получения состояния и автоматического обновления виджета.
    return Consumer<MainModel>(
      builder: (context, model, child) => Text('${model.counter}'),
    );
  }
}

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

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

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

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

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

Provider

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

import 'package:flutter/material.dart';
import 'package:provider/provider.dart';

// Объект конфигурации.
class AppConfig {
  final String apiEndpoint;
  final String appName;

  AppConfig({required this.apiEndpoint, required this.appName});
}

void main() {
  runApp(
    Provider<AppConfig>(
      create: (context) => AppConfig(
        apiEndpoint: 'https://api.example.com',
        appName: 'My Awesome App',
      ),
      child: MyApp(),
    ),
  );
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        appBar: AppBar(title: Text('Provider Example')),
        body: Center(child: AppInfo()),
      ),
    );
  }
}

class AppInfo extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    // Получаем доступ к объекту AppConfig.
    final config = Provider.of<AppConfig>(context);

    return Column(
      mainAxisAlignment: MainAxisAlignment.center,
      children: [
        Text('App Name: ${config.appName}'),
        Text('API Endpoint: ${config.apiEndpoint}'),
      ],
    );
  }
}

Результат выполнения

Результат выполнения примера 1

ListenableProvider

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

import 'package:flutter/material.dart';
import 'package:provider/provider.dart';

// Модель данных, реализующая Listenable.
class Counter extends ValueNotifier<int> {
  Counter() : super(0);

  void increment() {
    value++;
  }
}

void main() {
  runApp(
    // Используем ListenableProvider для управления состоянием.
    ListenableProvider<Counter>(create: (context) => Counter(), child: MyApp()),
  );
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        appBar: AppBar(title: Text('ListenableProvider Example')),
        body: Center(child: CounterDisplay()),
        floatingActionButton: IncrementButton(),
      ),
    );
  }
}

class CounterDisplay extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    // Слушаем изменения в Counter и обновляем UI.
    final counterValue = context.watch<Counter>().value;

    return Text('Count: $counterValue', style: TextStyle(fontSize: 24));
  }
}

class IncrementButton extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return FloatingActionButton(
      onPressed: () {
        // Получаем объект Counter и вызываем increment.
        context.read<Counter>().increment();
      },
      child: Icon(Icons.add),
    );
  }
}

Результат выполнения

Результат выполнения примера 2

ChangeNotifierProvider

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

import 'package:flutter/material.dart';
import 'package:provider/provider.dart';

// Модель данных, расширяющая ChangeNotifier для уведомления слушателей об изменениях.
class Counter with ChangeNotifier {
  int _count = 0;

  int get count => _count;

  void increment() {
    _count++;
    notifyListeners(); // Уведомляет всех слушателей о том, что состояние изменилось.
  }
}

void main() {
  runApp(
    // Используем ChangeNotifierProvider для управления состоянием.
    ChangeNotifierProvider(create: (context) => Counter(), child: MyApp()),
  );
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        appBar: AppBar(title: Text('ChangeNotifierProvider Example')),
        body: Center(child: CounterDisplay()),
        floatingActionButton: IncrementButton(),
      ),
    );
  }
}

class CounterDisplay extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    // Слушаем изменения в Counter и обновляем UI.
    int counterValue = context.watch<Counter>().count;

    return Text('Count: $counterValue', style: TextStyle(fontSize: 24));
  }
}

class IncrementButton extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return FloatingActionButton(
      onPressed: () {
        // Получаем объект Counter и вызываем increment.
        context.read<Counter>().increment();
      },
      child: Icon(Icons.add),
    );
  }
}

Результат выполнения

Результат выполнения примера 3

StreamProvider

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

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';

// Асинхронный генератор чисел.
// Генерирует новое значение раз в секунду, начиная с 0.
Stream<int> counterStream() async* {
  int counter = 0;
  while (true) {
    await Future.delayed(Duration(seconds: 1));
    yield counter++;
  }
}

void main() {
  runApp(
    // Используем StreamProvider для работы с потоком.
    StreamProvider<int>(
      create: (context) => counterStream(),
      initialData:
          0, // Начальные данные, до того как в Stream добавится первое значение.
      child: MyApp(),
    ),
  );
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        appBar: AppBar(title: Text('StreamProvider Example')),
        body: Center(child: CounterDisplay()),
      ),
    );
  }
}

class CounterDisplay extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    // Получаем текущее значение счётчика из StreamProvider.
    int counterValue = context.watch<int>();

    return Text('Count: $counterValue', style: TextStyle(fontSize: 24));
  }
}

Результат выполнения

Результат выполнения примера 4

FutureProvider

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

import 'package:flutter/material.dart';
import 'package:provider/provider.dart';

// Имитация функции, которая загружает данные асинхронно.
// Тут может быть запрос в Сеть или локальную БД.
Future<int> fetchData() async {
  // Симулируем задержку в 2 секунды.
  await Future.delayed(Duration(seconds: 2));
  // Возвращаем значение, как будто оно было получено из Сети.
  return 42;
}

void main() {
  runApp(
    // Используем FutureProvider для загрузки данных асинхронно.
    FutureProvider<int>(
      create: (context) => fetchData(),
      initialData: -1, // Значение до завершения Future.
      child: MyApp(),
    ),
  );
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        appBar: AppBar(title: Text('FutureProvider Example')),
        body: Center(child: DataDisplay()),
      ),
    );
  }
}

class DataDisplay extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    // Получаем результат из FutureProvider.
    int data = context.watch<int>();

    // Если данные пока не загружены, показываем индикатор загрузки.
    if (data == -1) {
      return CircularProgressIndicator();
    }

    // Когда данные загружены, показываем их.
    return Text('Loaded data: $data', style: TextStyle(fontSize: 24));
  }
}

Результат выполнения

Результат выполнения примера 5

MultiProvider

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

Provider<Something>(
  create: (context) => Something(),
  child: Provider<SomethingElse>(
    create: (context) => SomethingElse(),
    child: Provider<AnotherThing>(
      create: (context) => AnotherThing(),
      child: someWidget,
    ),
  ),
),

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

MultiProvider(
  providers: [
    Provider<Something>(create: (context) => Something()),
    Provider<SomethingElse>(create: (context) => SomethingElse()),
    Provider<AnotherThing>(create: (context) => AnotherThing()),
  ],
  child: someWidget,
)

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

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

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

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

Provider(
  create: (context) => MyModel(),
  child: ...
)

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

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

MyModel model;

Provider.value(
  value: model,
  child: ...
)

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

В примерах выше мы часто использовали метод 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.

class Todo {
  final String id;
  final String title;
  final bool completed;

  Todo({required this.id, required this.title, this.completed = false});

  Todo copyWithCompleted({required bool completed}) => Todo(id: this.id, title: this.title, completed: completed);
}

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

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

import 'package:flutter/foundation.dart';

class TodoListModel extends ChangeNotifier {
  List<Todo> _todos = [];

  List<Todo> get todos => _todos;

  void addTodo(Todo todo) {
    _todos.add(todo);
    notifyListeners();
  }

  void removeTodo(String id) {
    _todos.removeWhere((todo) => todo.id == id);
    notifyListeners();
  }
}

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

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

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

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

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

void main() {
  runApp(
    ChangeNotifierProvider(
      create: (context) => TodoListModel(),
      child: MyApp(),
    ),
  );
}

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

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Todo List App',
      home: TodoListScreen(),
    );
  }
}

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

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

Реализация TodoListScreen
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';

class TodoListScreen extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final todoListModel = Provider.of<TodoListModel>(context);

    return Scaffold(
      appBar: AppBar(
        title: Text('Todo List'),
      ),
      body: ListView.builder(
        itemCount: todoListModel.todos.length,
        itemBuilder: (context, index) {
          final todo = todoListModel.todos[index];
          return ListTile(
            title: Text(todo.title),
            subtitle: Text('Completed: ${todo.completed}'),
          );
        },
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () {
          todoListModel.addTodo(Todo(id: DateTime.now().toString(), title: 'New Todo'));
        },
        child: Icon(Icons.add),
      ),
    );
  }
}

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

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

class TodoListModel extends ChangeNotifier {
  // Написанный ранее код.

  void updateCompleted({required String id, required bool completed}) {
    final index = _todos.indexWhere((t) => t.id == id);
    if (index != -1) {
      _todos[index] = _todos[index].copyWithCompleted(completed: completed);
      notifyListeners();
    }
  }
}

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

Обновлённая реализация TodoListScreen
class TodoListScreen extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final todoListModel = Provider.of<TodoListModel>(context);

    return Scaffold(
      appBar: AppBar(
        title: Text('Todo List'),
      ),
      body: ListView.builder(
        itemCount: todoListModel.todos.length,
        itemBuilder: (context, index) {
          final todo = todoListModel.todos[index];
          return ListTile(
            title: Text(todo.title),
            subtitle: Text('Completed: ${todo.completed}'),
            trailing: Checkbox(
              value: todo.completed,
              onChanged: (bool? newValue) {
                todoListModel.updateCompleted(
                  id: todo.id,
                  completed: newValue ?? false,
                );
              },
            ),
          );
        },
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () {
          todoListModel.addTodo(Todo(id: DateTime.now().toString(), title: 'New Todo'));
        },
        child: Icon(Icons.add),
      ),
    );
  }
}

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

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

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

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

class UserModel extends ChangeNotifier {
  String _username = '';

  String get username => _username;

  void setUsername(String username) {
    _username = username;
    notifyListeners();
  }
}

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

void main() {
  runApp(
    MultiProvider(
      providers: [
        ChangeNotifierProvider(create: (context) => TodoListModel()),
        ChangeNotifierProvider(create: (context) => UserModel()),
      ],
      child: MyApp(),
    ),
  );
}

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

class ProfileScreen extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final userModel = Provider.of<UserModel>(context);

    return Scaffold(
      appBar: AppBar(
        title: Text('Profile'),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Text('Username: ${userModel.username}'),
            ElevatedButton(
              onPressed: () {
                userModel.setUsername('NewUsername');
              },
              child: Text('Update Username'),
            ),
          ],
        ),
      ),
    );
  }
}

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

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

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

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

Пример:

@override
Widget build(BuildContext context) {
  return ChangeNotifierProvider(
    create: (context) => Foo(),
    child: Text(Provider.of<Foo>(context).value),
  );
}

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

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

@override
Widget build(BuildContext context) {
  return ChangeNotifierProvider(
    create: (context) => Foo(),
    child: Consumer<Foo>(
      builder: (context, foo, child) => Text(foo.value),
    },
  );
}

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

@override
 Widget build(BuildContext context) {
   return FooWidget(
     child: BarWidget(
       bar: Provider.of<Bar>(context),
     ),
   );
 }

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

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

 @override
 Widget build(BuildContext context) {
   return FooWidget(
     child: Consumer<Bar>(
       builder: (context, bar, child) => BarWidget(bar: bar),
     ),
   );
 }

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

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

 @override
 Widget build(BuildContext context) {
   return Consumer<Foo>(
     builder: (context, foo, child) => FooWidget(foo: foo, child: child),
     child: BarWidget(),
   );
 }

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

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

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

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

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

class TodoListScreen extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Consumer<TodoListModel>(
      builder: (context, todoListModel, child) {
        return Scaffold(
          appBar: AppBar(
            title: Text('Todo List'),
          ),
          body: ListView.builder(
            itemCount: todoListModel.todos.length,
            itemBuilder: (context, index) {
              final todo = todoListModel.todos[index];
              return ListTile(
                title: Text(todo.title),
                subtitle: Text('Completed: ${todo.completed}'),
                trailing: Checkbox(
                  value: todo.completed,
                  onChanged: (bool? newValue) {
                    todoListModel.updateCompleted(
                   id: todo.id,
                   completed: newValue ?? false,
                 );
                  },
                ),
              );
            },
          ),
          floatingActionButton: FloatingActionButton(
            onPressed: () {
              todoListModel.addTodo(Todo(id: DateTime.now().toString(), title: 'New Todo'));
            },
            child: Icon(Icons.add),
          ),
        );
      },
    );
  }
}

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

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

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

   await tester.pumpWidget(
     ChangeNotifierProvider(
       create: (context) => TodoListModel(),
       child: MyApp(),
     ),
   );

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

void main() {
  testWidgets('TodoListScreen adds todo', (WidgetTester tester) async {
    await tester.pumpWidget(
      ChangeNotifierProvider(
        create: (context) => TodoListModel(),
        child: MyApp(),
      ),
    );

    // Проверяем, что заголовок приложения отображается.
    expect(find.text('Todo List'), findsOneWidget);

    // Проверяем, что новой задачи ещё нет в списке.
    expect(find.text('New Todo'), findsNothing);

    // Нажимаем на кнопку добавления задачи.
    await tester.tap(find.byIcon(Icons.add));
    await tester.pump(); // Перестраиваем дерево виджетов.

    // Проверяем, что новая задача появилась в списке.
    expect(find.text('New Todo'), findsOneWidget);
  });
}

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

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