
Привет, Хабр! Это Аня, руководитель 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.
Чтение данных
Далее по дереву от экземпляра провайдера мы можем получить переданные данные через контекст. Для этого существует три метода:
-
context.read<T>() — получает текущее значение без подписки на изменения.
-
context.watch<T>() — подписывается на изменения значения и вызывает перестроение текущего виджета при изменении.
-
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/