Привет, Хабр!

В наших среднесрочных планах — выход книги по Flutter. Относительно языка Dart как темы мы пока занимаем более осторожную позицию, поэтому попробуем оценить ее актуальность по результатам этой статьи. Речь в ней пойдет о пакете Provider и, следовательно, об управлении состоянием в Flutter.

Provider – это пакет для управления состояниями, написанный Реми Русле и взятый на вооружение в Google и в сообществе Flutter. Но что такое управление состоянием? Для начала, что такое состояние? Напомню, что состояние – это просто данные для представления UI в вашем приложении. Управление состоянием – это подход к созданию этих данных, доступа к ним, обращению с ними и избавления от них. Чтобы лучше понять пакет Provider, кратко обрисуем историю управления состоянием в Flutter.

1. StatefulWidget


StatelessWidget – простой компонент UI, который отображается, лишь когда у него есть данные. У StatelessWidget никакой “памяти” нет; он создается и уничтожается по мере необходимости. Во Flutter также есть StatefulWidget, в котором имеется память, благодаря ему долгоживущему спутнику – объекту State. В этом классе есть метод setState(), при вызове которого запускается виджет, который перестраивает состояние и отображает его в новом виде. Это простейшая форма управления состоянием во Flutter, предоставляемая «из коробки». Вот пример с кнопкой, на которой всегда отображается время последнего нажатия на нее:

class _MyWidgetState extends State<MyWidget> {
  DateTime _time = DateTime.now();  @override
  Widget build(BuildContext context) {
    return FlatButton(
      child: Text(_time.toString()),
      onPressed: () {
        setState(() => _time = DateTime.now());
      },
    );
  }
}

Итак, что же за проблема с таким подходом? Допустим, у вашего приложения есть некоторое глобальное состояние, сохраненное в корневом StatefulWidget. В нем находятся данные, которые предназначены для использования в самых разных частях UI. Эти данные являются разделяемыми и передаются каждому дочернему виджету в форме параметров. Любые события, при которых планируется менять эти данные, затем всплывают в виде обратных вызовов. Таким образом, через все промежуточные виджеты передается масса параметров и обратных вызовов, что вскоре может привести к путанице. Хуже того, любые обновления вышеупомянутого корня будут приводить к перестройке всего дерева виджетов, а это неэффективно.

2. InheritedWidget


InheritedWidget – это особый виджет, потомки которого могут обращаться к нему, не имея прямой ссылки. Просто обратившись к InheritedWidget, потребляющий виджет может зарегистрироваться на автоматическую перестройку, которая будет происходить при перестройке виджета-предка. Такой прием позволяет более эффективно организовать обновление UI. Вместо перестройки огромных кусков приложения в ответ на небольшое изменение состояния, можно прицельно выбирать лишь те конкретные виджеты, которые необходимо перестроить. Вы уже работали с InheritedWidget всякий раз, когда использовали MediaQuery.of(context) или Theme.of(context). Правда, менее вероятно, что вам доводилось реализовывать собственный InheritedWidget с сохранением состояния. Дело в том, что правильно реализовать их непросто.

3. ScopedModel


ScopedModel – это пакет, созданный в 2017 году Брайаном Иганом, он упрощает использование InheritedWidget для хранения состояния приложения. Сначала нужно создать объект состояния, наследующий от Model, а потом вызвать notifyListeners(), когда его свойства меняются. Ситуация напоминает реализацию интерфейса PropertyChangeListener в Java.

class MyModel extends Model {
  String _foo;  String get foo => _foo;
  
  void set foo(String value) {
    _foo = value;
    notifyListeners();  
  }
}

Чтобы предоставить наш объект состояния, мы оборачиваем этот объект в виджет ScopedModel в корне нашего приложения:

ScopedModel<MyModel>(
  model: MyModel(),
  child: MyApp(...)
)

Теперь любые виджеты-потомки смогут обращаться к MyModel при помощи виджета ScopedModelDescendant. Экземпляр модели передается в параметр builder:

class MyWidget extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return ScopedModelDescendant<MyModel>(
      builder: (context, child, model) => Text(model.foo),
    );
  }
}

Любой виджет-потомок также сможет обновлять модель, что автоматически спровоцирует перестройку любых ScopedModelDescendants (при условии, что наша модель правильно вызывает notifyListeners()):

class OtherWidget extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return FlatButton(
      child: Text('Update'),
      onPressed: () {
        final model = ScopedModel.of<MyModel>(context);
        model.foo = 'bar';
      },
    );
  }
}

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

