Как устроено состояние во Flutter: локальное состояние, данные приложения, InheritedWidget и Provider

от автора

Привет, Хабр! Это Аня, руководитель Flutter-команды Friflex, и Катя, Flutter-разработчица Friflex.

В этой статье разберем, что такое состояние во Flutter. Катя расскажет, как отличать локальное состояние от состояния приложения и в каких случаях достаточно setState(). Аня покажет, как передавать данные по дереву виджетов с помощью InheritedWidget, и разберет, как устроен Provider.

Что такое состояние

В самом широком смысле, состояние или state — это все, что хранится в памяти приложения во время его работы: шрифты, текстуры, анимации, UI, переменные.

Но управляем мы далеко не всем. Например, за текстуры отвечает сам Flutter. Нам важнее другое определение: state — это данные, от которых зависит построение пользовательского интерфейса и изменение которых может привести к перестроению UI.

Управляемые состояния Flutter-приложения можно разделить на два вида — эфемерное состояние (Ephemeral state) и состояние приложения (App state).

Что такое эфемерное состояние

Эфемерное состояние еще можно назвать локальным состоянием. Это понятие очень точно описывает саму суть — такой вид состояния управляет только одним виджетом и не распространяется вне его контекста. Оно живет только вместе с конкретным виджетом, к которому привязано.

Примеры:

  • текущая страница в PageView;

  • выбранный таб в BottomNavigationBar;

  • прогресс анимации.

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

Самая простая реализация эфемерного состояния — использование StatefulWidget и метода setState().

Для еще более простого понимания рассмотрим пример кастомного текстового поля с кнопкой очистки, которая должна отображаться, когда поле заполняется текстом. Создадим StatefulWidget CustomTextField, State которого будет выглядеть так:

class _CustomTextFieldState extends State<CustomTextField> {  late final TextEditingController controller;  bool showClearButton = false;  @override  void initState() {    super.initState();    controller = TextEditingController();    controller.addListener(      () => setState(() => showClearButton = controller.text.isNotEmpty),    );  }  @override  Widget build(BuildContext context) {    return TextField(      controller: controller,      decoration: InputDecoration(        suffixIcon: showClearButton            ? IconButton(                icon: const Icon(Icons.clear),                onPressed: () {                  controller.clear();                  setState(() {                    showClearButton = false;                  });                },              )            : null,      ),    );  }  @override  void dispose() {    controller.dispose();    super.dispose();  }}

Здесь мы видим очень простой пример эфемерного состояния.

Пример с BottomNavigationBar:

class MyHomepage extends StatefulWidget {  const MyHomepage({super.key});  @override  State<MyHomepage> createState() => _MyHomepageState();}class _MyHomepageState extends State<MyHomepage> {  int _index = 0;  @override  Widget build(BuildContext context) {    return BottomNavigationBar(      currentIndex: _index,      onTap: (newIndex) {        setState(() {          _index = newIndex;        });      },      // ... items ...    );  }}

Здесь _index — это ephemeral state. Он живет только в этом экране и спокойно сбрасывается при перезапуске приложения.

Что такое состояние приложения

App state — это состояние, которое влияет на несколько виджетов. Оно может затрагивать и разные части приложения, например, разные экраны.

App state также называют shared state. Это данные, которые нужны разным экранам или частям приложения и могут определять ключевую бизнес-логику. При необходимости приложение может сохранять такое состояние между пользовательскими сессиями. 

Например:

  • данные пользователя: логин, токен;

  • настройки и предпочтения;

  • корзина в e-commerce;

  • уведомления или список непрочитанных статей.

В качестве примера можно привести классическую функцию всех приложений для электронной торговли — корзину товаров.

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

Часто управление такими состояниями реализуется с помощью стейт-менеджеров, например, Bloc или Redux.

Для управления app state часто используют:

  • Provider / Riverpod — декларативный state management;

  • Redux / BLoC — для сложных приложений;

  • Hive / SharedPreferences — для сохранения на диск.

Важно понимать: граница между ephemeral state и app state условная.

Например, выбранный таб в BottomNavigationBar может быть ephemeral state, если он нужен только внутри экрана, но стать app state, если нужно восстанавливать его при повторном входе и он влияет на другие части приложения.

Соавтор Redux, Дэн Абрамов, говорил: используйте то решение, которое проще и естественнее именно в вашем случае (The rule of thumb is: Do whatever is less awkward). 

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

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

Что такое InheritedWidget и как он работает

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

Для примера создадим виджет CustomInherited.

class CustomInherited extends InheritedWidget {  const CustomInherited({    required this.data,    required Widget child,  }) : super(child: child);  final String data;  static CustomInherited? of(BuildContext context) =>      context.dependOnInheritedWidgetOfExactType<CustomInherited>();  @override  bool updateShouldNotify(CustomInherited oldWidget) =>      data != oldWidget.data;}

CustomInherited реализует статический метод of(context). Под капотом этот метод вызывает context.dependOnInheritedWidgetOfExactType<T>(), который в свою очередь по переданному контексту выполняет поиск самого ближайшего экземпляра виджета типа CustomInherited и возвращает его.

Теперь CustomInherited готов к внедрению в дерево виджетов.

CustomInherited(  data: 'inherited data',  child: Builder(    builder: (context) {      return Container(        padding: const EdgeInsets.all(16),        child: Text(CustomInherited.of(context)?.data ?? ''),      );    },  ),)

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

По необходимости официальная документация Flutter предлагает создавать два метода — maybeOf(context) и of(context), где первый возвращает nullable-экземпляр, а второй — non-nullable.

static CustomInherited? maybeOf(BuildContext context) {  return context.dependOnInheritedWidgetOfExactType<CustomInherited>();}static CustomInherited of(BuildContext context) {  final result = maybeOf(context);  assert(result != null, 'No CustomInherited found in context');  return result!;}

Также InheritedWidget обязует нас переопределять метод updateShouldNotify. Этот метод возвращает bool и определяет, нужно ли уведомлять зависимые виджеты при обновлении InheritedWidget.

Что такое Provider и как он работает

Библиотека provider — одна из наиболее известных во Flutter-сообществе. Provider построен поверх InheritedWidget и предоставляет более удобный API для передачи зависимостей и управления состоянием. 

Здесь важно понять, что Provider работает по тому же принципу, что и InheritedWidget — данные прокидываются по дереву виджетов, а доступ к ним в любой момент можно получить по контексту. 

Передача данных

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

Реализовать это можно двумя способами.

С помощью стандартного конструктора Provider()

Здесь создание источника данных выполняется через функцию create.

Provider(  create: (context) => CustomData(),  child: Container(),)

С помощью конструктора Provider.value()

Этот конструктор не будет создавать новый экземпляр источника данных — он требует передачу уже ранее созданного.

final customData = CustomData();Provider.value(  value: customData,  child: Container(),)

При необходимости провайдить несколько источников данных можно использовать MultiProvider.

Кроме этого библиотека дает доступ к множеству разных видов провайдера, например StreamProvider и FutureProvider.

Чтение данных

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

  1. context.read<T>() — получает текущее значение без подписки на изменения.

  2. context.watch<T>() — подписывается на изменения значения и вызывает перестроение текущего виджета при изменении.

  3. context.select<T, R>(R cb(T value)) — прослушивает только указанную часть данных, например одно поле.

Библиотека также дает доступ к виджетам Consumer и Selector. Они так же, как методы context.watch и context.select, прослушивают изменения данных провайдера, но позволяют ребилдить не весь текущий виджет, а только дочерние.

class Example extends StatelessWidget {  const Example({super.key});  @override  Widget build(BuildContext context) {    return Column(      children: [        const Text('Строка 1'),        Text(context.watch<CustomData>().data),        // заставит перестроиться весь виджет Example        const Text('Строка 3'),      ],    );  }}class Example2 extends StatelessWidget {  const Example2({super.key});  @override  Widget build(BuildContext context) {    return Column(      children: [        const Text('Строка 1'),        Consumer<CustomData>(          // перестроит только дочерний Text          builder: (context, customData, _) {            return Text(customData.data);          },        ),        const Text('Строка 3'),      ],    );  }}

Еще больше полезной информации можно найти в документации классов библиотеки, а также в README.

А по каким признакам вы понимаете, что локального setState() уже недостаточно и состояние пора поднимать выше по дереву или выносить в отдельный state manager?

ссылка на оригинал статьи https://habr.com/ru/articles/1054946/