4. BLoC


На конференции Google I/O ’18 был представлен паттерн Business Logic Component (BLoC), служащий в качестве еще одного инструмента, позволяющего вынести состояние из виджетов. Классы BLoC – это долгоживущие компоненты, не относящиеся к UI, сохраняющие состояние и предоставляющие его в виде потоков и приемников. Вынося состояние и бизнес-логику за пределы UI, можно реализовать виджет как простой StatelessWidget и использовать StreamBuilder для автоматической перестройки. В результате виджет «глупеет», и его становится проще тестировать.

Пример класса BLoC:

class MyBloc {
  final _controller = StreamController<MyType>();  Stream<MyType> get stream => _controller.stream;
  StreamSink<MyType> get sink => _controller.sink;
  
  myMethod() {
    // ВАШ КОД
    sink.add(foo);
  }  dispose() {
    _controller.close();
  }
}
Пример виджета, потребляющего  BLoC:
@override
Widget build(BuildContext context) {
 return StreamBuilder<MyType>(
  stream: myBloc.stream,
  builder: (context, asyncSnapshot) {
    // ВАШ КОД
 });
}

Проблема с паттерном BLoC заключается в том, что неочевидно, как создавать и разрушать BLoC-объекты. Как создавался экземпляр myBloc в вышеприведенном примере? Как мы вызываем dispose(), чтобы избавиться от него? Потоки требуют использовать StreamController, который должен быть closed, как только станет не нужен – это делается для предотвращения утечек в памяти. (В Dart нет такой вещи как деструктор классов; только класс State в StatefulWidget имеет метод dispose()). Кроме того, непонятно, как разделять этот BLoC между множеством виджетов. Зачастую разработчикам сложно осваивать BLoC. Есть несколько пакетов, при помощи которых предпринимаются попытки это упростить.

5. Provider


Provider – это пакет, написанный в 2018 году Реми Русле, похожий на ScopedModel, но функции которого не ограничиваются предоставлением подкласса Model. Это тоже обертка, заключающая InheritedWidget, но провайдер может предоставлять любые объекты состояния, в том числе, BLoC, потоки, футуры и другие. Поскольку провайдер так прост и гибок, Google анонсировала на конференции Google I/O ’19, что в дальнейшем Provider будет предпочтительным пакетом для управления состоянием. Разумеется, допускается и использование других пакетов, но, если у вас есть какие-либо сомнения, Google рекомендует остановиться на Provider.

Provider построен “с виджетами, для виджетов.” Provider позволяет поместить любой объект, обладающий состоянием, в дерево виджетов и открыть к нему доступ для любого другого виджета (потомка). Также Provider помогает управлять временем жизни объектов состояний, инициализируя их с данными и выполняя очистку после того, как они будут удалены из дерева виджетов. Поэтому Provider подходит даже для реализации компонентов BLoC или может служить основой для других решений по управлению состоянием! Либо просто применяться для внедрения зависимостей — причудливый термин, подразумевающий передачу данных в виджеты таким образом, который позволяет ослабить связанность и улучшить тестируемость кода. Наконец, Provider поставляется с набором специализированных классов, благодаря которым использовать его еще удобнее. Далее мы подробнее рассмотрим каждый из этих классов.

  • Базовый Provider
  • ChangeNotifierProvider
  • StreamProvider
  • FutureProvider
  • ValueListenableProvider
  • MultiProvider
  • ProxyProvider

Установка


Чтобы использовать Provider, сначала добавим зависимость в наш файл pubspec.yaml:

provider: ^3.0.0

Затем импортируем пакет Provider там, где это нужно:

import 'package:provider/provider.dart';

Базовый провайдер

Создадим базовый Provider в корне нашего приложения; здесь будет содержаться экземпляр нашей модели:

Provider<MyModel>(
  builder: (context) => MyModel(),
  child: MyApp(...),
)

Параметр builder создает экземпляр MyModel. Если вы хотите передать ему уже имеющийся экземпляр, используйте здесь конструктор Provider.value.

Затем можно потреблять этот экземпляр модели где угодно в MyApp, воспользовавшись виджетом Consumer:

class MyWidget extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Consumer<MyModel>(
      builder: (context, value, child) => Text(value.foo),
    );
  }
}

В вышеприведенном примере класс MyWidget получает экземпляр MyModel при помощи виджета Consumer. Этот виджет дает нам builder, содержащий наш объект в параметре value.

Теперь, что нам делать, если мы хотим обновить данные в нашей модели? Допустим, у нас есть другой виджет, где при нажатии кнопки должно обновляться свойство foo:

class OtherWidget extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return FlatButton(
      child: Text('Update'),
      onPressed: () {
        final model = Provider.of<MyModel>(context);
        model.foo = 'bar';
      },
    );
  }
}

Обратите внимание на характерный синтаксис, используемый для доступа к экземпляру MyModel. Функционально это эквивалентно обращению к виджету Consumer. Виджет Consumer полезен в случаях, когда в коде не удается легко получить ссылку BuildContext.

Как вы думаете, что произойдет с исходным виджетом MyWidget, который мы создали ранее? Отобразится ли в нем новое значение bar? К сожалению, нет. Не предусмотрена возможность слушания изменений в старых традиционных объектах Dart (как минимум, без рефлексии, которая во Flutter не предоставляется). Таким образом, Provider не сможет “увидеть”, что мы должным образом обновили свойство foo и приказать виджету MyWidget обновиться в ответ.

ChangeNotifierProvider

Но надежда есть! Можно сделать так, чтобы наш класс MyModel реализовывал примесь ChangeNotifier. Потребуется немного изменить реализацию нашей модели и вызывать специальный метод notifyListeners() всякий раз, когда меняется одно из наших свойств. Примерно таким образом работает ScopedModel, но в данном случае приятно, что не требуется наследовать от конкретного класса модели. Достаточно лишь реализовать примесь ChangeNotifier. Вот как это выглядит:

class MyModel with ChangeNotifier {
  String _foo;  String get foo => _foo;
  
  void set foo(String value) {
    _foo = value;
    notifyListeners();  
  }
}

Как видите, мы заменили наше свойство foo на getter и setter, подкрепленные приватной переменной _foo. Так мы сможем «перехватывать» любые изменения, вносимые в свойство foo, и сообщать нашим слушателям, что наш объект изменился.

Теперь, со стороны Provider, можно изменить нашу реализацию так, чтобы она использовала иной класс под названием ChangeNotifierProvider:

ChangeNotifierProvider<MyModel>(
  builder: (context) => MyModel(),
  child: MyApp(...),
)

Вот так! Теперь, когда наш OtherWidget обновляет свойство foo в экземпляре MyModel, MyWidget автоматически обновится, чтобы отразить это изменение. Круто, правда?

Кстати. Вероятно, вы заметили обработчик кнопки OtherWidget, с которым мы использовали следующий синтаксис:

final model = Provider.of<MyModel>(context);

По умолчанию такой синтаксис автоматически вызовет перестройку экземпляра OtherWidget, как только изменится модель MyModel. Возможно, нам это и не нужно. В конце концов, OtherWidget просто содержит кнопку, которая совершенно не меняется при изменении значения MyModel. Чтобы избежать перестройки, можно воспользоваться следующим синтаксисом для обращения к нашей модели без регистрации на перестройку:

final model = Provider.of<MyModel>(context, listen: false);

Это еще одна прелесть, предоставляемая в пакете Provider просто так.

StreamProvider

На первый взгляд непонятно, зачем нужен StreamProvider. В конце концов, можно просто воспользоваться обычным StreamBuilder, если нужно потребить поток в Flutter. Например, здесь мы слушаем поток onAuthStateChanged, предоставляемый FirebaseAuth:

@override
Widget build(BuildContext context {
  return StreamBuilder(
   stream: FirebaseAuth.instance.onAuthStateChanged, 
   builder: (BuildContext context, AsyncSnapshot snapshot){ 
     ...
   });
}

Чтобы сделать то же самое при помощи Provider, можно было бы предоставить наш поток через StreamProvider в корне нашего приложения:

StreamProvider<FirebaseUser>.value(
  stream: FirebaseAuth.instance.onAuthStateChanged,
  child: MyApp(...),
}

Затем потребить дочерний виджет, как это обычно делается при помощи Provider:

@override
Widget build(BuildContext context) {
  return Consumer<FirebaseUser>(
    builder: (context, value, child) => Text(value.displayName),
  );
}

Мало того, что наш код потребляющего виджета стал гораздо чище, в нем теперь еще и абстрагирован тот факт, что данные поступили из потока. Если мы когда-нибудь решим изменить базовую реализацию, к примеру, на FutureProvider, то никаких изменений в код виджета вносить не потребуется. Как вы убедитесь, это касается и всех прочих провайдеров, показанных ниже.

FutureProvider

Аналогично вышеприведенному примеру, FutureProvider – это альтернатива стандартному FutureBuilder при работе с виджетами. Вот пример:

FutureProvider<FirebaseUser>.value(
  value: FirebaseAuth.instance.currentUser(),
  child: MyApp(...),
);

Чтобы потребить это значение в дочернем виджете, мы используем все ту же реализацию Consumer, что и в примере с StreamProvider выше.

ValueListenableProvider

ValueListenable – это интерфейс Dart, реализуемый классом ValueNotifier, который принимает значение и уведомляет слушатели, когда оно меняется на другое значение. В него можно, например, обернуть целочисленный счетчик в простом классе модели:

class MyModel {
  final ValueNotifier<int> counter = ValueNotifier(0);  
}

При работе со сложными типами ValueNotifier использует оператор == хранящегося в нем объекта, чтобы определить, изменилось ли значение.
Давайте создадим простейший Provider, в котором будет содержаться наша главная модель, а за ней будет следовать Consumer и вложенный ValueListenableProvider, слушающий свойство counter:

Provider<MyModel>(
  builder: (context) => MyModel(),
  child: Consumer<MyModel>(builder: (context, value, child) {
    return ValueListenableProvider<int>.value(
      value: value.counter,
      child: MyApp(...)
    }
  }
}

Обратите внимание, что этот вложенный провайдер относится к типу int. Могут быть и другие. Если у вас зарегистрировано несколько провайдеров одного и того же типа, Provider вернет “ближайший” (ближайшего предка).

Вот как слушать свойство counter из любого виджета-потомка:

class MyWidget extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Consumer<int>(
      builder: (context, value, child) {
        return Text(value.toString());
      },
    );
  }
}

А вот как обновить свойство counter еще из одного виджета. Обратите внимание: нам нужен доступ к оригинальному экземпляру MyModel.

class OtherWidget extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return FlatButton(
      child: Text('Update'),
      onPressed: () {
        final model = Provider.of<MyModel>(context);
        model.counter.value++;
      },
    );
  }
}

MultiProvider

Если вы используете множество виджетов Provider, то в корне приложения получается уродливая структура из множества вложений:

Provider<Foo>.value( 
  value: foo, 
  child: Provider<Bar>.value( 
    value: bar, 
    child: Provider<Baz>.value( 
      value: baz , 
      child: MyApp(...)
    ) 
  ) 
)

MultiProvider позволяет объявить их все на одном уровне. Это просто синтаксический сахар: на внутрисистемном уровне все они все равно остаются вложенными.

MultiProvider( 
  providers: [ 
    Provider<Foo>.value(value: foo), 
    Provider<Bar>.value(value: bar), 
    Provider<Baz>.value(value: baz), 
  ], 
  child: MyApp(...), 
)

ProxyProvider

ProxyProvider – интересный класс, добавленный в третьем релизе пакета Provider. Он позволяет объявлять провайдеры, которые сами могут зависеть от других провайдеров, вплоть до шести на один. В данном примере класс Bar зависит от экземпляра Foo. Это полезно при составлении корневого набора сервисов, которые сами зависят друг от друга.

MultiProvider ( 
  providers: [ 
    Provider<Foo> ( 
      builder: (context) => Foo(),
    ), 
    ProxyProvider<Foo, Bar>(
      builder: (context, value, previous) => Bar(value),
    ), 
  ], 
  child: MyApp(...),
)

Первый обобщенный аргумент типа – это тип, от которого зависит ваш ProxyProvider, а второй – тип, который он возвращает.

Как одновременно слушать множество провайдеров


Что, если мы хотим, чтобы единственный виджет слушал множество провайдеров и перестраивался при изменении любого из них? Можно слушать до 6 провайдеров одновременно, используя варианты виджета Consumer. Мы будем получать экземпляры как дополнительные параметры метода builder.

Consumer2<MyModel, int>(
  builder: (context, value, value2, child) {
    //value равно MyModel
    //value2 равно int
  },
);

Заключение


При использовании InheritedWidget Provider позволяет управлять состоянием так, как принято во Flutter. Он позволяет виджетам обращаться к объектам состояния и слушать их таким образом, что абстрагируется основополагающий механизм уведомлений. Так проще управлять временем жизни объектов состояния, создавая точки привязки, чтобы создавать эти объекты по мере необходимости и избавляться от них, когда нужно. Этот механизм может применяться для простого внедрения зависимостей и даже как основа для более расширенных вариантов управления состоянием. Заручившись благословением Google и растущей поддержкой в сообществе Flutter, Provider превратился в тот пакет, который стоит попробовать, не откладывая!